Skip to content

Commit 36e1198

Browse files
committed
Add Hold payment state per bLIP-51 spec
The bLIP-51 specification defines a `HOLD` intermediate payment state: - `EXPECT_PAYMENT` -> `HOLD` -> `PAID` (success path) - `EXPECT_PAYMENT` -> `REFUNDED` (failure before payment) - `HOLD` -> `REFUNDED` (failure after payment received) This commit adds the `Hold` variant to `LSPS1PaymentState` and updates the state machine transitions: - `payment_received()` now sets payment state to `Hold` (not `Paid`) - `channel_opened()` transitions payment state from `Hold` to `Paid` - Tests updated to verify the correct state at each transition This allows LSPs to properly communicate when a payment has been received but the channel has not yet been opened (e.g., Lightning HTLC held, or on-chain tx detected but channel funding not published). Co-Authored-By: HAL 9000
1 parent 029ad80 commit 36e1198

3 files changed

Lines changed: 54 additions & 15 deletions

File tree

lightning-liquidity/src/lsps1/msgs.rs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,12 @@ impl_writeable_tlv_based!(LSPS1OnchainPaymentInfo, {
310310
pub enum LSPS1PaymentState {
311311
/// A payment is expected.
312312
ExpectPayment,
313-
/// A sufficient payment has been received.
313+
/// A payment has been received but the channel has not yet been opened.
314+
///
315+
/// This indicates the LSP has received the payment (e.g., Lightning HTLC held,
316+
/// or on-chain transaction detected) but has not yet published the funding transaction.
317+
Hold,
318+
/// A sufficient payment has been received and the channel has been opened.
314319
Paid,
315320
/// The payment has been refunded.
316321
#[serde(alias = "CANCELLED")]
@@ -319,8 +324,9 @@ pub enum LSPS1PaymentState {
319324

320325
impl_writeable_tlv_based_enum!(LSPS1PaymentState,
321326
(0, ExpectPayment) => {},
322-
(2, Paid) => {},
323-
(4, Refunded) => {}
327+
(2, Hold) => {},
328+
(4, Paid) => {},
329+
(6, Refunded) => {}
324330
);
325331

326332
/// Details regarding the state of an ordered channel.

lightning-liquidity/src/lsps1/peer_state.rs

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -102,33 +102,33 @@ impl ChannelOrderState {
102102

103103
/// Transition: ExpectingPayment -> OrderPaid
104104
///
105-
/// Updates the specified payment method's state to PAID.
105+
/// Updates the specified payment method's state to HOLD.
106106
pub(super) fn payment_received(
107107
&mut self, method: PaymentMethod,
108108
) -> Result<(), ChannelOrderStateError> {
109109
match self {
110110
ChannelOrderState::ExpectingPayment { payment_details } => {
111-
// Update the payment state for the specified method
111+
// Update the payment state for the specified method to HOLD
112112
let method_exists = match method {
113113
PaymentMethod::Bolt11 => {
114114
if let Some(ref mut bolt11) = payment_details.bolt11 {
115-
bolt11.state = LSPS1PaymentState::Paid;
115+
bolt11.state = LSPS1PaymentState::Hold;
116116
true
117117
} else {
118118
false
119119
}
120120
},
121121
PaymentMethod::Bolt12 => {
122122
if let Some(ref mut bolt12) = payment_details.bolt12 {
123-
bolt12.state = LSPS1PaymentState::Paid;
123+
bolt12.state = LSPS1PaymentState::Hold;
124124
true
125125
} else {
126126
false
127127
}
128128
},
129129
PaymentMethod::Onchain => {
130130
if let Some(ref mut onchain) = payment_details.onchain {
131-
onchain.state = LSPS1PaymentState::Paid;
131+
onchain.state = LSPS1PaymentState::Hold;
132132
true
133133
} else {
134134
false
@@ -152,13 +152,33 @@ impl ChannelOrderState {
152152
}
153153

154154
/// Transition: OrderPaid -> CompletedAndChannelOpened
155+
///
156+
/// Updates payment states from HOLD to PAID.
155157
pub(super) fn channel_opened(
156158
&mut self, channel_info: LSPS1ChannelInfo,
157159
) -> Result<(), ChannelOrderStateError> {
158160
match self {
159161
ChannelOrderState::OrderPaid { payment_details } => {
162+
// Update payment states from HOLD to PAID
163+
let mut paid_details = payment_details.clone();
164+
if let Some(ref mut bolt11) = paid_details.bolt11 {
165+
if bolt11.state == LSPS1PaymentState::Hold {
166+
bolt11.state = LSPS1PaymentState::Paid;
167+
}
168+
}
169+
if let Some(ref mut bolt12) = paid_details.bolt12 {
170+
if bolt12.state == LSPS1PaymentState::Hold {
171+
bolt12.state = LSPS1PaymentState::Paid;
172+
}
173+
}
174+
if let Some(ref mut onchain) = paid_details.onchain {
175+
if onchain.state == LSPS1PaymentState::Hold {
176+
onchain.state = LSPS1PaymentState::Paid;
177+
}
178+
}
179+
160180
*self = ChannelOrderState::CompletedAndChannelOpened {
161-
payment_details: payment_details.clone(),
181+
payment_details: paid_details,
162182
channel_info,
163183
};
164184
Ok(())
@@ -276,7 +296,7 @@ impl PeerState {
276296

277297
/// Transition: ExpectingPayment -> OrderPaid
278298
///
279-
/// Updates the specified payment method's state to PAID.
299+
/// Updates the specified payment method's state to HOLD.
280300
pub(super) fn order_payment_received(
281301
&mut self, order_id: &LSPS1OrderId, method: PaymentMethod,
282302
) -> Result<(), PeerStateError> {
@@ -530,7 +550,8 @@ mod tests {
530550

531551
assert!(matches!(state, ChannelOrderState::OrderPaid { .. }));
532552
assert_eq!(state.order_state(), LSPS1OrderState::Created);
533-
assert_eq!(state.payment_details().bolt11.as_ref().unwrap().state, LSPS1PaymentState::Paid);
553+
// Payment state should be HOLD (not PAID) until channel is opened
554+
assert_eq!(state.payment_details().bolt11.as_ref().unwrap().state, LSPS1PaymentState::Hold);
534555
}
535556

536557
// Test valid transition: ExpectingPayment -> OrderPaid via payment_received (Onchain)
@@ -542,9 +563,10 @@ mod tests {
542563
state.payment_received(PaymentMethod::Onchain).unwrap();
543564

544565
assert!(matches!(state, ChannelOrderState::OrderPaid { .. }));
566+
// Payment state should be HOLD (not PAID) until channel is opened
545567
assert_eq!(
546568
state.payment_details().onchain.as_ref().unwrap().state,
547-
LSPS1PaymentState::Paid
569+
LSPS1PaymentState::Hold
548570
);
549571
}
550572

@@ -555,12 +577,17 @@ mod tests {
555577
let mut state = ChannelOrderState::new(payment_info);
556578
state.payment_received(PaymentMethod::Bolt11).unwrap();
557579

580+
// Verify payment state is HOLD before channel opens
581+
assert_eq!(state.payment_details().bolt11.as_ref().unwrap().state, LSPS1PaymentState::Hold);
582+
558583
let channel_info = create_test_channel_info();
559584
state.channel_opened(channel_info.clone()).unwrap();
560585

561586
assert!(matches!(state, ChannelOrderState::CompletedAndChannelOpened { .. }));
562587
assert_eq!(state.order_state(), LSPS1OrderState::Completed);
563588
assert_eq!(state.channel_info(), Some(&channel_info));
589+
// Payment state should now be PAID after channel is opened
590+
assert_eq!(state.payment_details().bolt11.as_ref().unwrap().state, LSPS1PaymentState::Paid);
564591
}
565592

566593
// Test valid transition: ExpectingPayment -> FailedAndRefunded
@@ -586,10 +613,14 @@ mod tests {
586613
let mut state = ChannelOrderState::new(payment_info);
587614
state.payment_received(PaymentMethod::Bolt11).unwrap();
588615

616+
// Verify payment state is HOLD before failure
617+
assert_eq!(state.payment_details().bolt11.as_ref().unwrap().state, LSPS1PaymentState::Hold);
618+
589619
state.mark_failed_and_refunded().unwrap();
590620

591621
assert!(matches!(state, ChannelOrderState::FailedAndRefunded { .. }));
592622
assert_eq!(state.order_state(), LSPS1OrderState::Failed);
623+
// Payment state should now be REFUNDED
593624
assert_eq!(
594625
state.payment_details().bolt11.as_ref().unwrap().state,
595626
LSPS1PaymentState::Refunded

lightning-liquidity/tests/lsps1_integration_tests.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -725,8 +725,8 @@ fn lsps1_order_state_transitions() {
725725
if let LiquidityEvent::LSPS1Client(LSPS1ClientEvent::OrderStatus { payment, channel, .. }) =
726726
order_status_event
727727
{
728-
// Payment state should be Paid
729-
assert_eq!(payment.onchain.as_ref().unwrap().state, LSPS1PaymentState::Paid);
728+
// Payment state should be Hold (payment received but channel not yet opened)
729+
assert_eq!(payment.onchain.as_ref().unwrap().state, LSPS1PaymentState::Hold);
730730
// No channel info yet (order state is still Created internally)
731731
assert!(channel.is_none());
732732
} else {
@@ -754,9 +754,11 @@ fn lsps1_order_state_transitions() {
754754
client_node.liquidity_manager.handle_custom_message(order_response, service_node_id).unwrap();
755755

756756
let order_status_event = client_node.liquidity_manager.next_event().unwrap();
757-
if let LiquidityEvent::LSPS1Client(LSPS1ClientEvent::OrderStatus { channel, .. }) =
757+
if let LiquidityEvent::LSPS1Client(LSPS1ClientEvent::OrderStatus { payment, channel, .. }) =
758758
order_status_event
759759
{
760+
// Payment state should now be Paid (channel has been opened)
761+
assert_eq!(payment.onchain.as_ref().unwrap().state, LSPS1PaymentState::Paid);
760762
// Channel info should be present (indicates Completed state)
761763
assert_eq!(channel, Some(channel_info));
762764
} else {

0 commit comments

Comments
 (0)