Refactor `ChannelOrder` to use `ChannelOrderState` state machine
What changed, and why it matters
This commit is a code-quality refactor, not a security patch. It replaces a loose 'update_order_status' call with a stricter state machine for tracking LSPS1 channel orders. The change makes invalid transitions explicit errors and keeps payment/channel data tied to the correct states, which reduces the chance of accidental misuse but does not by itself fix a known exploitable vulnerability.
Treat as a normal code-quality refactor. Review downstream callers that previously used update_order_status to ensure they migrate to the new typed transition methods. No urgent security action is required based on the supplied materials.
Security signals we found
Refactor of state-management API with stricter transitions
Addition of InvalidStateTransition error type
Removal of generic update_order_status setter
No mention of vulnerability, CVE, or security fix in commit message
Evidence from the diff
The patch refactors ChannelOrder in rust-lightning’s LSPS1 liquidity module. It introduces a ChannelOrderState enum (ExpectingPayment, OrderPaid, CompletedAndChannelOpened, FailedAndRefunded) with typed transition methods (payment_received, channel_opened, mark_failed_and_refunded) and removes the generic update_order_status API. The refactor also changes serialization of ChannelOrder and adds unit tests for valid and invalid transitions. No vulnerability, CVE, or security disclosure is mentioned in the commit or supplied references.
Changed components
lightning-liquidity/src/lsps1/peer_state.rslightning-liquidity/src/lsps1/service.rsInspect captured patch +709 / −56
diff --git a/lightning-liquidity/src/lsps1/peer_state.rs b/lightning-liquidity/src/lsps1/peer_state.rs
index 1b51f64..1d13d07 100644
--- a/lightning-liquidity/src/lsps1/peer_state.rs
+++ b/lightning-liquidity/src/lsps1/peer_state.rs
@@ -11,17 +11,240 @@
use super::msgs::{
LSPS1ChannelInfo, LSPS1OrderId, LSPS1OrderParams, LSPS1OrderState, LSPS1PaymentInfo,
- LSPS1Request,
+ LSPS1PaymentState, LSPS1Request,
};
use crate::lsps0::ser::{LSPSDateTime, LSPSRequestId};
use crate::prelude::HashMap;
-use lightning::impl_writeable_tlv_based;
use lightning::util::hash_tables::new_hash_map;
+use lightning::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum};
use core::fmt;
+/// Indicates which payment method was used for the order.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum PaymentMethod {
+ /// A Lightning payment using BOLT 11.
+ Bolt11,
+ /// A Lightning payment using BOLT 12.
+ Bolt12,
+ /// An onchain payment.
+ Onchain,
+}
+
+/// Error type for invalid state transitions.
+#[derive(Debug, Clone)]
+pub(super) enum ChannelOrderStateError {
+ /// Attempted an invalid state transition.
+ InvalidStateTransition {
+ /// The state from which the transition was attempted.
+ from: LSPS1OrderState,
+ /// The action that was attempted.
+ action: &'static str,
+ },
+ /// The specified payment method was not configured for this order.
+ PaymentMethodNotConfigured,
+}
+
+impl fmt::Display for ChannelOrderStateError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::InvalidStateTransition { from, action } => {
+ write!(f, "invalid state transition: cannot {} from {:?}", action, from)
+ },
+ Self::PaymentMethodNotConfigured => {
+ write!(f, "payment method not configured for this order")
+ },
+ }
+ }
+}
+
+/// Internal state machine for tracking channel order progress.
+///
+/// This combines the wire `order_state` (CREATED/COMPLETED/FAILED) with internal
+/// payment tracking to provide type-safe state transitions.
+#[derive(Debug, Clone)]
+pub(super) enum ChannelOrderState {
+ /// Initial state - awaiting payment from client.
+ /// Payment states within payment_details should be EXPECT_PAYMENT.
+ ExpectingPayment {
+ /// Details about how to pay for the order.
+ payment_details: LSPS1PaymentInfo,
+ },
+ /// Payment received, awaiting channel open.
+ /// The paid method's state should be PAID.
+ OrderPaid {
+ /// Details about how to pay for the order (with paid method updated).
+ payment_details: LSPS1PaymentInfo,
+ },
+ /// Channel successfully funded and opened (terminal).
+ /// Payment states should be PAID.
+ CompletedAndChannelOpened {
+ /// Details about how to pay for the order.
+ payment_details: LSPS1PaymentInfo,
+ /// Information about the opened channel.
+ channel_info: LSPS1ChannelInfo,
+ },
+ /// Order failed, payment refunded (terminal).
+ /// Payment states should be REFUNDED.
+ FailedAndRefunded {
+ /// Details about how to pay for the order (with states set to REFUNDED).
+ payment_details: LSPS1PaymentInfo,
+ },
+}
+
+impl ChannelOrderState {
+ /// Creates a new state in the ExpectingPayment state.
+ pub(super) fn new(payment_details: LSPS1PaymentInfo) -> Self {
+ ChannelOrderState::ExpectingPayment { payment_details }
+ }
+
+ /// Transition: ExpectingPayment -> OrderPaid
+ ///
+ /// Updates the specified payment method's state to PAID.
+ 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
+ let method_exists = match method {
+ PaymentMethod::Bolt11 => {
+ if let Some(ref mut bolt11) = payment_details.bolt11 {
+ bolt11.state = LSPS1PaymentState::Paid;
+ true
+ } else {
+ false
+ }
+ },
+ PaymentMethod::Bolt12 => {
+ if let Some(ref mut bolt12) = payment_details.bolt12 {
+ bolt12.state = LSPS1PaymentState::Paid;
+ true
+ } else {
+ false
+ }
+ },
+ PaymentMethod::Onchain => {
+ if let Some(ref mut onchain) = payment_details.onchain {
+ onchain.state = LSPS1PaymentState::Paid;
+ true
+ } else {
+ false
+ }
+ },
+ };
+
+ if !method_exists {
+ return Err(ChannelOrderStateError::PaymentMethodNotConfigured);
+ }
+
+ // Move to OrderPaid state
+ *self = ChannelOrderState::OrderPaid { payment_details: payment_details.clone() };
+ Ok(())
+ },
+ _ => Err(ChannelOrderStateError::InvalidStateTransition {
+ from: self.order_state(),
+ action: "payment_received",
+ }),
+ }
+ }
+
+ /// Transition: OrderPaid -> CompletedAndChannelOpened
+ pub(super) fn channel_opened(
+ &mut self, channel_info: LSPS1ChannelInfo,
+ ) -> Result<(), ChannelOrderStateError> {
+ match self {
+ ChannelOrderState::OrderPaid { payment_details } => {
+ *self = ChannelOrderState::CompletedAndChannelOpened {
+ payment_details: payment_details.clone(),
+ channel_info,
+ };
+ Ok(())
+ },
+ _ => Err(ChannelOrderStateError::InvalidStateTransition {
+ from: self.order_state(),
+ action: "channel_opened",
+ }),
+ }
+ }
+
+ /// Transition: ExpectingPayment|OrderPaid -> FailedAndRefunded
+ ///
+ /// Updates all payment states to REFUNDED.
+ pub(super) fn mark_failed_and_refunded(&mut self) -> Result<(), ChannelOrderStateError> {
+ match self {
+ ChannelOrderState::ExpectingPayment { payment_details }
+ | ChannelOrderState::OrderPaid { payment_details } => {
+ // Mark all payment methods as refunded
+ let mut refunded_details = payment_details.clone();
+ if let Some(ref mut bolt11) = refunded_details.bolt11 {
+ bolt11.state = LSPS1PaymentState::Refunded;
+ }
+ if let Some(ref mut bolt12) = refunded_details.bolt12 {
+ bolt12.state = LSPS1PaymentState::Refunded;
+ }
+ if let Some(ref mut onchain) = refunded_details.onchain {
+ onchain.state = LSPS1PaymentState::Refunded;
+ }
+
+ *self = ChannelOrderState::FailedAndRefunded { payment_details: refunded_details };
+ Ok(())
+ },
+ _ => Err(ChannelOrderStateError::InvalidStateTransition {
+ from: self.order_state(),
+ action: "mark_failed_and_refunded",
+ }),
+ }
+ }
+
+ /// Get payment_details (available in all states).
+ pub(super) fn payment_details(&self) -> &LSPS1PaymentInfo {
+ match self {
+ ChannelOrderState::ExpectingPayment { payment_details }
+ | ChannelOrderState::OrderPaid { payment_details }
+ | ChannelOrderState::CompletedAndChannelOpened { payment_details, .. }
+ | ChannelOrderState::FailedAndRefunded { payment_details } => payment_details,
+ }
+ }
+
+ /// Get channel_info if in CompletedAndChannelOpened state.
+ pub(super) fn channel_info(&self) -> Option<&LSPS1ChannelInfo> {
+ match self {
+ ChannelOrderState::CompletedAndChannelOpened { channel_info, .. } => Some(channel_info),
+ _ => None,
+ }
+ }
+
+ /// Convert to wire format LSPS1OrderState.
+ pub(super) fn order_state(&self) -> LSPS1OrderState {
+ match self {
+ ChannelOrderState::ExpectingPayment { .. } | ChannelOrderState::OrderPaid { .. } => {
+ LSPS1OrderState::Created
+ },
+ ChannelOrderState::CompletedAndChannelOpened { .. } => LSPS1OrderState::Completed,
+ ChannelOrderState::FailedAndRefunded { .. } => LSPS1OrderState::Failed,
+ }
+ }
+}
+
+impl_writeable_tlv_based_enum!(ChannelOrderState,
+ (0, ExpectingPayment) => {
+ (0, payment_details, required),
+ },
+ (2, OrderPaid) => {
+ (0, payment_details, required),
+ },
+ (4, CompletedAndChannelOpened) => {
+ (0, payment_details, required),
+ (2, channel_info, required),
+ },
+ (6, FailedAndRefunded) => {
+ (0, payment_details, required),
+ }
+);
+
#[derive(Default)]
pub(crate) struct PeerState {
outbound_channels_by_order_id: HashMap<LSPS1OrderId, ChannelOrder>,
@@ -34,15 +257,8 @@ impl PeerState {
&mut self, order_id: LSPS1OrderId, order_params: LSPS1OrderParams,
created_at: LSPSDateTime, payment_details: LSPS1PaymentInfo,
) -> ChannelOrder {
- let order_state = LSPS1OrderState::Created;
- let channel_details = None;
- let channel_order = ChannelOrder {
- order_params,
- order_state,
- created_at,
- payment_details,
- channel_details,
- };
+ let state = ChannelOrderState::new(payment_details);
+ let channel_order = ChannelOrder { order_params, state, created_at };
self.outbound_channels_by_order_id.insert(order_id, channel_order.clone());
self.needs_persist |= true;
channel_order
@@ -58,16 +274,45 @@ impl PeerState {
Ok(order)
}
- pub(super) fn update_order<'a>(
- &'a mut self, order_id: &LSPS1OrderId, order_state: LSPS1OrderState,
- channel_details: Option<LSPS1ChannelInfo>,
+ /// Transition: ExpectingPayment -> OrderPaid
+ ///
+ /// Updates the specified payment method's state to PAID.
+ pub(super) fn order_payment_received(
+ &mut self, order_id: &LSPS1OrderId, method: PaymentMethod,
+ ) -> Result<(), PeerStateError> {
+ let order = self
+ .outbound_channels_by_order_id
+ .get_mut(order_id)
+ .ok_or(PeerStateError::UnknownOrderId)?;
+ order.state.payment_received(method).map_err(PeerStateError::InvalidStateTransition)?;
+ self.needs_persist |= true;
+ Ok(())
+ }
+
+ /// Transition: OrderPaid -> CompletedAndChannelOpened
+ pub(super) fn order_channel_opened(
+ &mut self, order_id: &LSPS1OrderId, channel_info: LSPS1ChannelInfo,
) -> Result<(), PeerStateError> {
let order = self
.outbound_channels_by_order_id
.get_mut(order_id)
.ok_or(PeerStateError::UnknownOrderId)?;
- order.order_state = order_state;
- order.channel_details = channel_details;
+ order.state.channel_opened(channel_info).map_err(PeerStateError::InvalidStateTransition)?;
+ self.needs_persist |= true;
+ Ok(())
+ }
+
+ /// Transition: ExpectingPayment|OrderPaid -> FailedAndRefunded
+ ///
+ /// Updates all payment states to REFUNDED.
+ pub(super) fn order_failed_and_refunded(
+ &mut self, order_id: &LSPS1OrderId,
+ ) -> Result<(), PeerStateError> {
+ let order = self
+ .outbound_channels_by_order_id
+ .get_mut(order_id)
+ .ok_or(PeerStateError::UnknownOrderId)?;
+ order.state.mark_failed_and_refunded().map_err(PeerStateError::InvalidStateTransition)?;
self.needs_persist |= true;
Ok(())
}
@@ -132,11 +377,12 @@ impl_writeable_tlv_based!(PeerState, {
(_unused, needs_persist, (static_value, false)),
});
-#[derive(Debug, Copy, Clone)]
+#[derive(Debug, Clone)]
pub(super) enum PeerStateError {
UnknownRequestId,
DuplicateRequestId,
UnknownOrderId,
+ InvalidStateTransition(ChannelOrderStateError),
}
impl fmt::Display for PeerStateError {
@@ -145,6 +391,7 @@ impl fmt::Display for PeerStateError {
Self::UnknownRequestId => write!(f, "unknown request id"),
Self::DuplicateRequestId => write!(f, "duplicate request id"),
Self::UnknownOrderId => write!(f, "unknown order id"),
+ Self::InvalidStateTransition(e) => write!(f, "{}", e),
}
}
}
@@ -152,18 +399,31 @@ impl fmt::Display for PeerStateError {
#[derive(Debug, Clone)]
pub(super) struct ChannelOrder {
pub(super) order_params: LSPS1OrderParams,
- pub(super) order_state: LSPS1OrderState,
+ pub(super) state: ChannelOrderState,
pub(super) created_at: LSPSDateTime,
- pub(super) payment_details: LSPS1PaymentInfo,
- pub(super) channel_details: Option<LSPS1ChannelInfo>,
}
impl ChannelOrder {
+ /// Returns the order state.
+ pub(super) fn order_state(&self) -> LSPS1OrderState {
+ self.state.order_state()
+ }
+
+ /// Returns the payment details.
+ pub(super) fn payment_details(&self) -> &LSPS1PaymentInfo {
+ self.state.payment_details()
+ }
+
+ /// Returns the channel details if the channel has been opened.
+ pub(super) fn channel_details(&self) -> Option<&LSPS1ChannelInfo> {
+ self.state.channel_info()
+ }
+
fn is_prunable(&self) -> bool {
let all_payment_details_expired;
#[cfg(feature = "time")]
{
- let details = &self.payment_details;
+ let details = self.state.payment_details();
all_payment_details_expired =
details.bolt11.as_ref().map_or(true, |d| d.expires_at.is_past())
&& details.bolt12.as_ref().map_or(true, |d| d.expires_at.is_past())
@@ -175,8 +435,11 @@ impl ChannelOrder {
all_payment_details_expired = false;
}
- let created_or_failed =
- matches!(self.order_state, LSPS1OrderState::Created | LSPS1OrderState::Failed);
+ let created_or_failed = matches!(
+ self.state,
+ ChannelOrderState::ExpectingPayment { .. }
+ | ChannelOrderState::FailedAndRefunded { .. }
+ );
all_payment_details_expired && created_or_failed
}
@@ -184,8 +447,278 @@ impl ChannelOrder {
impl_writeable_tlv_based!(ChannelOrder, {
(0, order_params, required),
- (2, order_state, required),
+ (2, state, required),
(4, created_at, required),
- (6, payment_details, required),
- (8, channel_details, option),
});
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::lsps0::ser::LSPSDateTime;
+ use crate::lsps1::msgs::{LSPS1Bolt11PaymentInfo, LSPS1OnchainPaymentInfo, LSPS1PaymentState};
+
+ use bitcoin::{Address, FeeRate, OutPoint};
+ use lightning_invoice::Bolt11Invoice;
+
+ use core::str::FromStr;
+
+ fn create_test_bolt11_payment_info() -> LSPS1Bolt11PaymentInfo {
+ let invoice_str = "lnbc252u1p3aht9ysp580g4633gd2x9lc5al0wd8wx0mpn9748jeyz46kqjrpxn52uhfpjqpp5qgf67tcqmuqehzgjm8mzya90h73deafvr4m5705l5u5l4r05l8cqdpud3h8ymm4w3jhytnpwpczqmt0de6xsmre2pkxzm3qydmkzdjrdev9s7zhgfaqxqyjw5qcqpjrzjqt6xptnd85lpqnu2lefq4cx070v5cdwzh2xlvmdgnu7gqp4zvkus5zapryqqx9qqqyqqqqqqqqqqqcsq9q9qyysgqen77vu8xqjelum24hgjpgfdgfgx4q0nehhalcmuggt32japhjuksq9jv6eksjfnppm4hrzsgyxt8y8xacxut9qv3fpyetz8t7tsymygq8yzn05";
+ LSPS1Bolt11PaymentInfo {
+ state: LSPS1PaymentState::ExpectPayment,
+ expires_at: LSPSDateTime::from_str("2035-01-01T00:00:00Z").unwrap(),
+ fee_total_sat: 9999,
+ order_total_sat: 200999,
+ invoice: Bolt11Invoice::from_str(invoice_str).unwrap(),
+ }
+ }
+
+ fn create_test_onchain_payment_info() -> LSPS1OnchainPaymentInfo {
+ LSPS1OnchainPaymentInfo {
+ state: LSPS1PaymentState::ExpectPayment,
+ expires_at: LSPSDateTime::from_str("2035-01-01T00:00:00Z").unwrap(),
+ fee_total_sat: 9999,
+ order_total_sat: 200999,
+ address: Address::from_str(
+ "bc1p5uvtaxzkjwvey2tfy49k5vtqfpjmrgm09cvs88ezyy8h2zv7jhas9tu4yr",
+ )
+ .unwrap()
+ .assume_checked(),
+ min_onchain_payment_confirmations: Some(1),
+ min_fee_for_0conf: FeeRate::from_sat_per_vb(253).unwrap(),
+ refund_onchain_address: None,
+ }
+ }
+
+ fn create_test_payment_info_bolt11_only() -> LSPS1PaymentInfo {
+ LSPS1PaymentInfo {
+ bolt11: Some(create_test_bolt11_payment_info()),
+ bolt12: None,
+ onchain: None,
+ }
+ }
+
+ fn create_test_payment_info_onchain_only() -> LSPS1PaymentInfo {
+ LSPS1PaymentInfo {
+ bolt11: None,
+ bolt12: None,
+ onchain: Some(create_test_onchain_payment_info()),
+ }
+ }
+
+ fn create_test_channel_info() -> LSPS1ChannelInfo {
+ LSPS1ChannelInfo {
+ funded_at: LSPSDateTime::from_str("2035-01-01T00:00:00Z").unwrap(),
+ funding_outpoint: OutPoint::from_str(
+ "0301e0480b374b32851a9462db29dc19fe830a7f7d7a88b81612b9d42099c0ae:0",
+ )
+ .unwrap(),
+ expires_at: LSPSDateTime::from_str("2036-01-01T00:00:00Z").unwrap(),
+ }
+ }
+
+ // Test valid transition: ExpectingPayment -> OrderPaid via payment_received (Bolt11)
+ #[test]
+ fn test_payment_received_bolt11() {
+ let payment_info = create_test_payment_info_bolt11_only();
+ let mut state = ChannelOrderState::new(payment_info);
+
+ assert!(matches!(state, ChannelOrderState::ExpectingPayment { .. }));
+ assert_eq!(state.order_state(), LSPS1OrderState::Created);
+
+ state.payment_received(PaymentMethod::Bolt11).unwrap();
+
+ assert!(matches!(state, ChannelOrderState::OrderPaid { .. }));
+ assert_eq!(state.order_state(), LSPS1OrderState::Created);
+ assert_eq!(state.payment_details().bolt11.as_ref().unwrap().state, LSPS1PaymentState::Paid);
+ }
+
+ // Test valid transition: ExpectingPayment -> OrderPaid via payment_received (Onchain)
+ #[test]
+ fn test_payment_received_onchain() {
+ let payment_info = create_test_payment_info_onchain_only();
+ let mut state = ChannelOrderState::new(payment_info);
+
+ state.payment_received(PaymentMethod::Onchain).unwrap();
+
+ assert!(matches!(state, ChannelOrderState::OrderPaid { .. }));
+ assert_eq!(
+ state.payment_details().onchain.as_ref().unwrap().state,
+ LSPS1PaymentState::Paid
+ );
+ }
+
+ // Test valid transition: OrderPaid -> CompletedAndChannelOpened via channel_opened
+ #[test]
+ fn test_channel_opened() {
+ let payment_info = create_test_payment_info_bolt11_only();
+ let mut state = ChannelOrderState::new(payment_info);
+ state.payment_received(PaymentMethod::Bolt11).unwrap();
+
+ 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));
+ }
+
+ // Test valid transition: ExpectingPayment -> FailedAndRefunded
+ #[test]
+ fn test_mark_failed_from_expecting_payment() {
+ let payment_info = create_test_payment_info_bolt11_only();
+ let mut state = ChannelOrderState::new(payment_info);
+
+ state.mark_failed_and_refunded().unwrap();
+
+ assert!(matches!(state, ChannelOrderState::FailedAndRefunded { .. }));
+ assert_eq!(state.order_state(), LSPS1OrderState::Failed);
+ assert_eq!(
+ state.payment_details().bolt11.as_ref().unwrap().state,
+ LSPS1PaymentState::Refunded
+ );
+ }
+
+ // Test valid transition: OrderPaid -> FailedAndRefunded
+ #[test]
+ fn test_mark_failed_from_order_paid() {
+ let payment_info = create_test_payment_info_bolt11_only();
+ let mut state = ChannelOrderState::new(payment_info);
+ state.payment_received(PaymentMethod::Bolt11).unwrap();
+
+ state.mark_failed_and_refunded().unwrap();
+
+ assert!(matches!(state, ChannelOrderState::FailedAndRefunded { .. }));
+ assert_eq!(state.order_state(), LSPS1OrderState::Failed);
+ assert_eq!(
+ state.payment_details().bolt11.as_ref().unwrap().state,
+ LSPS1PaymentState::Refunded
+ );
+ }
+
+ // Test invalid transition: payment_received from OrderPaid
+ #[test]
+ fn test_payment_received_from_order_paid_fails() {
+ let payment_info = create_test_payment_info_bolt11_only();
+ let mut state = ChannelOrderState::new(payment_info);
+ state.payment_received(PaymentMethod::Bolt11).unwrap();
+
+ let result = state.payment_received(PaymentMethod::Bolt11);
+ assert!(matches!(result, Err(ChannelOrderStateError::InvalidStateTransition { .. })));
+ }
+
+ // Test invalid transition: payment_received from CompletedAndChannelOpened
+ #[test]
+ fn test_payment_received_from_completed_fails() {
+ let payment_info = create_test_payment_info_bolt11_only();
+ let mut state = ChannelOrderState::new(payment_info);
+ state.payment_received(PaymentMethod::Bolt11).unwrap();
+ state.channel_opened(create_test_channel_info()).unwrap();
+
+ let result = state.payment_received(PaymentMethod::Bolt11);
+ assert!(matches!(result, Err(ChannelOrderStateError::InvalidStateTransition { .. })));
+ }
+
+ // Test invalid transition: payment_received from FailedAndRefunded
+ #[test]
+ fn test_payment_received_from_failed_fails() {
+ let payment_info = create_test_payment_info_bolt11_only();
+ let mut state = ChannelOrderState::new(payment_info);
+ state.mark_failed_and_refunded().unwrap();
+
+ let result = state.payment_received(PaymentMethod::Bolt11);
+ assert!(matches!(result, Err(ChannelOrderStateError::InvalidStateTransition { .. })));
+ }
+
+ // Test invalid transition: channel_opened from ExpectingPayment
+ #[test]
+ fn test_channel_opened_from_expecting_payment_fails() {
+ let payment_info = create_test_payment_info_bolt11_only();
+ let mut state = ChannelOrderState::new(payment_info);
+
+ let result = state.channel_opened(create_test_channel_info());
+ assert!(matches!(result, Err(ChannelOrderStateError::InvalidStateTransition { .. })));
+ }
+
+ // Test invalid transition: channel_opened from CompletedAndChannelOpened
+ #[test]
+ fn test_channel_opened_from_completed_fails() {
+ let payment_info = create_test_payment_info_bolt11_only();
+ let mut state = ChannelOrderState::new(payment_info);
+ state.payment_received(PaymentMethod::Bolt11).unwrap();
+ state.channel_opened(create_test_channel_info()).unwrap();
+
+ let result = state.channel_opened(create_test_channel_info());
+ assert!(matches!(result, Err(ChannelOrderStateError::InvalidStateTransition { .. })));
+ }
+
+ // Test invalid transition: channel_opened from FailedAndRefunded
+ #[test]
+ fn test_channel_opened_from_failed_fails() {
+ let payment_info = create_test_payment_info_bolt11_only();
+ let mut state = ChannelOrderState::new(payment_info);
+ state.mark_failed_and_refunded().unwrap();
+
+ let result = state.channel_opened(create_test_channel_info());
+ assert!(matches!(result, Err(ChannelOrderStateError::InvalidStateTransition { .. })));
+ }
+
+ // Test invalid transition: mark_failed_and_refunded from CompletedAndChannelOpened
+ #[test]
+ fn test_mark_failed_from_completed_fails() {
+ let payment_info = create_test_payment_info_bolt11_only();
+ let mut state = ChannelOrderState::new(payment_info);
+ state.payment_received(PaymentMethod::Bolt11).unwrap();
+ state.channel_opened(create_test_channel_info()).unwrap();
+
+ let result = state.mark_failed_and_refunded();
+ assert!(matches!(result, Err(ChannelOrderStateError::InvalidStateTransition { .. })));
+ }
+
+ // Test invalid transition: mark_failed_and_refunded from FailedAndRefunded
+ #[test]
+ fn test_mark_failed_from_failed_fails() {
+ let payment_info = create_test_payment_info_bolt11_only();
+ let mut state = ChannelOrderState::new(payment_info);
+ state.mark_failed_and_refunded().unwrap();
+
+ let result = state.mark_failed_and_refunded();
+ assert!(matches!(result, Err(ChannelOrderStateError::InvalidStateTransition { .. })));
+ }
+
+ // Test error: payment_received with unconfigured payment method
+ #[test]
+ fn test_payment_received_unconfigured_method_fails() {
+ // Create payment info with only onchain configured
+ let payment_info = create_test_payment_info_onchain_only();
+ let mut state = ChannelOrderState::new(payment_info);
+
+ // Try to mark bolt11 as paid, which is not configured
+ let result = state.payment_received(PaymentMethod::Bolt11);
+ assert!(matches!(result, Err(ChannelOrderStateError::PaymentMethodNotConfigured)));
+
+ // State should remain unchanged
+ assert!(matches!(state, ChannelOrderState::ExpectingPayment { .. }));
+ }
+
+ // Test that channel_info is only available in CompletedAndChannelOpened state
+ #[test]
+ fn test_channel_info_availability() {
+ let payment_info = create_test_payment_info_bolt11_only();
+ let mut state = ChannelOrderState::new(payment_info);
+
+ // Not available in ExpectingPayment
+ assert!(state.channel_info().is_none());
+
+ state.payment_received(PaymentMethod::Bolt11).unwrap();
+
+ // Not available in OrderPaid
+ assert!(state.channel_info().is_none());
+
+ let channel_info = create_test_channel_info();
+ state.channel_opened(channel_info.clone()).unwrap();
+
+ // Available in CompletedAndChannelOpened
+ assert_eq!(state.channel_info(), Some(&channel_info));
+ }
+}
diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs
index c4a678d..bc10116 100644
--- a/lightning-liquidity/src/lsps1/service.rs
+++ b/lightning-liquidity/src/lsps1/service.rs
@@ -22,11 +22,12 @@ use super::event::LSPS1ServiceEvent;
use super::msgs::{
LSPS1ChannelInfo, LSPS1CreateOrderRequest, LSPS1CreateOrderResponse, LSPS1GetInfoResponse,
LSPS1GetOrderRequest, LSPS1Message, LSPS1Options, LSPS1OrderId, LSPS1OrderParams,
- LSPS1OrderState, LSPS1PaymentInfo, LSPS1PaymentState, LSPS1Request, LSPS1Response,
+ LSPS1PaymentInfo, LSPS1PaymentState, LSPS1Request, LSPS1Response,
LSPS1_CREATE_ORDER_REQUEST_ORDER_MISMATCH_ERROR_CODE,
LSPS1_CREATE_ORDER_REQUEST_UNRECOGNIZED_OR_STALE_TOKEN_ERROR_CODE,
LSPS1_GET_ORDER_REQUEST_ORDER_NOT_FOUND_ERROR_CODE,
};
+pub use super::peer_state::PaymentMethod;
use super::peer_state::PeerState;
use crate::message_queue::MessageQueue;
@@ -415,13 +416,12 @@ where
should_persist |= peer_state_lock.needs_persist();
let response = LSPS1Response::CreateOrder(LSPS1CreateOrderResponse {
- order: order.order_params,
order_id,
-
- order_state: order.order_state,
- created_at: order.created_at,
- payment: order.payment_details,
- channel: order.channel_details,
+ order_state: order.order_state(),
+ created_at: order.created_at.clone(),
+ payment: order.payment_details().clone(),
+ channel: order.channel_details().cloned(),
+ order: order.order_params,
});
let msg = LSPS1Message::Response(request_id, response).into();
message_queue_notifier.enqueue(&counterparty_node_id, msg);
@@ -523,10 +523,10 @@ where
let response = LSPS1Response::GetOrder(LSPS1CreateOrderResponse {
order_id: params.order_id,
order: order.order_params.clone(),
- order_state: order.order_state.clone(),
+ order_state: order.order_state(),
created_at: order.created_at.clone(),
- payment: order.payment_details.clone(),
- channel: order.channel_details.clone(),
+ payment: order.payment_details().clone(),
+ channel: order.channel_details().cloned(),
});
let msg = LSPS1Message::Response(request_id, response).into();
message_queue_notifier.enqueue(&counterparty_node_id, msg);
@@ -551,23 +551,108 @@ where
}
}
- /// Used by LSP to give details to client regarding the status of channel opening.
+ /// Marks an order as paid after payment has been received.
+ ///
+ /// This should be called when the LSP detects that a Lightning payment has arrived or an
+ /// on-chain payment has been confirmed.
+ ///
+ /// This should be called before opening the channel and the channel should not be opened if
+ /// this returns an error.
+ ///
+ /// Note that in the case of a lightning payment, we expect the payment to have been received
+ /// (i.e. LDK's [`Event::PaymentClaimable`]) but not claimed (i.e. calling LDK's
+ /// [`ChannelManager::claim_funds`]), allowing the payment to be returned to the sender if
+ /// channel opening fails.
+ ///
+ /// [`Event::PaymentClaimable`]: lightning::events::Event::PaymentClaimable
+ /// [`ChannelManager::claim_funds`]: lightning::ln::channelmanager::ChannelManager::claim_funds
+ pub async fn order_payment_received(
+ &self, counterparty_node_id: PublicKey, order_id: LSPS1OrderId, method: PaymentMethod,
+ ) -> Result<(), APIError> {
+ let mut should_persist = false;
+ 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.order_payment_received(&order_id, method).map_err(|e| {
+ APIError::APIMisuseError { err: format!("Failed to update order: {}", e) }
+ })?;
+ should_persist |= peer_state_lock.needs_persist();
+ },
+ None => {
+ return Err(APIError::APIMisuseError {
+ err: format!("No existing state with counterparty {}", counterparty_node_id),
+ });
+ },
+ }
+
+ if should_persist {
+ self.persist_peer_state(counterparty_node_id).await.map_err(|e| {
+ APIError::APIMisuseError {
+ err: format!(
+ "Failed to persist peer state for {}: {}",
+ counterparty_node_id, e
+ ),
+ }
+ })?;
+ }
+
+ Ok(())
+ }
+
+ /// Marks an order as completed after the channel has been opened.
///
- /// The LSP continously polls for checking payment confirmation on-chain or Lightning
- /// and then responds to client request.
- pub async fn update_order_status(
+ /// This should be called when the LSP has successfully published the funding
+ /// transaction for the channel.
+ pub async fn order_channel_opened(
&self, counterparty_node_id: PublicKey, order_id: LSPS1OrderId,
- order_state: LSPS1OrderState, channel_details: Option<LSPS1ChannelInfo>,
+ channel_info: LSPS1ChannelInfo,
) -> Result<(), APIError> {
let mut should_persist = false;
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.update_order(&order_id, order_state, channel_details).map_err(
- |e| APIError::APIMisuseError {
- err: format!("Failed to update order: {:?}", e),
- },
- )?;
+ peer_state_lock.order_channel_opened(&order_id, channel_info).map_err(|e| {
+ APIError::APIMisuseError { err: format!("Failed to update order: {}", e) }
+ })?;
+ should_persist |= peer_state_lock.needs_persist();
+ },
+ None => {
+ return Err(APIError::APIMisuseError {
+ err: format!("No existing state with counterparty {}", counterparty_node_id),
+ });
+ },
+ }
+
+ if should_persist {
+ self.persist_peer_state(counterparty_node_id).await.map_err(|e| {
+ APIError::APIMisuseError {
+ err: format!(
+ "Failed to persist peer state for {}: {}",
+ counterparty_node_id, e
+ ),
+ }
+ })?;
+ }
+
+ Ok(())
+ }
+
+ /// 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(
+ &self, counterparty_node_id: PublicKey, order_id: LSPS1OrderId,
+ ) -> Result<(), APIError> {
+ let mut should_persist = false;
+ 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.order_failed_and_refunded(&order_id).map_err(|e| {
+ APIError::APIMisuseError { err: format!("Failed to update order: {}", e) }
+ })?;
should_persist |= peer_state_lock.needs_persist();
},
None => {
@@ -697,19 +782,54 @@ where
self.inner.invalid_token_provided(counterparty_node_id, request_id)
}
- /// Used by LSP to give details to client regarding the status of channel opening.
+ /// Marks an order as paid after payment has been received.
+ ///
+ /// Wraps [`LSPS1ServiceHandler::order_payment_received`].
+ pub fn order_payment_received(
+ &self, counterparty_node_id: PublicKey, order_id: LSPS1OrderId, method: PaymentMethod,
+ ) -> Result<(), APIError> {
+ let mut fut =
+ pin!(self.inner.order_payment_received(counterparty_node_id, order_id, method));
+
+ let mut waker = dummy_waker();
+ let mut ctx = task::Context::from_waker(&mut waker);
+ match fut.as_mut().poll(&mut ctx) {
+ task::Poll::Ready(result) => result,
+ task::Poll::Pending => {
+ // In a sync context, we can't wait for the future to complete.
+ unreachable!("Should not be pending in a sync context");
+ },
+ }
+ }
+
+ /// Marks an order as completed after the channel has been opened.
///
- /// Wraps [`LSPS1ServiceHandler::update_order_status`].
- pub fn update_order_status(
+ /// Wraps [`LSPS1ServiceHandler::order_channel_opened`].
+ pub fn order_channel_opened(
&self, counterparty_node_id: PublicKey, order_id: LSPS1OrderId,
- order_state: LSPS1OrderState, channel_details: Option<LSPS1ChannelInfo>,
+ channel_info: LSPS1ChannelInfo,
) -> Result<(), APIError> {
- let mut fut = pin!(self.inner.update_order_status(
- counterparty_node_id,
- order_id,
- order_state,
- channel_details
- ));
+ let mut fut =
+ pin!(self.inner.order_channel_opened(counterparty_node_id, order_id, channel_info));
+
+ let mut waker = dummy_waker();
+ let mut ctx = task::Context::from_waker(&mut waker);
+ match fut.as_mut().poll(&mut ctx) {
+ task::Poll::Ready(result) => result,
+ task::Poll::Pending => {
+ // In a sync context, we can't wait for the future to complete.
+ unreachable!("Should not be pending in a sync context");
+ },
+ }
+ }
+
+ /// Marks an order as failed and refunded.
+ ///
+ /// Wraps [`LSPS1ServiceHandler::order_failed_and_refunded`].
+ pub fn order_failed_and_refunded(
+ &self, counterparty_node_id: PublicKey, order_id: LSPS1OrderId,
+ ) -> Result<(), APIError> {
+ let mut fut = pin!(self.inner.order_failed_and_refunded(counterparty_node_id, order_id));
let mut waker = dummy_waker();
let mut ctx = task::Context::from_waker(&mut waker);
Why this scored 26/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.