Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions bindings/ldk_node.udl
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,6 +25,18 @@ dictionary EsploraSyncConfig {
u64 fee_rate_cache_update_interval_secs;
};

dictionary LSPS2ServiceConfig {
string? require_token;
boolean advertise_service;
u32 channel_opening_fee_ppm;
u32 channel_over_provisioning_ppm;
u64 min_channel_opening_fee_msat;
u32 min_channel_lifetime;
u32 max_client_to_self_delay;
u64 min_payment_size_msat;
u64 max_payment_size_msat;
};

enum LogLevel {
"Gossip",
"Trace",
Expand Down
159 changes: 113 additions & 46 deletions src/builder.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,7 +18,9 @@ use crate::gossip::GossipSource;
use crate::io::sqlite_store::SqliteStore;
use crate::io::utils::{read_node_metrics, write_node_metrics};
use crate::io::vss_store::VssStore;
use crate::liquidity::LiquiditySourceBuilder;
use crate::liquidity::{
LSPS1ClientConfig, LSPS2ClientConfig, LSPS2ServiceConfig, LiquiditySourceBuilder,
};
use crate::logger::{log_error, log_info, LdkLogger, LogLevel, LogWriter, Logger};
use crate::message_handler::NodeCustomMessageHandler;
use crate::payment::store::PaymentStore;
Expand DownExpand Up@@ -75,6 +77,10 @@ use std::sync::{Arc, Mutex, RwLock};
use std::time::SystemTime;
use vss_client::headers::{FixedHeaders, LnurlAuthToJwtProvider, VssHeaderProvider};

const VSS_HARDENED_CHILD_INDEX: u32 = 877;
const VSS_LNURL_AUTH_HARDENED_CHILD_INDEX: u32 = 138;
const LSPS_HARDENED_CHILD_INDEX: u32 = 577;

#[derive(Debug, Clone)]
enum ChainDataSourceConfig {
Esplora { server_url: String, sync_config: Option<EsploraSyncConfig> },
Expand All@@ -94,18 +100,14 @@ enum GossipSourceConfig {
RapidGossipSync(String),
}

#[derive(Debug, Clone)]
#[derive(Debug, Clone, Default)]
struct LiquiditySourceConfig {
// LSPS1 service's (node_id, address, token)
lsps1_service: Option<(PublicKey, SocketAddress, Option<String>)>,
// LSPS2 service's (node_id, address, token)
lsps2_service: Option<(PublicKey, SocketAddress, Option<String>)>,
}

impl Default for LiquiditySourceConfig {
fn default() -> Self {
Self { lsps1_service: None, lsps2_service: None }
}
// Act as an LSPS1 client connecting to the given service.
lsps1_client: Option<LSPS1ClientConfig>,
// Act as an LSPS2 client connecting to the given service.
lsps2_client: Option<LSPS2ClientConfig>,
// Act as an LSPS2 service.
lsps2_service: Option<LSPS2ServiceConfig>,
}

#[derive(Clone)]
Expand DownExpand Up@@ -319,7 +321,8 @@ impl NodeBuilder {

let liquidity_source_config =
self.liquidity_source_config.get_or_insert(LiquiditySourceConfig::default());
liquidity_source_config.lsps1_service = Some((node_id, address, token));
let lsps1_client_config = LSPS1ClientConfig { node_id, address, token };
liquidity_source_config.lsps1_client = Some(lsps1_client_config);
self
}

Expand All@@ -339,7 +342,23 @@ impl NodeBuilder {

let liquidity_source_config =
self.liquidity_source_config.get_or_insert(LiquiditySourceConfig::default());
liquidity_source_config.lsps2_service = Some((node_id, address, token));
let lsps2_client_config = LSPS2ClientConfig { node_id, address, token };
liquidity_source_config.lsps2_client = Some(lsps2_client_config);
self
}

/// Configures the [`Node`] instance to provide an [LSPS2] service, issuing just-in-time
/// channels to clients.
///
/// **Caution**: LSP service support is in **alpha** and is considered an experimental feature.
///
/// [LSPS2]: https://github.com/BitcoinAndLightningLayerSpecs/lsp/blob/main/LSPS2/README.md
pub fn set_liquidity_provider_lsps2(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know we have probably used setX in names because it is builder but it is getting slightly confusing with lsps client and service.
Personally, I was slightly confused due to the fact "we need to set liquidity provider to act as lsps-client".

no strong opinion, but imo, these methods could be renamed to enable_lsps2_service, enable_lsps2_client and so on.
what do you think?

alternatively, we can add another layer here similar to .bolt11() and .bolt12().

@tnulltnullFeb 14, 2025

Copy link
Copy Markdown
CollaboratorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, thanks for bringing this up. I see you point in that theset_liquidity_provider case that could be a bit confusing, but still think I'd like to keep the set_ pattern everywhere for now. For one, it follows the Rust API guidelines for naming setters. Also note that once we break the pattern, we might need to revisit all of the builder methods, which a) would be a larger refactor out-of-scope of this PR and b) would make the API less predicatable, IMO. But noted.

&mut self, service_config: LSPS2ServiceConfig,
) -> &mut Self {
let liquidity_source_config =
self.liquidity_source_config.get_or_insert(LiquiditySourceConfig::default());
liquidity_source_config.lsps2_service = Some(service_config);
self
}

Expand DownExpand Up@@ -471,10 +490,14 @@ impl NodeBuilder {

let config = Arc::new(self.config.clone());

let vss_xprv = derive_vss_xprv(config, &seed_bytes, Arc::clone(&logger))?;
let vss_xprv =
derive_xprv(config, &seed_bytes, VSS_HARDENED_CHILD_INDEX, Arc::clone(&logger))?;

let lnurl_auth_xprv = vss_xprv
.derive_priv(&Secp256k1::new(), &[ChildNumber::Hardened { index: 138 }])
.derive_priv(
&Secp256k1::new(),
&[ChildNumber::Hardened { index: VSS_LNURL_AUTH_HARDENED_CHILD_INDEX }],
)
.map_err(|e| {
log_error!(logger, "Failed to derive VSS secret: {}", e);
BuildError::KVStoreSetupFailed
Expand DownExpand Up@@ -536,7 +559,12 @@ impl NodeBuilder {

let config = Arc::new(self.config.clone());

let vss_xprv = derive_vss_xprv(config.clone(), &seed_bytes, Arc::clone(&logger))?;
let vss_xprv = derive_xprv(
config.clone(),
&seed_bytes,
VSS_HARDENED_CHILD_INDEX,
Arc::clone(&logger),
)?;

let vss_seed_bytes: [u8; 32] = vss_xprv.private_key.secret_bytes();

Expand DownExpand Up@@ -691,6 +719,16 @@ impl ArcedNodeBuilder {
self.inner.write().unwrap().set_liquidity_source_lsps2(node_id, address, token);
}

/// Configures the [`Node`] instance to provide an [LSPS2] service, issuing just-in-time
/// channels to clients.
///
/// **Caution**: LSP service support is in **alpha** and is considered an experimental feature.
///
/// [LSPS2]: https://github.com/BitcoinAndLightningLayerSpecs/lsp/blob/main/LSPS2/README.md
pub fn set_liquidity_provider_lsps2(&self, service_config: LSPS2ServiceConfig) {
self.inner.write().unwrap().set_liquidity_provider_lsps2(service_config);
}

/// 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);
Expand DownExpand Up@@ -1039,7 +1077,7 @@ fn build_with_store_internal(
};

let mut user_config = default_user_config(&config);
if liquidity_source_config.and_then(|lsc| lsc.lsps2_service.as_ref()).is_some() {
if liquidity_source_config.and_then(|lsc| lsc.lsps2_client.as_ref()).is_some() {
// Generally allow claiming underpaying HTLCs as the LSP will skim off some fee. We'll
// check that they don't take too much before claiming.
user_config.channel_config.accept_underpaying_htlcs = true;
Expand All@@ -1051,6 +1089,12 @@ fn build_with_store_internal(
100;
}

if liquidity_source_config.and_then(|lsc| lsc.lsps2_service.as_ref()).is_some() {
// If we act as an LSPS2 service, we need to to be able to intercept HTLCs and forward the
// information to the service handler.
user_config.accept_intercept_htlcs = true;
}

let message_router =
Arc::new(MessageRouter::new(Arc::clone(&network_graph), Arc::clone(&keys_manager)));

Expand DownExpand Up@@ -1171,31 +1215,53 @@ fn build_with_store_internal(
},
};

let liquidity_source = liquidity_source_config.as_ref().map(|lsc| {
let mut liquidity_source_builder = LiquiditySourceBuilder::new(
Arc::clone(&channel_manager),
Arc::clone(&keys_manager),
Arc::clone(&chain_source),
Arc::clone(&config),
Arc::clone(&logger),
);

lsc.lsps1_service.as_ref().map(|(node_id, address, token)| {
liquidity_source_builder.lsps1_service(*node_id, address.clone(), token.clone())
});
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.lsps2_service.as_ref().map(|(node_id, address, token)| {
liquidity_source_builder.lsps2_service(*node_id, address.clone(), token.clone())
});
lsc.lsps1_client.as_ref().map(|config| {
liquidity_source_builder.lsps1_client(
config.node_id,
config.address.clone(),
config.token.clone(),
)
});

Arc::new(liquidity_source_builder.build())
});
lsc.lsps2_client.as_ref().map(|config| {
liquidity_source_builder.lsps2_client(
config.node_id,
config.address.clone(),
config.token.clone(),
)
});

let custom_message_handler = if let Some(liquidity_source) = liquidity_source.as_ref() {
Arc::new(NodeCustomMessageHandler::new_liquidity(Arc::clone(&liquidity_source)))
} else {
Arc::new(NodeCustomMessageHandler::new_ignoring())
};
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)
} else {
(None, Arc::new(NodeCustomMessageHandler::new_ignoring()))
};

let msg_handler = match gossip_source.as_gossip_sync() {
GossipSync::P2P(p2p_gossip_sync) => MessageHandler {
Expand DownExpand Up@@ -1397,8 +1463,8 @@ fn seed_bytes_from_config(
}
}

fn derive_vss_xprv(
config: Arc<Config>, seed_bytes: &[u8; 64], logger: Arc<Logger>,
fn derive_xprv(
config: Arc<Config>, seed_bytes: &[u8; 64], hardened_child_index: u32, logger: Arc<Logger>,
) -> Result<Xpriv, BuildError> {
use bitcoin::key::Secp256k1;

Expand All@@ -1407,10 +1473,11 @@ fn derive_vss_xprv(
BuildError::InvalidSeedBytes
})?;

xprv.derive_priv(&Secp256k1::new(), &[ChildNumber::Hardened { index: 877 }]).map_err(|e| {
log_error!(logger, "Failed to derive VSS secret: {}", e);
BuildError::KVStoreSetupFailed
})
xprv.derive_priv(&Secp256k1::new(), &[ChildNumber::Hardened { index: hardened_child_index }])
.map_err(|e| {
log_error!(logger, "Failed to derive hardened child secret: {}", e);
BuildError::InvalidSeedBytes
})
}

/// Sanitize the user-provided node alias to ensure that it is a valid protocol-specified UTF-8 string.
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Add bLIP-52 / LSPS2 service-side support by tnull · Pull Request #420 · lightningdevkit/ldk-node · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions bindings/ldk_node.udl
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,6 +25,18 @@ dictionary EsploraSyncConfig {
u64 fee_rate_cache_update_interval_secs;
};

dictionary LSPS2ServiceConfig {
string? require_token;
boolean advertise_service;
u32 channel_opening_fee_ppm;
u32 channel_over_provisioning_ppm;
u64 min_channel_opening_fee_msat;
u32 min_channel_lifetime;
u32 max_client_to_self_delay;
u64 min_payment_size_msat;
u64 max_payment_size_msat;
};

enum LogLevel {
"Gossip",
"Trace",
Expand Down
159 changes: 113 additions & 46 deletions src/builder.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,7 +18,9 @@ use crate::gossip::GossipSource;
use crate::io::sqlite_store::SqliteStore;
use crate::io::utils::{read_node_metrics, write_node_metrics};
use crate::io::vss_store::VssStore;
use crate::liquidity::LiquiditySourceBuilder;
use crate::liquidity::{
LSPS1ClientConfig, LSPS2ClientConfig, LSPS2ServiceConfig, LiquiditySourceBuilder,
};
use crate::logger::{log_error, log_info, LdkLogger, LogLevel, LogWriter, Logger};
use crate::message_handler::NodeCustomMessageHandler;
use crate::payment::store::PaymentStore;
Expand DownExpand Up@@ -75,6 +77,10 @@ use std::sync::{Arc, Mutex, RwLock};
use std::time::SystemTime;
use vss_client::headers::{FixedHeaders, LnurlAuthToJwtProvider, VssHeaderProvider};

const VSS_HARDENED_CHILD_INDEX: u32 = 877;
const VSS_LNURL_AUTH_HARDENED_CHILD_INDEX: u32 = 138;
const LSPS_HARDENED_CHILD_INDEX: u32 = 577;

#[derive(Debug, Clone)]
enum ChainDataSourceConfig {
Esplora { server_url: String, sync_config: Option<EsploraSyncConfig> },
Expand All@@ -94,18 +100,14 @@ enum GossipSourceConfig {
RapidGossipSync(String),
}

#[derive(Debug, Clone)]
#[derive(Debug, Clone, Default)]
struct LiquiditySourceConfig {
// LSPS1 service's (node_id, address, token)
lsps1_service: Option<(PublicKey, SocketAddress, Option<String>)>,
// LSPS2 service's (node_id, address, token)
lsps2_service: Option<(PublicKey, SocketAddress, Option<String>)>,
}

impl Default for LiquiditySourceConfig {
fn default() -> Self {
Self { lsps1_service: None, lsps2_service: None }
}
// Act as an LSPS1 client connecting to the given service.
lsps1_client: Option<LSPS1ClientConfig>,
// Act as an LSPS2 client connecting to the given service.
lsps2_client: Option<LSPS2ClientConfig>,
// Act as an LSPS2 service.
lsps2_service: Option<LSPS2ServiceConfig>,
}

#[derive(Clone)]
Expand DownExpand Up@@ -319,7 +321,8 @@ impl NodeBuilder {

let liquidity_source_config =
self.liquidity_source_config.get_or_insert(LiquiditySourceConfig::default());
liquidity_source_config.lsps1_service = Some((node_id, address, token));
let lsps1_client_config = LSPS1ClientConfig { node_id, address, token };
liquidity_source_config.lsps1_client = Some(lsps1_client_config);
self
}

Expand All@@ -339,7 +342,23 @@ impl NodeBuilder {

let liquidity_source_config =
self.liquidity_source_config.get_or_insert(LiquiditySourceConfig::default());
liquidity_source_config.lsps2_service = Some((node_id, address, token));
let lsps2_client_config = LSPS2ClientConfig { node_id, address, token };
liquidity_source_config.lsps2_client = Some(lsps2_client_config);
self
}

/// Configures the [`Node`] instance to provide an [LSPS2] service, issuing just-in-time
/// channels to clients.
///
/// **Caution**: LSP service support is in **alpha** and is considered an experimental feature.
///
/// [LSPS2]: https://github.com/BitcoinAndLightningLayerSpecs/lsp/blob/main/LSPS2/README.md
pub fn set_liquidity_provider_lsps2(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know we have probably used setX in names because it is builder but it is getting slightly confusing with lsps client and service.
Personally, I was slightly confused due to the fact "we need to set liquidity provider to act as lsps-client".

no strong opinion, but imo, these methods could be renamed to enable_lsps2_service, enable_lsps2_client and so on.
what do you think?

alternatively, we can add another layer here similar to .bolt11() and .bolt12().

@tnulltnullFeb 14, 2025

Copy link
Copy Markdown
CollaboratorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, thanks for bringing this up. I see you point in that theset_liquidity_provider case that could be a bit confusing, but still think I'd like to keep the set_ pattern everywhere for now. For one, it follows the Rust API guidelines for naming setters. Also note that once we break the pattern, we might need to revisit all of the builder methods, which a) would be a larger refactor out-of-scope of this PR and b) would make the API less predicatable, IMO. But noted.

&mut self, service_config: LSPS2ServiceConfig,
) -> &mut Self {
let liquidity_source_config =
self.liquidity_source_config.get_or_insert(LiquiditySourceConfig::default());
liquidity_source_config.lsps2_service = Some(service_config);
self
}

Expand DownExpand Up@@ -471,10 +490,14 @@ impl NodeBuilder {

let config = Arc::new(self.config.clone());

let vss_xprv = derive_vss_xprv(config, &seed_bytes, Arc::clone(&logger))?;
let vss_xprv =
derive_xprv(config, &seed_bytes, VSS_HARDENED_CHILD_INDEX, Arc::clone(&logger))?;

let lnurl_auth_xprv = vss_xprv
.derive_priv(&Secp256k1::new(), &[ChildNumber::Hardened { index: 138 }])
.derive_priv(
&Secp256k1::new(),
&[ChildNumber::Hardened { index: VSS_LNURL_AUTH_HARDENED_CHILD_INDEX }],
)
.map_err(|e| {
log_error!(logger, "Failed to derive VSS secret: {}", e);
BuildError::KVStoreSetupFailed
Expand DownExpand Up@@ -536,7 +559,12 @@ impl NodeBuilder {

let config = Arc::new(self.config.clone());

let vss_xprv = derive_vss_xprv(config.clone(), &seed_bytes, Arc::clone(&logger))?;
let vss_xprv = derive_xprv(
config.clone(),
&seed_bytes,
VSS_HARDENED_CHILD_INDEX,
Arc::clone(&logger),
)?;

let vss_seed_bytes: [u8; 32] = vss_xprv.private_key.secret_bytes();

Expand DownExpand Up@@ -691,6 +719,16 @@ impl ArcedNodeBuilder {
self.inner.write().unwrap().set_liquidity_source_lsps2(node_id, address, token);
}

/// Configures the [`Node`] instance to provide an [LSPS2] service, issuing just-in-time
/// channels to clients.
///
/// **Caution**: LSP service support is in **alpha** and is considered an experimental feature.
///
/// [LSPS2]: https://github.com/BitcoinAndLightningLayerSpecs/lsp/blob/main/LSPS2/README.md
pub fn set_liquidity_provider_lsps2(&self, service_config: LSPS2ServiceConfig) {
self.inner.write().unwrap().set_liquidity_provider_lsps2(service_config);
}

/// 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);
Expand DownExpand Up@@ -1039,7 +1077,7 @@ fn build_with_store_internal(
};

let mut user_config = default_user_config(&config);
if liquidity_source_config.and_then(|lsc| lsc.lsps2_service.as_ref()).is_some() {
if liquidity_source_config.and_then(|lsc| lsc.lsps2_client.as_ref()).is_some() {
// Generally allow claiming underpaying HTLCs as the LSP will skim off some fee. We'll
// check that they don't take too much before claiming.
user_config.channel_config.accept_underpaying_htlcs = true;
Expand All@@ -1051,6 +1089,12 @@ fn build_with_store_internal(
100;
}

if liquidity_source_config.and_then(|lsc| lsc.lsps2_service.as_ref()).is_some() {
// If we act as an LSPS2 service, we need to to be able to intercept HTLCs and forward the
// information to the service handler.
user_config.accept_intercept_htlcs = true;
}

let message_router =
Arc::new(MessageRouter::new(Arc::clone(&network_graph), Arc::clone(&keys_manager)));

Expand DownExpand Up@@ -1171,31 +1215,53 @@ fn build_with_store_internal(
},
};

let liquidity_source = liquidity_source_config.as_ref().map(|lsc| {
let mut liquidity_source_builder = LiquiditySourceBuilder::new(
Arc::clone(&channel_manager),
Arc::clone(&keys_manager),
Arc::clone(&chain_source),
Arc::clone(&config),
Arc::clone(&logger),
);

lsc.lsps1_service.as_ref().map(|(node_id, address, token)| {
liquidity_source_builder.lsps1_service(*node_id, address.clone(), token.clone())
});
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.lsps2_service.as_ref().map(|(node_id, address, token)| {
liquidity_source_builder.lsps2_service(*node_id, address.clone(), token.clone())
});
lsc.lsps1_client.as_ref().map(|config| {
liquidity_source_builder.lsps1_client(
config.node_id,
config.address.clone(),
config.token.clone(),
)
});

Arc::new(liquidity_source_builder.build())
});
lsc.lsps2_client.as_ref().map(|config| {
liquidity_source_builder.lsps2_client(
config.node_id,
config.address.clone(),
config.token.clone(),
)
});

let custom_message_handler = if let Some(liquidity_source) = liquidity_source.as_ref() {
Arc::new(NodeCustomMessageHandler::new_liquidity(Arc::clone(&liquidity_source)))
} else {
Arc::new(NodeCustomMessageHandler::new_ignoring())
};
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)
} else {
(None, Arc::new(NodeCustomMessageHandler::new_ignoring()))
};

let msg_handler = match gossip_source.as_gossip_sync() {
GossipSync::P2P(p2p_gossip_sync) => MessageHandler {
Expand DownExpand Up@@ -1397,8 +1463,8 @@ fn seed_bytes_from_config(
}
}

fn derive_vss_xprv(
config: Arc<Config>, seed_bytes: &[u8; 64], logger: Arc<Logger>,
fn derive_xprv(
config: Arc<Config>, seed_bytes: &[u8; 64], hardened_child_index: u32, logger: Arc<Logger>,
) -> Result<Xpriv, BuildError> {
use bitcoin::key::Secp256k1;

Expand All@@ -1407,10 +1473,11 @@ fn derive_vss_xprv(
BuildError::InvalidSeedBytes
})?;

xprv.derive_priv(&Secp256k1::new(), &[ChildNumber::Hardened { index: 877 }]).map_err(|e| {
log_error!(logger, "Failed to derive VSS secret: {}", e);
BuildError::KVStoreSetupFailed
})
xprv.derive_priv(&Secp256k1::new(), &[ChildNumber::Hardened { index: hardened_child_index }])
.map_err(|e| {
log_error!(logger, "Failed to derive hardened child secret: {}", e);
BuildError::InvalidSeedBytes
})
}

/// Sanitize the user-provided node alias to ensure that it is a valid protocol-specified UTF-8 string.
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Add bLIP-52 / LSPS2 service-side support by tnull · Pull Request #420 · lightningdevkit/ldk-node · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions bindings/ldk_node.udl
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,6 +25,18 @@ dictionary EsploraSyncConfig {
u64 fee_rate_cache_update_interval_secs;
};

dictionary LSPS2ServiceConfig {
string? require_token;
boolean advertise_service;
u32 channel_opening_fee_ppm;
u32 channel_over_provisioning_ppm;
u64 min_channel_opening_fee_msat;
u32 min_channel_lifetime;
u32 max_client_to_self_delay;
u64 min_payment_size_msat;
u64 max_payment_size_msat;
};

enum LogLevel {
"Gossip",
"Trace",
Expand Down
159 changes: 113 additions & 46 deletions src/builder.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,7 +18,9 @@ use crate::gossip::GossipSource;
use crate::io::sqlite_store::SqliteStore;
use crate::io::utils::{read_node_metrics, write_node_metrics};
use crate::io::vss_store::VssStore;
use crate::liquidity::LiquiditySourceBuilder;
use crate::liquidity::{
LSPS1ClientConfig, LSPS2ClientConfig, LSPS2ServiceConfig, LiquiditySourceBuilder,
};
use crate::logger::{log_error, log_info, LdkLogger, LogLevel, LogWriter, Logger};
use crate::message_handler::NodeCustomMessageHandler;
use crate::payment::store::PaymentStore;
Expand DownExpand Up@@ -75,6 +77,10 @@ use std::sync::{Arc, Mutex, RwLock};
use std::time::SystemTime;
use vss_client::headers::{FixedHeaders, LnurlAuthToJwtProvider, VssHeaderProvider};

const VSS_HARDENED_CHILD_INDEX: u32 = 877;
const VSS_LNURL_AUTH_HARDENED_CHILD_INDEX: u32 = 138;
const LSPS_HARDENED_CHILD_INDEX: u32 = 577;

#[derive(Debug, Clone)]
enum ChainDataSourceConfig {
Esplora { server_url: String, sync_config: Option<EsploraSyncConfig> },
Expand All@@ -94,18 +100,14 @@ enum GossipSourceConfig {
RapidGossipSync(String),
}

#[derive(Debug, Clone)]
#[derive(Debug, Clone, Default)]
struct LiquiditySourceConfig {
// LSPS1 service's (node_id, address, token)
lsps1_service: Option<(PublicKey, SocketAddress, Option<String>)>,
// LSPS2 service's (node_id, address, token)
lsps2_service: Option<(PublicKey, SocketAddress, Option<String>)>,
}

impl Default for LiquiditySourceConfig {
fn default() -> Self {
Self { lsps1_service: None, lsps2_service: None }
}
// Act as an LSPS1 client connecting to the given service.
lsps1_client: Option<LSPS1ClientConfig>,
// Act as an LSPS2 client connecting to the given service.
lsps2_client: Option<LSPS2ClientConfig>,
// Act as an LSPS2 service.
lsps2_service: Option<LSPS2ServiceConfig>,
}

#[derive(Clone)]
Expand DownExpand Up@@ -319,7 +321,8 @@ impl NodeBuilder {

let liquidity_source_config =
self.liquidity_source_config.get_or_insert(LiquiditySourceConfig::default());
liquidity_source_config.lsps1_service = Some((node_id, address, token));
let lsps1_client_config = LSPS1ClientConfig { node_id, address, token };
liquidity_source_config.lsps1_client = Some(lsps1_client_config);
self
}

Expand All@@ -339,7 +342,23 @@ impl NodeBuilder {

let liquidity_source_config =
self.liquidity_source_config.get_or_insert(LiquiditySourceConfig::default());
liquidity_source_config.lsps2_service = Some((node_id, address, token));
let lsps2_client_config = LSPS2ClientConfig { node_id, address, token };
liquidity_source_config.lsps2_client = Some(lsps2_client_config);
self
}

/// Configures the [`Node`] instance to provide an [LSPS2] service, issuing just-in-time
/// channels to clients.
///
/// **Caution**: LSP service support is in **alpha** and is considered an experimental feature.
///
/// [LSPS2]: https://github.com/BitcoinAndLightningLayerSpecs/lsp/blob/main/LSPS2/README.md
pub fn set_liquidity_provider_lsps2(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know we have probably used setX in names because it is builder but it is getting slightly confusing with lsps client and service.
Personally, I was slightly confused due to the fact "we need to set liquidity provider to act as lsps-client".

no strong opinion, but imo, these methods could be renamed to enable_lsps2_service, enable_lsps2_client and so on.
what do you think?

alternatively, we can add another layer here similar to .bolt11() and .bolt12().

@tnulltnullFeb 14, 2025

Copy link
Copy Markdown
CollaboratorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, thanks for bringing this up. I see you point in that theset_liquidity_provider case that could be a bit confusing, but still think I'd like to keep the set_ pattern everywhere for now. For one, it follows the Rust API guidelines for naming setters. Also note that once we break the pattern, we might need to revisit all of the builder methods, which a) would be a larger refactor out-of-scope of this PR and b) would make the API less predicatable, IMO. But noted.

&mut self, service_config: LSPS2ServiceConfig,
) -> &mut Self {
let liquidity_source_config =
self.liquidity_source_config.get_or_insert(LiquiditySourceConfig::default());
liquidity_source_config.lsps2_service = Some(service_config);
self
}

Expand DownExpand Up@@ -471,10 +490,14 @@ impl NodeBuilder {

let config = Arc::new(self.config.clone());

let vss_xprv = derive_vss_xprv(config, &seed_bytes, Arc::clone(&logger))?;
let vss_xprv =
derive_xprv(config, &seed_bytes, VSS_HARDENED_CHILD_INDEX, Arc::clone(&logger))?;

let lnurl_auth_xprv = vss_xprv
.derive_priv(&Secp256k1::new(), &[ChildNumber::Hardened { index: 138 }])
.derive_priv(
&Secp256k1::new(),
&[ChildNumber::Hardened { index: VSS_LNURL_AUTH_HARDENED_CHILD_INDEX }],
)
.map_err(|e| {
log_error!(logger, "Failed to derive VSS secret: {}", e);
BuildError::KVStoreSetupFailed
Expand DownExpand Up@@ -536,7 +559,12 @@ impl NodeBuilder {

let config = Arc::new(self.config.clone());

let vss_xprv = derive_vss_xprv(config.clone(), &seed_bytes, Arc::clone(&logger))?;
let vss_xprv = derive_xprv(
config.clone(),
&seed_bytes,
VSS_HARDENED_CHILD_INDEX,
Arc::clone(&logger),
)?;

let vss_seed_bytes: [u8; 32] = vss_xprv.private_key.secret_bytes();

Expand DownExpand Up@@ -691,6 +719,16 @@ impl ArcedNodeBuilder {
self.inner.write().unwrap().set_liquidity_source_lsps2(node_id, address, token);
}

/// Configures the [`Node`] instance to provide an [LSPS2] service, issuing just-in-time
/// channels to clients.
///
/// **Caution**: LSP service support is in **alpha** and is considered an experimental feature.
///
/// [LSPS2]: https://github.com/BitcoinAndLightningLayerSpecs/lsp/blob/main/LSPS2/README.md
pub fn set_liquidity_provider_lsps2(&self, service_config: LSPS2ServiceConfig) {
self.inner.write().unwrap().set_liquidity_provider_lsps2(service_config);
}

/// 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);
Expand DownExpand Up@@ -1039,7 +1077,7 @@ fn build_with_store_internal(
};

let mut user_config = default_user_config(&config);
if liquidity_source_config.and_then(|lsc| lsc.lsps2_service.as_ref()).is_some() {
if liquidity_source_config.and_then(|lsc| lsc.lsps2_client.as_ref()).is_some() {
// Generally allow claiming underpaying HTLCs as the LSP will skim off some fee. We'll
// check that they don't take too much before claiming.
user_config.channel_config.accept_underpaying_htlcs = true;
Expand All@@ -1051,6 +1089,12 @@ fn build_with_store_internal(
100;
}

if liquidity_source_config.and_then(|lsc| lsc.lsps2_service.as_ref()).is_some() {
// If we act as an LSPS2 service, we need to to be able to intercept HTLCs and forward the
// information to the service handler.
user_config.accept_intercept_htlcs = true;
}

let message_router =
Arc::new(MessageRouter::new(Arc::clone(&network_graph), Arc::clone(&keys_manager)));

Expand DownExpand Up@@ -1171,31 +1215,53 @@ fn build_with_store_internal(
},
};

let liquidity_source = liquidity_source_config.as_ref().map(|lsc| {
let mut liquidity_source_builder = LiquiditySourceBuilder::new(
Arc::clone(&channel_manager),
Arc::clone(&keys_manager),
Arc::clone(&chain_source),
Arc::clone(&config),
Arc::clone(&logger),
);

lsc.lsps1_service.as_ref().map(|(node_id, address, token)| {
liquidity_source_builder.lsps1_service(*node_id, address.clone(), token.clone())
});
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.lsps2_service.as_ref().map(|(node_id, address, token)| {
liquidity_source_builder.lsps2_service(*node_id, address.clone(), token.clone())
});
lsc.lsps1_client.as_ref().map(|config| {
liquidity_source_builder.lsps1_client(
config.node_id,
config.address.clone(),
config.token.clone(),
)
});

Arc::new(liquidity_source_builder.build())
});
lsc.lsps2_client.as_ref().map(|config| {
liquidity_source_builder.lsps2_client(
config.node_id,
config.address.clone(),
config.token.clone(),
)
});

let custom_message_handler = if let Some(liquidity_source) = liquidity_source.as_ref() {
Arc::new(NodeCustomMessageHandler::new_liquidity(Arc::clone(&liquidity_source)))
} else {
Arc::new(NodeCustomMessageHandler::new_ignoring())
};
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)
} else {
(None, Arc::new(NodeCustomMessageHandler::new_ignoring()))
};

let msg_handler = match gossip_source.as_gossip_sync() {
GossipSync::P2P(p2p_gossip_sync) => MessageHandler {
Expand DownExpand Up@@ -1397,8 +1463,8 @@ fn seed_bytes_from_config(
}
}

fn derive_vss_xprv(
config: Arc<Config>, seed_bytes: &[u8; 64], logger: Arc<Logger>,
fn derive_xprv(
config: Arc<Config>, seed_bytes: &[u8; 64], hardened_child_index: u32, logger: Arc<Logger>,
) -> Result<Xpriv, BuildError> {
use bitcoin::key::Secp256k1;

Expand All@@ -1407,10 +1473,11 @@ fn derive_vss_xprv(
BuildError::InvalidSeedBytes
})?;

xprv.derive_priv(&Secp256k1::new(), &[ChildNumber::Hardened { index: 877 }]).map_err(|e| {
log_error!(logger, "Failed to derive VSS secret: {}", e);
BuildError::KVStoreSetupFailed
})
xprv.derive_priv(&Secp256k1::new(), &[ChildNumber::Hardened { index: hardened_child_index }])
.map_err(|e| {
log_error!(logger, "Failed to derive hardened child secret: {}", e);
BuildError::InvalidSeedBytes
})
}

/// Sanitize the user-provided node alias to ensure that it is a valid protocol-specified UTF-8 string.
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Add bLIP-52 / LSPS2 service-side support by tnull · Pull Request #420 · lightningdevkit/ldk-node · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions bindings/ldk_node.udl
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,6 +25,18 @@ dictionary EsploraSyncConfig {
u64 fee_rate_cache_update_interval_secs;
};

dictionary LSPS2ServiceConfig {
string? require_token;
boolean advertise_service;
u32 channel_opening_fee_ppm;
u32 channel_over_provisioning_ppm;
u64 min_channel_opening_fee_msat;
u32 min_channel_lifetime;
u32 max_client_to_self_delay;
u64 min_payment_size_msat;
u64 max_payment_size_msat;
};

enum LogLevel {
"Gossip",
"Trace",
Expand Down
159 changes: 113 additions & 46 deletions src/builder.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,7 +18,9 @@ use crate::gossip::GossipSource;
use crate::io::sqlite_store::SqliteStore;
use crate::io::utils::{read_node_metrics, write_node_metrics};
use crate::io::vss_store::VssStore;
use crate::liquidity::LiquiditySourceBuilder;
use crate::liquidity::{
LSPS1ClientConfig, LSPS2ClientConfig, LSPS2ServiceConfig, LiquiditySourceBuilder,
};
use crate::logger::{log_error, log_info, LdkLogger, LogLevel, LogWriter, Logger};
use crate::message_handler::NodeCustomMessageHandler;
use crate::payment::store::PaymentStore;
Expand DownExpand Up@@ -75,6 +77,10 @@ use std::sync::{Arc, Mutex, RwLock};
use std::time::SystemTime;
use vss_client::headers::{FixedHeaders, LnurlAuthToJwtProvider, VssHeaderProvider};

const VSS_HARDENED_CHILD_INDEX: u32 = 877;
const VSS_LNURL_AUTH_HARDENED_CHILD_INDEX: u32 = 138;
const LSPS_HARDENED_CHILD_INDEX: u32 = 577;

#[derive(Debug, Clone)]
enum ChainDataSourceConfig {
Esplora { server_url: String, sync_config: Option<EsploraSyncConfig> },
Expand All@@ -94,18 +100,14 @@ enum GossipSourceConfig {
RapidGossipSync(String),
}

#[derive(Debug, Clone)]
#[derive(Debug, Clone, Default)]
struct LiquiditySourceConfig {
// LSPS1 service's (node_id, address, token)
lsps1_service: Option<(PublicKey, SocketAddress, Option<String>)>,
// LSPS2 service's (node_id, address, token)
lsps2_service: Option<(PublicKey, SocketAddress, Option<String>)>,
}

impl Default for LiquiditySourceConfig {
fn default() -> Self {
Self { lsps1_service: None, lsps2_service: None }
}
// Act as an LSPS1 client connecting to the given service.
lsps1_client: Option<LSPS1ClientConfig>,
// Act as an LSPS2 client connecting to the given service.
lsps2_client: Option<LSPS2ClientConfig>,
// Act as an LSPS2 service.
lsps2_service: Option<LSPS2ServiceConfig>,
}

#[derive(Clone)]
Expand DownExpand Up@@ -319,7 +321,8 @@ impl NodeBuilder {

let liquidity_source_config =
self.liquidity_source_config.get_or_insert(LiquiditySourceConfig::default());
liquidity_source_config.lsps1_service = Some((node_id, address, token));
let lsps1_client_config = LSPS1ClientConfig { node_id, address, token };
liquidity_source_config.lsps1_client = Some(lsps1_client_config);
self
}

Expand All@@ -339,7 +342,23 @@ impl NodeBuilder {

let liquidity_source_config =
self.liquidity_source_config.get_or_insert(LiquiditySourceConfig::default());
liquidity_source_config.lsps2_service = Some((node_id, address, token));
let lsps2_client_config = LSPS2ClientConfig { node_id, address, token };
liquidity_source_config.lsps2_client = Some(lsps2_client_config);
self
}

/// Configures the [`Node`] instance to provide an [LSPS2] service, issuing just-in-time
/// channels to clients.
///
/// **Caution**: LSP service support is in **alpha** and is considered an experimental feature.
///
/// [LSPS2]: https://github.com/BitcoinAndLightningLayerSpecs/lsp/blob/main/LSPS2/README.md
pub fn set_liquidity_provider_lsps2(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know we have probably used setX in names because it is builder but it is getting slightly confusing with lsps client and service.
Personally, I was slightly confused due to the fact "we need to set liquidity provider to act as lsps-client".

no strong opinion, but imo, these methods could be renamed to enable_lsps2_service, enable_lsps2_client and so on.
what do you think?

alternatively, we can add another layer here similar to .bolt11() and .bolt12().

@tnulltnullFeb 14, 2025

Copy link
Copy Markdown
CollaboratorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, thanks for bringing this up. I see you point in that theset_liquidity_provider case that could be a bit confusing, but still think I'd like to keep the set_ pattern everywhere for now. For one, it follows the Rust API guidelines for naming setters. Also note that once we break the pattern, we might need to revisit all of the builder methods, which a) would be a larger refactor out-of-scope of this PR and b) would make the API less predicatable, IMO. But noted.

&mut self, service_config: LSPS2ServiceConfig,
) -> &mut Self {
let liquidity_source_config =
self.liquidity_source_config.get_or_insert(LiquiditySourceConfig::default());
liquidity_source_config.lsps2_service = Some(service_config);
self
}

Expand DownExpand Up@@ -471,10 +490,14 @@ impl NodeBuilder {

let config = Arc::new(self.config.clone());

let vss_xprv = derive_vss_xprv(config, &seed_bytes, Arc::clone(&logger))?;
let vss_xprv =
derive_xprv(config, &seed_bytes, VSS_HARDENED_CHILD_INDEX, Arc::clone(&logger))?;

let lnurl_auth_xprv = vss_xprv
.derive_priv(&Secp256k1::new(), &[ChildNumber::Hardened { index: 138 }])
.derive_priv(
&Secp256k1::new(),
&[ChildNumber::Hardened { index: VSS_LNURL_AUTH_HARDENED_CHILD_INDEX }],
)
.map_err(|e| {
log_error!(logger, "Failed to derive VSS secret: {}", e);
BuildError::KVStoreSetupFailed
Expand DownExpand Up@@ -536,7 +559,12 @@ impl NodeBuilder {

let config = Arc::new(self.config.clone());

let vss_xprv = derive_vss_xprv(config.clone(), &seed_bytes, Arc::clone(&logger))?;
let vss_xprv = derive_xprv(
config.clone(),
&seed_bytes,
VSS_HARDENED_CHILD_INDEX,
Arc::clone(&logger),
)?;

let vss_seed_bytes: [u8; 32] = vss_xprv.private_key.secret_bytes();

Expand DownExpand Up@@ -691,6 +719,16 @@ impl ArcedNodeBuilder {
self.inner.write().unwrap().set_liquidity_source_lsps2(node_id, address, token);
}

/// Configures the [`Node`] instance to provide an [LSPS2] service, issuing just-in-time
/// channels to clients.
///
/// **Caution**: LSP service support is in **alpha** and is considered an experimental feature.
///
/// [LSPS2]: https://github.com/BitcoinAndLightningLayerSpecs/lsp/blob/main/LSPS2/README.md
pub fn set_liquidity_provider_lsps2(&self, service_config: LSPS2ServiceConfig) {
self.inner.write().unwrap().set_liquidity_provider_lsps2(service_config);
}

/// 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);
Expand DownExpand Up@@ -1039,7 +1077,7 @@ fn build_with_store_internal(
};

let mut user_config = default_user_config(&config);
if liquidity_source_config.and_then(|lsc| lsc.lsps2_service.as_ref()).is_some() {
if liquidity_source_config.and_then(|lsc| lsc.lsps2_client.as_ref()).is_some() {
// Generally allow claiming underpaying HTLCs as the LSP will skim off some fee. We'll
// check that they don't take too much before claiming.
user_config.channel_config.accept_underpaying_htlcs = true;
Expand All@@ -1051,6 +1089,12 @@ fn build_with_store_internal(
100;
}

if liquidity_source_config.and_then(|lsc| lsc.lsps2_service.as_ref()).is_some() {
// If we act as an LSPS2 service, we need to to be able to intercept HTLCs and forward the
// information to the service handler.
user_config.accept_intercept_htlcs = true;
}

let message_router =
Arc::new(MessageRouter::new(Arc::clone(&network_graph), Arc::clone(&keys_manager)));

Expand DownExpand Up@@ -1171,31 +1215,53 @@ fn build_with_store_internal(
},
};

let liquidity_source = liquidity_source_config.as_ref().map(|lsc| {
let mut liquidity_source_builder = LiquiditySourceBuilder::new(
Arc::clone(&channel_manager),
Arc::clone(&keys_manager),
Arc::clone(&chain_source),
Arc::clone(&config),
Arc::clone(&logger),
);

lsc.lsps1_service.as_ref().map(|(node_id, address, token)| {
liquidity_source_builder.lsps1_service(*node_id, address.clone(), token.clone())
});
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.lsps2_service.as_ref().map(|(node_id, address, token)| {
liquidity_source_builder.lsps2_service(*node_id, address.clone(), token.clone())
});
lsc.lsps1_client.as_ref().map(|config| {
liquidity_source_builder.lsps1_client(
config.node_id,
config.address.clone(),
config.token.clone(),
)
});

Arc::new(liquidity_source_builder.build())
});
lsc.lsps2_client.as_ref().map(|config| {
liquidity_source_builder.lsps2_client(
config.node_id,
config.address.clone(),
config.token.clone(),
)
});

let custom_message_handler = if let Some(liquidity_source) = liquidity_source.as_ref() {
Arc::new(NodeCustomMessageHandler::new_liquidity(Arc::clone(&liquidity_source)))
} else {
Arc::new(NodeCustomMessageHandler::new_ignoring())
};
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)
} else {
(None, Arc::new(NodeCustomMessageHandler::new_ignoring()))
};

let msg_handler = match gossip_source.as_gossip_sync() {
GossipSync::P2P(p2p_gossip_sync) => MessageHandler {
Expand DownExpand Up@@ -1397,8 +1463,8 @@ fn seed_bytes_from_config(
}
}

fn derive_vss_xprv(
config: Arc<Config>, seed_bytes: &[u8; 64], logger: Arc<Logger>,
fn derive_xprv(
config: Arc<Config>, seed_bytes: &[u8; 64], hardened_child_index: u32, logger: Arc<Logger>,
) -> Result<Xpriv, BuildError> {
use bitcoin::key::Secp256k1;

Expand All@@ -1407,10 +1473,11 @@ fn derive_vss_xprv(
BuildError::InvalidSeedBytes
})?;

xprv.derive_priv(&Secp256k1::new(), &[ChildNumber::Hardened { index: 877 }]).map_err(|e| {
log_error!(logger, "Failed to derive VSS secret: {}", e);
BuildError::KVStoreSetupFailed
})
xprv.derive_priv(&Secp256k1::new(), &[ChildNumber::Hardened { index: hardened_child_index }])
.map_err(|e| {
log_error!(logger, "Failed to derive hardened child secret: {}", e);
BuildError::InvalidSeedBytes
})
}

/// Sanitize the user-provided node alias to ensure that it is a valid protocol-specified UTF-8 string.
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' Add bLIP-52 / LSPS2 service-side support by tnull · Pull Request #420 · lightningdevkit/ldk-node · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions bindings/ldk_node.udl
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,6 +25,18 @@ dictionary EsploraSyncConfig {
u64 fee_rate_cache_update_interval_secs;
};

dictionary LSPS2ServiceConfig {
string? require_token;
boolean advertise_service;
u32 channel_opening_fee_ppm;
u32 channel_over_provisioning_ppm;
u64 min_channel_opening_fee_msat;
u32 min_channel_lifetime;
u32 max_client_to_self_delay;
u64 min_payment_size_msat;
u64 max_payment_size_msat;
};

enum LogLevel {
"Gossip",
"Trace",
Expand Down
159 changes: 113 additions & 46 deletions src/builder.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,7 +18,9 @@ use crate::gossip::GossipSource;
use crate::io::sqlite_store::SqliteStore;
use crate::io::utils::{read_node_metrics, write_node_metrics};
use crate::io::vss_store::VssStore;
use crate::liquidity::LiquiditySourceBuilder;
use crate::liquidity::{
LSPS1ClientConfig, LSPS2ClientConfig, LSPS2ServiceConfig, LiquiditySourceBuilder,
};
use crate::logger::{log_error, log_info, LdkLogger, LogLevel, LogWriter, Logger};
use crate::message_handler::NodeCustomMessageHandler;
use crate::payment::store::PaymentStore;
Expand DownExpand Up@@ -75,6 +77,10 @@ use std::sync::{Arc, Mutex, RwLock};
use std::time::SystemTime;
use vss_client::headers::{FixedHeaders, LnurlAuthToJwtProvider, VssHeaderProvider};

const VSS_HARDENED_CHILD_INDEX: u32 = 877;
const VSS_LNURL_AUTH_HARDENED_CHILD_INDEX: u32 = 138;
const LSPS_HARDENED_CHILD_INDEX: u32 = 577;

#[derive(Debug, Clone)]
enum ChainDataSourceConfig {
Esplora { server_url: String, sync_config: Option<EsploraSyncConfig> },
Expand All@@ -94,18 +100,14 @@ enum GossipSourceConfig {
RapidGossipSync(String),
}

#[derive(Debug, Clone)]
#[derive(Debug, Clone, Default)]
struct LiquiditySourceConfig {
// LSPS1 service's (node_id, address, token)
lsps1_service: Option<(PublicKey, SocketAddress, Option<String>)>,
// LSPS2 service's (node_id, address, token)
lsps2_service: Option<(PublicKey, SocketAddress, Option<String>)>,
}

impl Default for LiquiditySourceConfig {
fn default() -> Self {
Self { lsps1_service: None, lsps2_service: None }
}
// Act as an LSPS1 client connecting to the given service.
lsps1_client: Option<LSPS1ClientConfig>,
// Act as an LSPS2 client connecting to the given service.
lsps2_client: Option<LSPS2ClientConfig>,
// Act as an LSPS2 service.
lsps2_service: Option<LSPS2ServiceConfig>,
}

#[derive(Clone)]
Expand DownExpand Up@@ -319,7 +321,8 @@ impl NodeBuilder {

let liquidity_source_config =
self.liquidity_source_config.get_or_insert(LiquiditySourceConfig::default());
liquidity_source_config.lsps1_service = Some((node_id, address, token));
let lsps1_client_config = LSPS1ClientConfig { node_id, address, token };
liquidity_source_config.lsps1_client = Some(lsps1_client_config);
self
}

Expand All@@ -339,7 +342,23 @@ impl NodeBuilder {

let liquidity_source_config =
self.liquidity_source_config.get_or_insert(LiquiditySourceConfig::default());
liquidity_source_config.lsps2_service = Some((node_id, address, token));
let lsps2_client_config = LSPS2ClientConfig { node_id, address, token };
liquidity_source_config.lsps2_client = Some(lsps2_client_config);
self
}

/// Configures the [`Node`] instance to provide an [LSPS2] service, issuing just-in-time
/// channels to clients.
///
/// **Caution**: LSP service support is in **alpha** and is considered an experimental feature.
///
/// [LSPS2]: https://github.com/BitcoinAndLightningLayerSpecs/lsp/blob/main/LSPS2/README.md
pub fn set_liquidity_provider_lsps2(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know we have probably used setX in names because it is builder but it is getting slightly confusing with lsps client and service.
Personally, I was slightly confused due to the fact "we need to set liquidity provider to act as lsps-client".

no strong opinion, but imo, these methods could be renamed to enable_lsps2_service, enable_lsps2_client and so on.
what do you think?

alternatively, we can add another layer here similar to .bolt11() and .bolt12().

@tnulltnullFeb 14, 2025

Copy link
Copy Markdown
CollaboratorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, thanks for bringing this up. I see you point in that theset_liquidity_provider case that could be a bit confusing, but still think I'd like to keep the set_ pattern everywhere for now. For one, it follows the Rust API guidelines for naming setters. Also note that once we break the pattern, we might need to revisit all of the builder methods, which a) would be a larger refactor out-of-scope of this PR and b) would make the API less predicatable, IMO. But noted.

&mut self, service_config: LSPS2ServiceConfig,
) -> &mut Self {
let liquidity_source_config =
self.liquidity_source_config.get_or_insert(LiquiditySourceConfig::default());
liquidity_source_config.lsps2_service = Some(service_config);
self
}

Expand DownExpand Up@@ -471,10 +490,14 @@ impl NodeBuilder {

let config = Arc::new(self.config.clone());

let vss_xprv = derive_vss_xprv(config, &seed_bytes, Arc::clone(&logger))?;
let vss_xprv =
derive_xprv(config, &seed_bytes, VSS_HARDENED_CHILD_INDEX, Arc::clone(&logger))?;

let lnurl_auth_xprv = vss_xprv
.derive_priv(&Secp256k1::new(), &[ChildNumber::Hardened { index: 138 }])
.derive_priv(
&Secp256k1::new(),
&[ChildNumber::Hardened { index: VSS_LNURL_AUTH_HARDENED_CHILD_INDEX }],
)
.map_err(|e| {
log_error!(logger, "Failed to derive VSS secret: {}", e);
BuildError::KVStoreSetupFailed
Expand DownExpand Up@@ -536,7 +559,12 @@ impl NodeBuilder {

let config = Arc::new(self.config.clone());

let vss_xprv = derive_vss_xprv(config.clone(), &seed_bytes, Arc::clone(&logger))?;
let vss_xprv = derive_xprv(
config.clone(),
&seed_bytes,
VSS_HARDENED_CHILD_INDEX,
Arc::clone(&logger),
)?;

let vss_seed_bytes: [u8; 32] = vss_xprv.private_key.secret_bytes();

Expand DownExpand Up@@ -691,6 +719,16 @@ impl ArcedNodeBuilder {
self.inner.write().unwrap().set_liquidity_source_lsps2(node_id, address, token);
}

/// Configures the [`Node`] instance to provide an [LSPS2] service, issuing just-in-time
/// channels to clients.
///
/// **Caution**: LSP service support is in **alpha** and is considered an experimental feature.
///
/// [LSPS2]: https://github.com/BitcoinAndLightningLayerSpecs/lsp/blob/main/LSPS2/README.md
pub fn set_liquidity_provider_lsps2(&self, service_config: LSPS2ServiceConfig) {
self.inner.write().unwrap().set_liquidity_provider_lsps2(service_config);
}

/// 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);
Expand DownExpand Up@@ -1039,7 +1077,7 @@ fn build_with_store_internal(
};

let mut user_config = default_user_config(&config);
if liquidity_source_config.and_then(|lsc| lsc.lsps2_service.as_ref()).is_some() {
if liquidity_source_config.and_then(|lsc| lsc.lsps2_client.as_ref()).is_some() {
// Generally allow claiming underpaying HTLCs as the LSP will skim off some fee. We'll
// check that they don't take too much before claiming.
user_config.channel_config.accept_underpaying_htlcs = true;
Expand All@@ -1051,6 +1089,12 @@ fn build_with_store_internal(
100;
}

if liquidity_source_config.and_then(|lsc| lsc.lsps2_service.as_ref()).is_some() {
// If we act as an LSPS2 service, we need to to be able to intercept HTLCs and forward the
// information to the service handler.
user_config.accept_intercept_htlcs = true;
}

let message_router =
Arc::new(MessageRouter::new(Arc::clone(&network_graph), Arc::clone(&keys_manager)));

Expand DownExpand Up@@ -1171,31 +1215,53 @@ fn build_with_store_internal(
},
};

let liquidity_source = liquidity_source_config.as_ref().map(|lsc| {
let mut liquidity_source_builder = LiquiditySourceBuilder::new(
Arc::clone(&channel_manager),
Arc::clone(&keys_manager),
Arc::clone(&chain_source),
Arc::clone(&config),
Arc::clone(&logger),
);

lsc.lsps1_service.as_ref().map(|(node_id, address, token)| {
liquidity_source_builder.lsps1_service(*node_id, address.clone(), token.clone())
});
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.lsps2_service.as_ref().map(|(node_id, address, token)| {
liquidity_source_builder.lsps2_service(*node_id, address.clone(), token.clone())
});
lsc.lsps1_client.as_ref().map(|config| {
liquidity_source_builder.lsps1_client(
config.node_id,
config.address.clone(),
config.token.clone(),
)
});

Arc::new(liquidity_source_builder.build())
});
lsc.lsps2_client.as_ref().map(|config| {
liquidity_source_builder.lsps2_client(
config.node_id,
config.address.clone(),
config.token.clone(),
)
});

let custom_message_handler = if let Some(liquidity_source) = liquidity_source.as_ref() {
Arc::new(NodeCustomMessageHandler::new_liquidity(Arc::clone(&liquidity_source)))
} else {
Arc::new(NodeCustomMessageHandler::new_ignoring())
};
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)
} else {
(None, Arc::new(NodeCustomMessageHandler::new_ignoring()))
};

let msg_handler = match gossip_source.as_gossip_sync() {
GossipSync::P2P(p2p_gossip_sync) => MessageHandler {
Expand DownExpand Up@@ -1397,8 +1463,8 @@ fn seed_bytes_from_config(
}
}

fn derive_vss_xprv(
config: Arc<Config>, seed_bytes: &[u8; 64], logger: Arc<Logger>,
fn derive_xprv(
config: Arc<Config>, seed_bytes: &[u8; 64], hardened_child_index: u32, logger: Arc<Logger>,
) -> Result<Xpriv, BuildError> {
use bitcoin::key::Secp256k1;

Expand All@@ -1407,10 +1473,11 @@ fn derive_vss_xprv(
BuildError::InvalidSeedBytes
})?;

xprv.derive_priv(&Secp256k1::new(), &[ChildNumber::Hardened { index: 877 }]).map_err(|e| {
log_error!(logger, "Failed to derive VSS secret: {}", e);
BuildError::KVStoreSetupFailed
})
xprv.derive_priv(&Secp256k1::new(), &[ChildNumber::Hardened { index: hardened_child_index }])
.map_err(|e| {
log_error!(logger, "Failed to derive hardened child secret: {}", e);
BuildError::InvalidSeedBytes
})
}

/// Sanitize the user-provided node alias to ensure that it is a valid protocol-specified UTF-8 string.
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Add bLIP-52 / LSPS2 service-side support by tnull · Pull Request #420 · lightningdevkit/ldk-node · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions bindings/ldk_node.udl
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,6 +25,18 @@ dictionary EsploraSyncConfig {
u64 fee_rate_cache_update_interval_secs;
};

dictionary LSPS2ServiceConfig {
string? require_token;
boolean advertise_service;
u32 channel_opening_fee_ppm;
u32 channel_over_provisioning_ppm;
u64 min_channel_opening_fee_msat;
u32 min_channel_lifetime;
u32 max_client_to_self_delay;
u64 min_payment_size_msat;
u64 max_payment_size_msat;
};

enum LogLevel {
"Gossip",
"Trace",
Expand Down
159 changes: 113 additions & 46 deletions src/builder.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,7 +18,9 @@ use crate::gossip::GossipSource;
use crate::io::sqlite_store::SqliteStore;
use crate::io::utils::{read_node_metrics, write_node_metrics};
use crate::io::vss_store::VssStore;
use crate::liquidity::LiquiditySourceBuilder;
use crate::liquidity::{
LSPS1ClientConfig, LSPS2ClientConfig, LSPS2ServiceConfig, LiquiditySourceBuilder,
};
use crate::logger::{log_error, log_info, LdkLogger, LogLevel, LogWriter, Logger};
use crate::message_handler::NodeCustomMessageHandler;
use crate::payment::store::PaymentStore;
Expand DownExpand Up@@ -75,6 +77,10 @@ use std::sync::{Arc, Mutex, RwLock};
use std::time::SystemTime;
use vss_client::headers::{FixedHeaders, LnurlAuthToJwtProvider, VssHeaderProvider};

const VSS_HARDENED_CHILD_INDEX: u32 = 877;
const VSS_LNURL_AUTH_HARDENED_CHILD_INDEX: u32 = 138;
const LSPS_HARDENED_CHILD_INDEX: u32 = 577;

#[derive(Debug, Clone)]
enum ChainDataSourceConfig {
Esplora { server_url: String, sync_config: Option<EsploraSyncConfig> },
Expand All@@ -94,18 +100,14 @@ enum GossipSourceConfig {
RapidGossipSync(String),
}

#[derive(Debug, Clone)]
#[derive(Debug, Clone, Default)]
struct LiquiditySourceConfig {
// LSPS1 service's (node_id, address, token)
lsps1_service: Option<(PublicKey, SocketAddress, Option<String>)>,
// LSPS2 service's (node_id, address, token)
lsps2_service: Option<(PublicKey, SocketAddress, Option<String>)>,
}

impl Default for LiquiditySourceConfig {
fn default() -> Self {
Self { lsps1_service: None, lsps2_service: None }
}
// Act as an LSPS1 client connecting to the given service.
lsps1_client: Option<LSPS1ClientConfig>,
// Act as an LSPS2 client connecting to the given service.
lsps2_client: Option<LSPS2ClientConfig>,
// Act as an LSPS2 service.
lsps2_service: Option<LSPS2ServiceConfig>,
}

#[derive(Clone)]
Expand DownExpand Up@@ -319,7 +321,8 @@ impl NodeBuilder {

let liquidity_source_config =
self.liquidity_source_config.get_or_insert(LiquiditySourceConfig::default());
liquidity_source_config.lsps1_service = Some((node_id, address, token));
let lsps1_client_config = LSPS1ClientConfig { node_id, address, token };
liquidity_source_config.lsps1_client = Some(lsps1_client_config);
self
}

Expand All@@ -339,7 +342,23 @@ impl NodeBuilder {

let liquidity_source_config =
self.liquidity_source_config.get_or_insert(LiquiditySourceConfig::default());
liquidity_source_config.lsps2_service = Some((node_id, address, token));
let lsps2_client_config = LSPS2ClientConfig { node_id, address, token };
liquidity_source_config.lsps2_client = Some(lsps2_client_config);
self
}

/// Configures the [`Node`] instance to provide an [LSPS2] service, issuing just-in-time
/// channels to clients.
///
/// **Caution**: LSP service support is in **alpha** and is considered an experimental feature.
///
/// [LSPS2]: https://github.com/BitcoinAndLightningLayerSpecs/lsp/blob/main/LSPS2/README.md
pub fn set_liquidity_provider_lsps2(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know we have probably used setX in names because it is builder but it is getting slightly confusing with lsps client and service.
Personally, I was slightly confused due to the fact "we need to set liquidity provider to act as lsps-client".

no strong opinion, but imo, these methods could be renamed to enable_lsps2_service, enable_lsps2_client and so on.
what do you think?

alternatively, we can add another layer here similar to .bolt11() and .bolt12().

@tnulltnullFeb 14, 2025

Copy link
Copy Markdown
CollaboratorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, thanks for bringing this up. I see you point in that theset_liquidity_provider case that could be a bit confusing, but still think I'd like to keep the set_ pattern everywhere for now. For one, it follows the Rust API guidelines for naming setters. Also note that once we break the pattern, we might need to revisit all of the builder methods, which a) would be a larger refactor out-of-scope of this PR and b) would make the API less predicatable, IMO. But noted.

&mut self, service_config: LSPS2ServiceConfig,
) -> &mut Self {
let liquidity_source_config =
self.liquidity_source_config.get_or_insert(LiquiditySourceConfig::default());
liquidity_source_config.lsps2_service = Some(service_config);
self
}

Expand DownExpand Up@@ -471,10 +490,14 @@ impl NodeBuilder {

let config = Arc::new(self.config.clone());

let vss_xprv = derive_vss_xprv(config, &seed_bytes, Arc::clone(&logger))?;
let vss_xprv =
derive_xprv(config, &seed_bytes, VSS_HARDENED_CHILD_INDEX, Arc::clone(&logger))?;

let lnurl_auth_xprv = vss_xprv
.derive_priv(&Secp256k1::new(), &[ChildNumber::Hardened { index: 138 }])
.derive_priv(
&Secp256k1::new(),
&[ChildNumber::Hardened { index: VSS_LNURL_AUTH_HARDENED_CHILD_INDEX }],
)
.map_err(|e| {
log_error!(logger, "Failed to derive VSS secret: {}", e);
BuildError::KVStoreSetupFailed
Expand DownExpand Up@@ -536,7 +559,12 @@ impl NodeBuilder {

let config = Arc::new(self.config.clone());

let vss_xprv = derive_vss_xprv(config.clone(), &seed_bytes, Arc::clone(&logger))?;
let vss_xprv = derive_xprv(
config.clone(),
&seed_bytes,
VSS_HARDENED_CHILD_INDEX,
Arc::clone(&logger),
)?;

let vss_seed_bytes: [u8; 32] = vss_xprv.private_key.secret_bytes();

Expand DownExpand Up@@ -691,6 +719,16 @@ impl ArcedNodeBuilder {
self.inner.write().unwrap().set_liquidity_source_lsps2(node_id, address, token);
}

/// Configures the [`Node`] instance to provide an [LSPS2] service, issuing just-in-time
/// channels to clients.
///
/// **Caution**: LSP service support is in **alpha** and is considered an experimental feature.
///
/// [LSPS2]: https://github.com/BitcoinAndLightningLayerSpecs/lsp/blob/main/LSPS2/README.md
pub fn set_liquidity_provider_lsps2(&self, service_config: LSPS2ServiceConfig) {
self.inner.write().unwrap().set_liquidity_provider_lsps2(service_config);
}

/// 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);
Expand DownExpand Up@@ -1039,7 +1077,7 @@ fn build_with_store_internal(
};

let mut user_config = default_user_config(&config);
if liquidity_source_config.and_then(|lsc| lsc.lsps2_service.as_ref()).is_some() {
if liquidity_source_config.and_then(|lsc| lsc.lsps2_client.as_ref()).is_some() {
// Generally allow claiming underpaying HTLCs as the LSP will skim off some fee. We'll
// check that they don't take too much before claiming.
user_config.channel_config.accept_underpaying_htlcs = true;
Expand All@@ -1051,6 +1089,12 @@ fn build_with_store_internal(
100;
}

if liquidity_source_config.and_then(|lsc| lsc.lsps2_service.as_ref()).is_some() {
// If we act as an LSPS2 service, we need to to be able to intercept HTLCs and forward the
// information to the service handler.
user_config.accept_intercept_htlcs = true;
}

let message_router =
Arc::new(MessageRouter::new(Arc::clone(&network_graph), Arc::clone(&keys_manager)));

Expand DownExpand Up@@ -1171,31 +1215,53 @@ fn build_with_store_internal(
},
};

let liquidity_source = liquidity_source_config.as_ref().map(|lsc| {
let mut liquidity_source_builder = LiquiditySourceBuilder::new(
Arc::clone(&channel_manager),
Arc::clone(&keys_manager),
Arc::clone(&chain_source),
Arc::clone(&config),
Arc::clone(&logger),
);

lsc.lsps1_service.as_ref().map(|(node_id, address, token)| {
liquidity_source_builder.lsps1_service(*node_id, address.clone(), token.clone())
});
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.lsps2_service.as_ref().map(|(node_id, address, token)| {
liquidity_source_builder.lsps2_service(*node_id, address.clone(), token.clone())
});
lsc.lsps1_client.as_ref().map(|config| {
liquidity_source_builder.lsps1_client(
config.node_id,
config.address.clone(),
config.token.clone(),
)
});

Arc::new(liquidity_source_builder.build())
});
lsc.lsps2_client.as_ref().map(|config| {
liquidity_source_builder.lsps2_client(
config.node_id,
config.address.clone(),
config.token.clone(),
)
});

let custom_message_handler = if let Some(liquidity_source) = liquidity_source.as_ref() {
Arc::new(NodeCustomMessageHandler::new_liquidity(Arc::clone(&liquidity_source)))
} else {
Arc::new(NodeCustomMessageHandler::new_ignoring())
};
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)
} else {
(None, Arc::new(NodeCustomMessageHandler::new_ignoring()))
};

let msg_handler = match gossip_source.as_gossip_sync() {
GossipSync::P2P(p2p_gossip_sync) => MessageHandler {
Expand DownExpand Up@@ -1397,8 +1463,8 @@ fn seed_bytes_from_config(
}
}

fn derive_vss_xprv(
config: Arc<Config>, seed_bytes: &[u8; 64], logger: Arc<Logger>,
fn derive_xprv(
config: Arc<Config>, seed_bytes: &[u8; 64], hardened_child_index: u32, logger: Arc<Logger>,
) -> Result<Xpriv, BuildError> {
use bitcoin::key::Secp256k1;

Expand All@@ -1407,10 +1473,11 @@ fn derive_vss_xprv(
BuildError::InvalidSeedBytes
})?;

xprv.derive_priv(&Secp256k1::new(), &[ChildNumber::Hardened { index: 877 }]).map_err(|e| {
log_error!(logger, "Failed to derive VSS secret: {}", e);
BuildError::KVStoreSetupFailed
})
xprv.derive_priv(&Secp256k1::new(), &[ChildNumber::Hardened { index: hardened_child_index }])
.map_err(|e| {
log_error!(logger, "Failed to derive hardened child secret: {}", e);
BuildError::InvalidSeedBytes
})
}

/// Sanitize the user-provided node alias to ensure that it is a valid protocol-specified UTF-8 string.
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); Add bLIP-52 / LSPS2 service-side support by tnull · Pull Request #420 · lightningdevkit/ldk-node · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions bindings/ldk_node.udl
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,6 +25,18 @@ dictionary EsploraSyncConfig {
u64 fee_rate_cache_update_interval_secs;
};

dictionary LSPS2ServiceConfig {
string? require_token;
boolean advertise_service;
u32 channel_opening_fee_ppm;
u32 channel_over_provisioning_ppm;
u64 min_channel_opening_fee_msat;
u32 min_channel_lifetime;
u32 max_client_to_self_delay;
u64 min_payment_size_msat;
u64 max_payment_size_msat;
};

enum LogLevel {
"Gossip",
"Trace",
Expand Down
159 changes: 113 additions & 46 deletions src/builder.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,7 +18,9 @@ use crate::gossip::GossipSource;
use crate::io::sqlite_store::SqliteStore;
use crate::io::utils::{read_node_metrics, write_node_metrics};
use crate::io::vss_store::VssStore;
use crate::liquidity::LiquiditySourceBuilder;
use crate::liquidity::{
LSPS1ClientConfig, LSPS2ClientConfig, LSPS2ServiceConfig, LiquiditySourceBuilder,
};
use crate::logger::{log_error, log_info, LdkLogger, LogLevel, LogWriter, Logger};
use crate::message_handler::NodeCustomMessageHandler;
use crate::payment::store::PaymentStore;
Expand DownExpand Up@@ -75,6 +77,10 @@ use std::sync::{Arc, Mutex, RwLock};
use std::time::SystemTime;
use vss_client::headers::{FixedHeaders, LnurlAuthToJwtProvider, VssHeaderProvider};

const VSS_HARDENED_CHILD_INDEX: u32 = 877;
const VSS_LNURL_AUTH_HARDENED_CHILD_INDEX: u32 = 138;
const LSPS_HARDENED_CHILD_INDEX: u32 = 577;

#[derive(Debug, Clone)]
enum ChainDataSourceConfig {
Esplora { server_url: String, sync_config: Option<EsploraSyncConfig> },
Expand All@@ -94,18 +100,14 @@ enum GossipSourceConfig {
RapidGossipSync(String),
}

#[derive(Debug, Clone)]
#[derive(Debug, Clone, Default)]
struct LiquiditySourceConfig {
// LSPS1 service's (node_id, address, token)
lsps1_service: Option<(PublicKey, SocketAddress, Option<String>)>,
// LSPS2 service's (node_id, address, token)
lsps2_service: Option<(PublicKey, SocketAddress, Option<String>)>,
}

impl Default for LiquiditySourceConfig {
fn default() -> Self {
Self { lsps1_service: None, lsps2_service: None }
}
// Act as an LSPS1 client connecting to the given service.
lsps1_client: Option<LSPS1ClientConfig>,
// Act as an LSPS2 client connecting to the given service.
lsps2_client: Option<LSPS2ClientConfig>,
// Act as an LSPS2 service.
lsps2_service: Option<LSPS2ServiceConfig>,
}

#[derive(Clone)]
Expand DownExpand Up@@ -319,7 +321,8 @@ impl NodeBuilder {

let liquidity_source_config =
self.liquidity_source_config.get_or_insert(LiquiditySourceConfig::default());
liquidity_source_config.lsps1_service = Some((node_id, address, token));
let lsps1_client_config = LSPS1ClientConfig { node_id, address, token };
liquidity_source_config.lsps1_client = Some(lsps1_client_config);
self
}

Expand All@@ -339,7 +342,23 @@ impl NodeBuilder {

let liquidity_source_config =
self.liquidity_source_config.get_or_insert(LiquiditySourceConfig::default());
liquidity_source_config.lsps2_service = Some((node_id, address, token));
let lsps2_client_config = LSPS2ClientConfig { node_id, address, token };
liquidity_source_config.lsps2_client = Some(lsps2_client_config);
self
}

/// Configures the [`Node`] instance to provide an [LSPS2] service, issuing just-in-time
/// channels to clients.
///
/// **Caution**: LSP service support is in **alpha** and is considered an experimental feature.
///
/// [LSPS2]: https://github.com/BitcoinAndLightningLayerSpecs/lsp/blob/main/LSPS2/README.md
pub fn set_liquidity_provider_lsps2(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know we have probably used setX in names because it is builder but it is getting slightly confusing with lsps client and service.
Personally, I was slightly confused due to the fact "we need to set liquidity provider to act as lsps-client".

no strong opinion, but imo, these methods could be renamed to enable_lsps2_service, enable_lsps2_client and so on.
what do you think?

alternatively, we can add another layer here similar to .bolt11() and .bolt12().

@tnulltnullFeb 14, 2025

Copy link
Copy Markdown
CollaboratorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, thanks for bringing this up. I see you point in that theset_liquidity_provider case that could be a bit confusing, but still think I'd like to keep the set_ pattern everywhere for now. For one, it follows the Rust API guidelines for naming setters. Also note that once we break the pattern, we might need to revisit all of the builder methods, which a) would be a larger refactor out-of-scope of this PR and b) would make the API less predicatable, IMO. But noted.

&mut self, service_config: LSPS2ServiceConfig,
) -> &mut Self {
let liquidity_source_config =
self.liquidity_source_config.get_or_insert(LiquiditySourceConfig::default());
liquidity_source_config.lsps2_service = Some(service_config);
self
}

Expand DownExpand Up@@ -471,10 +490,14 @@ impl NodeBuilder {

let config = Arc::new(self.config.clone());

let vss_xprv = derive_vss_xprv(config, &seed_bytes, Arc::clone(&logger))?;
let vss_xprv =
derive_xprv(config, &seed_bytes, VSS_HARDENED_CHILD_INDEX, Arc::clone(&logger))?;

let lnurl_auth_xprv = vss_xprv
.derive_priv(&Secp256k1::new(), &[ChildNumber::Hardened { index: 138 }])
.derive_priv(
&Secp256k1::new(),
&[ChildNumber::Hardened { index: VSS_LNURL_AUTH_HARDENED_CHILD_INDEX }],
)
.map_err(|e| {
log_error!(logger, "Failed to derive VSS secret: {}", e);
BuildError::KVStoreSetupFailed
Expand DownExpand Up@@ -536,7 +559,12 @@ impl NodeBuilder {

let config = Arc::new(self.config.clone());

let vss_xprv = derive_vss_xprv(config.clone(), &seed_bytes, Arc::clone(&logger))?;
let vss_xprv = derive_xprv(
config.clone(),
&seed_bytes,
VSS_HARDENED_CHILD_INDEX,
Arc::clone(&logger),
)?;

let vss_seed_bytes: [u8; 32] = vss_xprv.private_key.secret_bytes();

Expand DownExpand Up@@ -691,6 +719,16 @@ impl ArcedNodeBuilder {
self.inner.write().unwrap().set_liquidity_source_lsps2(node_id, address, token);
}

/// Configures the [`Node`] instance to provide an [LSPS2] service, issuing just-in-time
/// channels to clients.
///
/// **Caution**: LSP service support is in **alpha** and is considered an experimental feature.
///
/// [LSPS2]: https://github.com/BitcoinAndLightningLayerSpecs/lsp/blob/main/LSPS2/README.md
pub fn set_liquidity_provider_lsps2(&self, service_config: LSPS2ServiceConfig) {
self.inner.write().unwrap().set_liquidity_provider_lsps2(service_config);
}

/// 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);
Expand DownExpand Up@@ -1039,7 +1077,7 @@ fn build_with_store_internal(
};

let mut user_config = default_user_config(&config);
if liquidity_source_config.and_then(|lsc| lsc.lsps2_service.as_ref()).is_some() {
if liquidity_source_config.and_then(|lsc| lsc.lsps2_client.as_ref()).is_some() {
// Generally allow claiming underpaying HTLCs as the LSP will skim off some fee. We'll
// check that they don't take too much before claiming.
user_config.channel_config.accept_underpaying_htlcs = true;
Expand All@@ -1051,6 +1089,12 @@ fn build_with_store_internal(
100;
}

if liquidity_source_config.and_then(|lsc| lsc.lsps2_service.as_ref()).is_some() {
// If we act as an LSPS2 service, we need to to be able to intercept HTLCs and forward the
// information to the service handler.
user_config.accept_intercept_htlcs = true;
}

let message_router =
Arc::new(MessageRouter::new(Arc::clone(&network_graph), Arc::clone(&keys_manager)));

Expand DownExpand Up@@ -1171,31 +1215,53 @@ fn build_with_store_internal(
},
};

let liquidity_source = liquidity_source_config.as_ref().map(|lsc| {
let mut liquidity_source_builder = LiquiditySourceBuilder::new(
Arc::clone(&channel_manager),
Arc::clone(&keys_manager),
Arc::clone(&chain_source),
Arc::clone(&config),
Arc::clone(&logger),
);

lsc.lsps1_service.as_ref().map(|(node_id, address, token)| {
liquidity_source_builder.lsps1_service(*node_id, address.clone(), token.clone())
});
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.lsps2_service.as_ref().map(|(node_id, address, token)| {
liquidity_source_builder.lsps2_service(*node_id, address.clone(), token.clone())
});
lsc.lsps1_client.as_ref().map(|config| {
liquidity_source_builder.lsps1_client(
config.node_id,
config.address.clone(),
config.token.clone(),
)
});

Arc::new(liquidity_source_builder.build())
});
lsc.lsps2_client.as_ref().map(|config| {
liquidity_source_builder.lsps2_client(
config.node_id,
config.address.clone(),
config.token.clone(),
)
});

let custom_message_handler = if let Some(liquidity_source) = liquidity_source.as_ref() {
Arc::new(NodeCustomMessageHandler::new_liquidity(Arc::clone(&liquidity_source)))
} else {
Arc::new(NodeCustomMessageHandler::new_ignoring())
};
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)
} else {
(None, Arc::new(NodeCustomMessageHandler::new_ignoring()))
};

let msg_handler = match gossip_source.as_gossip_sync() {
GossipSync::P2P(p2p_gossip_sync) => MessageHandler {
Expand DownExpand Up@@ -1397,8 +1463,8 @@ fn seed_bytes_from_config(
}
}

fn derive_vss_xprv(
config: Arc<Config>, seed_bytes: &[u8; 64], logger: Arc<Logger>,
fn derive_xprv(
config: Arc<Config>, seed_bytes: &[u8; 64], hardened_child_index: u32, logger: Arc<Logger>,
) -> Result<Xpriv, BuildError> {
use bitcoin::key::Secp256k1;

Expand All@@ -1407,10 +1473,11 @@ fn derive_vss_xprv(
BuildError::InvalidSeedBytes
})?;

xprv.derive_priv(&Secp256k1::new(), &[ChildNumber::Hardened { index: 877 }]).map_err(|e| {
log_error!(logger, "Failed to derive VSS secret: {}", e);
BuildError::KVStoreSetupFailed
})
xprv.derive_priv(&Secp256k1::new(), &[ChildNumber::Hardened { index: hardened_child_index }])
.map_err(|e| {
log_error!(logger, "Failed to derive hardened child secret: {}", e);
BuildError::InvalidSeedBytes
})
}

/// Sanitize the user-provided node alias to ensure that it is a valid protocol-specified UTF-8 string.
Expand Down
Loading