Add `onchain_payment_required` method
What changed, and why it matters
This commit adds a new method so a Lightning Service Provider (LSP) can cleanly reject a customer's order when the LSP only accepts on-chain Bitcoin payments but the customer did not supply a refund address. Before this change, the code had no explicit way to reject such orders, which could have led to confused or stuck orders. The change is a defensive API improvement, not a fix for an active exploit.
Treat as a normal feature/defensive-hardening commit. LSP operators using rust-lightning's LSPS1 module should adopt this method and reject orders that require on-chain payment without a refund address. No urgent security patch is required, but downstream implementers should update their event handling to call onchain_payments_required when appropriate.
Security signals we found
Adds explicit validation/rejection path for missing refund_onchain_address when on-chain payment is required
Renames error constant from ORDER_MISMATCH to OPTION_MISMATCH to better cover option validation failures
Removes the 'require onchain payment and no refund address' case from order_failed_and_refunded documentation, centralizing rejection in the new method
No memory safety issues, cryptographic bugs, or privilege escalation observed
Evidence from the diff
The patch introduces LSPS1ServiceHandler::onchain_payments_required and a wrapper in LSPS1ServiceHandler. It renames LSPS1_CREATE_ORDER_REQUEST_ORDER_MISMATCH_ERROR_CODE to LSPS1_CREATE_ORDER_REQUEST_OPTION_MISMATCH_ERROR_CODE and uses that code for the new rejection path. The method sends a JSON-RPC error response (code 100) when the LSP requires on-chain payment and refund_onchain_address is None. Documentation is updated to tell LSP implementers to call this method instead of silently proceeding or misusing order_failed_and_refunded.
Changed components
lightning-liquidity/src/lsps1/event.rslightning-liquidity/src/lsps1/msgs.rslightning-liquidity/src/lsps1/service.rsInspect captured patch +58 / −7
diff --git a/lightning-liquidity/src/lsps1/event.rs b/lightning-liquidity/src/lsps1/event.rs
index 1d18842..8868790 100644
--- a/lightning-liquidity/src/lsps1/event.rs
+++ b/lightning-liquidity/src/lsps1/event.rs
@@ -170,8 +170,10 @@ pub enum LSPS1ServiceEvent {
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.
+ /// If this is `None` and you *require* onchain payment, you should call
+ /// [`LSPS1ServiceHandler::onchain_payments_required`] to reject the request.
+ ///
+ /// [`LSPS1ServiceHandler::onchain_payments_required`]: crate::lsps1::service::LSPS1ServiceHandler::onchain_payments_required
refund_onchain_address: Option<Address>,
},
}
diff --git a/lightning-liquidity/src/lsps1/msgs.rs b/lightning-liquidity/src/lsps1/msgs.rs
index 9eff06e..b754f04 100644
--- a/lightning-liquidity/src/lsps1/msgs.rs
+++ b/lightning-liquidity/src/lsps1/msgs.rs
@@ -31,7 +31,7 @@ pub(crate) const LSPS1_CREATE_ORDER_METHOD_NAME: &str = "lsps1.create_order";
pub(crate) const LSPS1_GET_ORDER_METHOD_NAME: &str = "lsps1.get_order";
pub(crate) const _LSPS1_CREATE_ORDER_REQUEST_INVALID_PARAMS_ERROR_CODE: i32 = -32602;
-pub(crate) const LSPS1_CREATE_ORDER_REQUEST_ORDER_MISMATCH_ERROR_CODE: i32 = 100;
+pub(crate) const LSPS1_CREATE_ORDER_REQUEST_OPTION_MISMATCH_ERROR_CODE: i32 = 100;
pub(crate) const LSPS1_GET_ORDER_REQUEST_ORDER_NOT_FOUND_ERROR_CODE: i32 = 101;
pub(crate) const LSPS1_CREATE_ORDER_REQUEST_UNRECOGNIZED_OR_STALE_TOKEN_ERROR_CODE: i32 = 102;
diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs
index bc10116..9d58ea0 100644
--- a/lightning-liquidity/src/lsps1/service.rs
+++ b/lightning-liquidity/src/lsps1/service.rs
@@ -23,7 +23,7 @@ use super::msgs::{
LSPS1ChannelInfo, LSPS1CreateOrderRequest, LSPS1CreateOrderResponse, LSPS1GetInfoResponse,
LSPS1GetOrderRequest, LSPS1Message, LSPS1Options, LSPS1OrderId, LSPS1OrderParams,
LSPS1PaymentInfo, LSPS1PaymentState, LSPS1Request, LSPS1Response,
- LSPS1_CREATE_ORDER_REQUEST_ORDER_MISMATCH_ERROR_CODE,
+ LSPS1_CREATE_ORDER_REQUEST_OPTION_MISMATCH_ERROR_CODE,
LSPS1_CREATE_ORDER_REQUEST_UNRECOGNIZED_OR_STALE_TOKEN_ERROR_CODE,
LSPS1_GET_ORDER_REQUEST_ORDER_NOT_FOUND_ERROR_CODE,
};
@@ -291,7 +291,7 @@ where
if !is_valid(¶ms.order, &self.config.supported_options) {
let response = LSPS1Response::CreateOrderError(LSPSResponseError {
- code: LSPS1_CREATE_ORDER_REQUEST_ORDER_MISMATCH_ERROR_CODE,
+ code: LSPS1_CREATE_ORDER_REQUEST_OPTION_MISMATCH_ERROR_CODE,
message: "Order does not match options supported by LSP server".to_string(),
data: Some(format!("Supported options are {:?}", &self.config.supported_options)),
});
@@ -337,7 +337,8 @@ 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`.
+ /// user didn't provide a `refund_onchain_address`. If you *require* onchain payments, you need
+ /// to call [`Self::onchain_payments_required`] to reject the request.
///
/// [`LSPS1ServiceEvent::RequestForPaymentDetails`]: crate::lsps1::event::LSPS1ServiceEvent::RequestForPaymentDetails
pub async fn send_payment_details(
@@ -496,6 +497,45 @@ where
}
}
+ /// Used by LSP to inform a client that an order was rejected because they require onchain
+ /// payments and the client didn't provide a `refund_onchain_address`.
+ ///
+ /// Should be called in response to receiving a [`LSPS1ServiceEvent::RequestForPaymentDetails`]
+ /// event if the LSP requires onchain payments and `refund_onchain_address` is `None`.
+ ///
+ /// [`LSPS1ServiceEvent::RequestForPaymentDetails`]: crate::lsps1::event::LSPS1ServiceEvent::RequestForPaymentDetails
+ pub fn onchain_payments_required(
+ &self, counterparty_node_id: PublicKey, request_id: LSPSRequestId,
+ ) -> Result<(), APIError> {
+ let mut message_queue_notifier = self.pending_messages.notifier();
+
+ 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();
+ peer_state_lock.remove_request(&request_id).map_err(|e| {
+ debug_assert!(false, "Failed to send response due to: {}", e);
+ let err = format!("Failed to send response due to: {}", e);
+ APIError::APIMisuseError { err }
+ })?;
+
+ let response = LSPS1Response::CreateOrderError(LSPSResponseError {
+ code: LSPS1_CREATE_ORDER_REQUEST_OPTION_MISMATCH_ERROR_CODE,
+ message:
+ "We require onchain payment but no `refund_onchain_address` was provided"
+ .to_string(),
+ data: None,
+ });
+
+ let msg = LSPS1Message::Response(request_id, response).into();
+ message_queue_notifier.enqueue(&counterparty_node_id, msg);
+ Ok(())
+ },
+ None => Err(APIError::APIMisuseError {
+ err: format!("No state for the counterparty exists: {}", counterparty_node_id),
+ }),
+ }
+ }
+
fn handle_get_order_request(
&self, request_id: LSPSRequestId, counterparty_node_id: &PublicKey,
params: LSPS1GetOrderRequest,
@@ -640,7 +680,6 @@ where
/// Marks an order as failed and refunded.
///
/// This should be called when:
- /// - We require onchain payment and the client didn't provide a `refund_onchain_address`.
/// - The order expires without payment
/// - The channel open fails after payment and the LSP must refund
pub async fn order_failed_and_refunded(
@@ -782,6 +821,16 @@ where
self.inner.invalid_token_provided(counterparty_node_id, request_id)
}
+ /// Used by LSP to inform a client that an order was rejected because they require onchain
+ /// payments and the client didn't provide a `refund_onchain_address`.
+ ///
+ /// Wraps [`LSPS1ServiceHandler::onchain_payments_required`].
+ pub fn onchain_payments_required(
+ &self, counterparty_node_id: PublicKey, request_id: LSPSRequestId,
+ ) -> Result<(), APIError> {
+ self.inner.onchain_payments_required(counterparty_node_id, request_id)
+ }
+
/// Marks an order as paid after payment has been received.
///
/// Wraps [`LSPS1ServiceHandler::order_payment_received`].
Why this scored 32/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.