Add some checks on provided payment details
What changed, and why it matters
This commit adds validation checks in the LSPS1 (liquidity service) code to make sure a service provider cannot accidentally offer on-chain Bitcoin refunds when the customer never gave a refund address. It also requires at least one payment method and ensures each payment method starts in the correct initial state. The change is defensive: it prevents protocol non-compliance and reduces the risk of funds being sent to an unknown address if a channel opening fails.
Review is sufficient; this is a hardening/spec-compliance change. Ensure downstream service implementations handle the new APIMisuseError variants gracefully and that documentation reflects the requirement to provide a refund_onchain_address before offering on-chain payments.
Security signals we found
Prevents on-chain refund offers when no refund_onchain_address was supplied, avoiding potential loss of refund funds
Adds input validation (at least one payment detail) to avoid empty payment option responses
Adds state validation to ensure payment methods start in ExpectPayment
Validates request before removing it from peer state, improving retry behavior on operator error
Evidence from the diff
In rust-lightning’s lightning-liquidity LSPS1 service, the patch enforces three bLIP-51/spec-compliant rules when a service operator calls send_payment_details: (1) at least one of bolt11, bolt12, or onchain payment details must be provided; (2) every provided payment method must have state ExpectPayment; and (3) onchain payment details are rejected if the original CreateOrder request did not include a refund_onchain_address. The refund_onchain_address is now forwarded through the RequestForPaymentDetails event so the service can see it, and the pending request is validated before removal so a failed validation can be retried.
Changed components
lightning-liquidity/src/lsps1/event.rslightning-liquidity/src/lsps1/peer_state.rslightning-liquidity/src/lsps1/service.rslightning-liquidity/tests/lsps1_integration_tests.rsInspect captured patch +76 / −4
diff --git a/lightning-liquidity/src/lsps1/event.rs b/lightning-liquidity/src/lsps1/event.rs
index d966f8b..cdd0995 100644
--- a/lightning-liquidity/src/lsps1/event.rs
+++ b/lightning-liquidity/src/lsps1/event.rs
@@ -15,6 +15,7 @@ use super::msgs::{LSPS1ChannelInfo, LSPS1Options, LSPS1OrderParams, LSPS1Payment
use crate::lsps0::ser::{LSPSRequestId, LSPSResponseError};
use bitcoin::secp256k1::PublicKey;
+use bitcoin::Address;
/// An event which an bLIP-51 / LSPS1 client should take some action in response to.
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -164,6 +165,11 @@ pub enum LSPS1ServiceEvent {
counterparty_node_id: PublicKey,
/// The order requested by the client.
order: LSPS1OrderParams,
+ /// The address we need to send onchain refunds to in case channel opening fails.
+ ///
+ /// Please note that you can't offer onchain payments if this was not provided by the
+ /// client.
+ refund_onchain_address: Option<Address>,
},
/// If error is encountered, refund the amount if paid by the client.
///
diff --git a/lightning-liquidity/src/lsps1/peer_state.rs b/lightning-liquidity/src/lsps1/peer_state.rs
index 2b94f76..1b51f64 100644
--- a/lightning-liquidity/src/lsps1/peer_state.rs
+++ b/lightning-liquidity/src/lsps1/peer_state.rs
@@ -82,6 +82,12 @@ impl PeerState {
Ok(())
}
+ pub(super) fn get_request(
+ &self, request_id: &LSPSRequestId,
+ ) -> Result<&LSPS1Request, PeerStateError> {
+ self.pending_requests.get(request_id).ok_or(PeerStateError::UnknownRequestId)
+ }
+
pub(super) fn remove_request(
&mut self, request_id: &LSPSRequestId,
) -> Result<LSPS1Request, PeerStateError> {
diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs
index 459406e..478fc29 100644
--- a/lightning-liquidity/src/lsps1/service.rs
+++ b/lightning-liquidity/src/lsps1/service.rs
@@ -22,7 +22,7 @@ use super::event::LSPS1ServiceEvent;
use super::msgs::{
LSPS1ChannelInfo, LSPS1CreateOrderRequest, LSPS1CreateOrderResponse, LSPS1GetInfoResponse,
LSPS1GetOrderRequest, LSPS1Message, LSPS1Options, LSPS1OrderId, LSPS1OrderParams,
- LSPS1OrderState, LSPS1PaymentInfo, LSPS1Request, LSPS1Response,
+ LSPS1OrderState, LSPS1PaymentInfo, LSPS1PaymentState, LSPS1Request, LSPS1Response,
LSPS1_CREATE_ORDER_REQUEST_ORDER_MISMATCH_ERROR_CODE,
LSPS1_GET_ORDER_REQUEST_ORDER_NOT_FOUND_ERROR_CODE,
};
@@ -326,6 +326,7 @@ where
request_id,
counterparty_node_id: *counterparty_node_id,
order: params.order,
+ refund_onchain_address: params.refund_onchain_address,
});
Ok(())
@@ -335,6 +336,9 @@ where
///
/// Should be called in response to receiving a [`LSPS1ServiceEvent::RequestForPaymentDetails`] event.
///
+ /// Note that the provided `payment_details` can't include the onchain payment variant if the
+ /// user didn't provide a `refund_onchain_address`.
+ ///
/// [`LSPS1ServiceEvent::RequestForPaymentDetails`]: crate::lsps1::event::LSPS1ServiceEvent::RequestForPaymentDetails
pub async fn send_payment_details(
&self, request_id: LSPSRequestId, counterparty_node_id: PublicKey,
@@ -343,9 +347,54 @@ where
let mut message_queue_notifier = self.pending_messages.notifier();
let mut should_persist = false;
+ if payment_details.bolt11.is_none()
+ && payment_details.bolt12.is_none()
+ && payment_details.onchain.is_none()
+ {
+ let err = "At least one payment option must be provided".to_string();
+ return Err(APIError::APIMisuseError { err });
+ }
+
+ if payment_details
+ .bolt11
+ .as_ref()
+ .is_some_and(|b| b.state != LSPS1PaymentState::ExpectPayment)
+ || payment_details
+ .bolt12
+ .as_ref()
+ .is_some_and(|b| b.state != LSPS1PaymentState::ExpectPayment)
+ || payment_details
+ .onchain
+ .as_ref()
+ .is_some_and(|o| o.state != LSPS1PaymentState::ExpectPayment)
+ {
+ return Err(APIError::APIMisuseError {
+ err: "All payment methods must start in ExpectPayment state".to_string(),
+ });
+ }
+
match self.per_peer_state.read().unwrap().get(&counterparty_node_id) {
Some(inner_state_lock) => {
let mut peer_state_lock = inner_state_lock.lock().unwrap();
+
+ // Validate payment_details against the pending request before removing it,
+ // so the LSP operator can retry on failure.
+ if payment_details.onchain.is_some() {
+ let request = peer_state_lock.get_request(&request_id).map_err(|e| {
+ let err = format!("Failed to send response due to: {}", e);
+ APIError::APIMisuseError { err }
+ })?;
+ let has_refund_addr = matches!(
+ request,
+ LSPS1Request::CreateOrder(p) if p.refund_onchain_address.is_some()
+ );
+ if !has_refund_addr {
+ // bLIP-51: 'LSP MUST disable on-chain payments if the client omits this field.'
+ let err = "Onchain payments must be disabled if no refund_onchain_address is set.".to_string();
+ return Err(APIError::APIMisuseError { err });
+ }
+ }
+
let request = peer_state_lock.remove_request(&request_id).map_err(|e| {
let err = format!("Failed to send response due to: {}", e);
APIError::APIMisuseError { err }
@@ -357,6 +406,7 @@ where
let created_at = LSPSDateTime::new_from_duration_since_epoch(
self.time_provider.duration_since_epoch(),
);
+
let order = peer_state_lock.new_order(
order_id.clone(),
params.order,
diff --git a/lightning-liquidity/tests/lsps1_integration_tests.rs b/lightning-liquidity/tests/lsps1_integration_tests.rs
index 01c9a38..0261a08 100644
--- a/lightning-liquidity/tests/lsps1_integration_tests.rs
+++ b/lightning-liquidity/tests/lsps1_integration_tests.rs
@@ -145,8 +145,15 @@ fn lsps1_happy_path() {
announce_channel: true,
};
- let _create_order_id =
- client_handler.create_order(&service_node_id, order_params.clone(), None);
+ let refund_onchain_address =
+ Address::from_str("bc1p5uvtaxzkjwvey2tfy49k5vtqfpjmrgm09cvs88ezyy8h2zv7jhas9tu4yr")
+ .unwrap()
+ .assume_checked();
+ let _create_order_id = client_handler.create_order(
+ &service_node_id,
+ order_params.clone(),
+ Some(refund_onchain_address.clone()),
+ );
let create_order = get_lsps_message!(client_node, service_node_id);
service_node.liquidity_manager.handle_custom_message(create_order, client_node_id).unwrap();
@@ -157,11 +164,14 @@ fn lsps1_happy_path() {
request_id,
counterparty_node_id,
order,
+ refund_onchain_address: refund_addr,
+ ..
}) = _request_for_payment_event
{
assert_eq!(request_id, _create_order_id.clone());
assert_eq!(counterparty_node_id, client_node_id);
assert_eq!(order, order_params);
+ assert_eq!(refund_addr, Some(refund_onchain_address));
} else {
panic!("Unexpected event");
}
@@ -339,7 +349,7 @@ fn lsps1_service_handler_persistence_across_restarts() {
let create_order_id = client_handler.create_order(
&service_node_id,
order_params.clone(),
- Some(refund_onchain_address),
+ Some(refund_onchain_address.clone()),
);
let create_order = get_lsps_message!(client_node, service_node_id);
Why this scored 36/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.