Add `Hold` payment state per bLIP-51 spec
What changed, and why it matters
This commit adds a new 'Hold' payment state to the LSPS1 (Lightning Service Provider Specification 1) implementation in rust-lightning. Previously, when a customer paid for a channel, the system immediately marked it as 'Paid' even though the channel had not been opened yet. The new 'Hold' state correctly reflects that the payment has been received but the channel opening is still pending. This is a protocol/specification compliance change that improves state accuracy and reduces the chance of misleading status reporting, but it does not fix a memory-safety bug or an active exploit.
Review as a normal specification-compliance change. Verify that the renumbered TLV enum indices do not break wire compatibility with existing deployments, and confirm that downstream consumers of `LSPS1PaymentState` handle the new `Hold` variant. No urgent security response is indicated by the commit content.
Security signals we found
State machine correctness: prevents premature 'Paid' status before channel funding is published
Spec compliance: implements bLIP-51 HOLD intermediate state
Serialization renumbering: TLV indices changed for enum variants (backward compatibility should be considered by downstream reviewers)
Evidence from the diff
The patch introduces a Hold variant to LSPS1PaymentState and renumbers the TLV serialization indices. It changes payment_received() to set state to Hold instead of Paid, and adds a channel_opened() transition that moves Hold to Paid. Tests are updated to assert the intermediate state. The change aligns the implementation with bLIP-51. There is no evidence of a vulnerability being patched; it is a state-machine correctness and spec-compliance improvement.
Changed components
lightning-liquidity/src/lsps1/msgs.rslightning-liquidity/src/lsps1/peer_state.rslightning-liquidity/tests/lsps1_integration_tests.rsInspect captured patch +54 / −15
diff --git a/lightning-liquidity/src/lsps1/msgs.rs b/lightning-liquidity/src/lsps1/msgs.rs
index 6021b65..9eff06e 100644
--- a/lightning-liquidity/src/lsps1/msgs.rs
+++ b/lightning-liquidity/src/lsps1/msgs.rs
@@ -310,7 +310,12 @@ impl_writeable_tlv_based!(LSPS1OnchainPaymentInfo, {
pub enum LSPS1PaymentState {
/// A payment is expected.
ExpectPayment,
- /// A sufficient payment has been received.
+ /// A payment has been received but the channel has not yet been opened.
+ ///
+ /// This indicates the LSP has received the payment (e.g., Lightning HTLC held,
+ /// or on-chain transaction detected) but has not yet published the funding transaction.
+ Hold,
+ /// A sufficient payment has been received and the channel has been opened.
Paid,
/// The payment has been refunded.
#[serde(alias = "CANCELLED")]
@@ -319,8 +324,9 @@ pub enum LSPS1PaymentState {
impl_writeable_tlv_based_enum!(LSPS1PaymentState,
(0, ExpectPayment) => {},
- (2, Paid) => {},
- (4, Refunded) => {}
+ (2, Hold) => {},
+ (4, Paid) => {},
+ (6, Refunded) => {}
);
/// Details regarding the state of an ordered channel.
diff --git a/lightning-liquidity/src/lsps1/peer_state.rs b/lightning-liquidity/src/lsps1/peer_state.rs
index 1d13d07..d2b806c 100644
--- a/lightning-liquidity/src/lsps1/peer_state.rs
+++ b/lightning-liquidity/src/lsps1/peer_state.rs
@@ -102,17 +102,17 @@ impl ChannelOrderState {
/// Transition: ExpectingPayment -> OrderPaid
///
- /// Updates the specified payment method's state to PAID.
+ /// Updates the specified payment method's state to HOLD.
pub(super) fn payment_received(
&mut self, method: PaymentMethod,
) -> Result<(), ChannelOrderStateError> {
match self {
ChannelOrderState::ExpectingPayment { payment_details } => {
- // Update the payment state for the specified method
+ // Update the payment state for the specified method to HOLD
let method_exists = match method {
PaymentMethod::Bolt11 => {
if let Some(ref mut bolt11) = payment_details.bolt11 {
- bolt11.state = LSPS1PaymentState::Paid;
+ bolt11.state = LSPS1PaymentState::Hold;
true
} else {
false
@@ -120,7 +120,7 @@ impl ChannelOrderState {
},
PaymentMethod::Bolt12 => {
if let Some(ref mut bolt12) = payment_details.bolt12 {
- bolt12.state = LSPS1PaymentState::Paid;
+ bolt12.state = LSPS1PaymentState::Hold;
true
} else {
false
@@ -128,7 +128,7 @@ impl ChannelOrderState {
},
PaymentMethod::Onchain => {
if let Some(ref mut onchain) = payment_details.onchain {
- onchain.state = LSPS1PaymentState::Paid;
+ onchain.state = LSPS1PaymentState::Hold;
true
} else {
false
@@ -152,13 +152,33 @@ impl ChannelOrderState {
}
/// Transition: OrderPaid -> CompletedAndChannelOpened
+ ///
+ /// Updates payment states from HOLD to PAID.
pub(super) fn channel_opened(
&mut self, channel_info: LSPS1ChannelInfo,
) -> Result<(), ChannelOrderStateError> {
match self {
ChannelOrderState::OrderPaid { payment_details } => {
+ // Update payment states from HOLD to PAID
+ let mut paid_details = payment_details.clone();
+ if let Some(ref mut bolt11) = paid_details.bolt11 {
+ if bolt11.state == LSPS1PaymentState::Hold {
+ bolt11.state = LSPS1PaymentState::Paid;
+ }
+ }
+ if let Some(ref mut bolt12) = paid_details.bolt12 {
+ if bolt12.state == LSPS1PaymentState::Hold {
+ bolt12.state = LSPS1PaymentState::Paid;
+ }
+ }
+ if let Some(ref mut onchain) = paid_details.onchain {
+ if onchain.state == LSPS1PaymentState::Hold {
+ onchain.state = LSPS1PaymentState::Paid;
+ }
+ }
+
*self = ChannelOrderState::CompletedAndChannelOpened {
- payment_details: payment_details.clone(),
+ payment_details: paid_details,
channel_info,
};
Ok(())
@@ -276,7 +296,7 @@ impl PeerState {
/// Transition: ExpectingPayment -> OrderPaid
///
- /// Updates the specified payment method's state to PAID.
+ /// Updates the specified payment method's state to HOLD.
pub(super) fn order_payment_received(
&mut self, order_id: &LSPS1OrderId, method: PaymentMethod,
) -> Result<(), PeerStateError> {
@@ -530,7 +550,8 @@ mod tests {
assert!(matches!(state, ChannelOrderState::OrderPaid { .. }));
assert_eq!(state.order_state(), LSPS1OrderState::Created);
- assert_eq!(state.payment_details().bolt11.as_ref().unwrap().state, LSPS1PaymentState::Paid);
+ // Payment state should be HOLD (not PAID) until channel is opened
+ assert_eq!(state.payment_details().bolt11.as_ref().unwrap().state, LSPS1PaymentState::Hold);
}
// Test valid transition: ExpectingPayment -> OrderPaid via payment_received (Onchain)
@@ -542,9 +563,10 @@ mod tests {
state.payment_received(PaymentMethod::Onchain).unwrap();
assert!(matches!(state, ChannelOrderState::OrderPaid { .. }));
+ // Payment state should be HOLD (not PAID) until channel is opened
assert_eq!(
state.payment_details().onchain.as_ref().unwrap().state,
- LSPS1PaymentState::Paid
+ LSPS1PaymentState::Hold
);
}
@@ -555,12 +577,17 @@ mod tests {
let mut state = ChannelOrderState::new(payment_info);
state.payment_received(PaymentMethod::Bolt11).unwrap();
+ // Verify payment state is HOLD before channel opens
+ assert_eq!(state.payment_details().bolt11.as_ref().unwrap().state, LSPS1PaymentState::Hold);
+
let channel_info = create_test_channel_info();
state.channel_opened(channel_info.clone()).unwrap();
assert!(matches!(state, ChannelOrderState::CompletedAndChannelOpened { .. }));
assert_eq!(state.order_state(), LSPS1OrderState::Completed);
assert_eq!(state.channel_info(), Some(&channel_info));
+ // Payment state should now be PAID after channel is opened
+ assert_eq!(state.payment_details().bolt11.as_ref().unwrap().state, LSPS1PaymentState::Paid);
}
// Test valid transition: ExpectingPayment -> FailedAndRefunded
@@ -586,10 +613,14 @@ mod tests {
let mut state = ChannelOrderState::new(payment_info);
state.payment_received(PaymentMethod::Bolt11).unwrap();
+ // Verify payment state is HOLD before failure
+ assert_eq!(state.payment_details().bolt11.as_ref().unwrap().state, LSPS1PaymentState::Hold);
+
state.mark_failed_and_refunded().unwrap();
assert!(matches!(state, ChannelOrderState::FailedAndRefunded { .. }));
assert_eq!(state.order_state(), LSPS1OrderState::Failed);
+ // Payment state should now be REFUNDED
assert_eq!(
state.payment_details().bolt11.as_ref().unwrap().state,
LSPS1PaymentState::Refunded
diff --git a/lightning-liquidity/tests/lsps1_integration_tests.rs b/lightning-liquidity/tests/lsps1_integration_tests.rs
index 92ad06a..6318566 100644
--- a/lightning-liquidity/tests/lsps1_integration_tests.rs
+++ b/lightning-liquidity/tests/lsps1_integration_tests.rs
@@ -725,8 +725,8 @@ fn lsps1_order_state_transitions() {
if let LiquidityEvent::LSPS1Client(LSPS1ClientEvent::OrderStatus { payment, channel, .. }) =
order_status_event
{
- // Payment state should be Paid
- assert_eq!(payment.onchain.as_ref().unwrap().state, LSPS1PaymentState::Paid);
+ // Payment state should be Hold (payment received but channel not yet opened)
+ assert_eq!(payment.onchain.as_ref().unwrap().state, LSPS1PaymentState::Hold);
// No channel info yet (order state is still Created internally)
assert!(channel.is_none());
} else {
@@ -754,9 +754,11 @@ fn lsps1_order_state_transitions() {
client_node.liquidity_manager.handle_custom_message(order_response, service_node_id).unwrap();
let order_status_event = client_node.liquidity_manager.next_event().unwrap();
- if let LiquidityEvent::LSPS1Client(LSPS1ClientEvent::OrderStatus { channel, .. }) =
+ if let LiquidityEvent::LSPS1Client(LSPS1ClientEvent::OrderStatus { payment, channel, .. }) =
order_status_event
{
+ // Payment state should now be Paid (channel has been opened)
+ assert_eq!(payment.onchain.as_ref().unwrap().state, LSPS1PaymentState::Paid);
// Channel info should be present (indicates Completed state)
assert_eq!(channel, Some(channel_info));
} else {
Why this scored 19/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.