diff --git a/lightning-liquidity/src/lsps1/peer_state.rs b/lightning-liquidity/src/lsps1/peer_state.rs index 6e1889749ae..8f336446822 100644 --- a/lightning-liquidity/src/lsps1/peer_state.rs +++ b/lightning-liquidity/src/lsps1/peer_state.rs @@ -397,6 +397,27 @@ impl PeerState { }); } + /// Removes a terminal order from state, allowing the operator to reclaim memory and free + /// per-peer quota. + /// + /// Only orders in the `CompletedAndChannelOpened` or `FailedAndRefunded` terminal states + /// may be pruned. For `FailedAndRefunded` orders this may be called before the payment + /// invoice has expired, allowing the client to create new orders without being blocked by + /// DoS protections. + pub(super) fn prune_order(&mut self, order_id: &LSPS1OrderId) -> Result<(), PeerStateError> { + match self.outbound_channels_by_order_id.get(order_id) { + None => return Err(PeerStateError::UnknownOrderId), + Some(order) => match order.state { + ChannelOrderState::CompletedAndChannelOpened { .. } + | ChannelOrderState::FailedAndRefunded { .. } => {}, + _ => return Err(PeerStateError::OrderNotPrunable), + }, + } + self.outbound_channels_by_order_id.remove(order_id); + self.needs_persist |= true; + Ok(()) + } + fn pending_requests_and_unpaid_orders(&self) -> usize { let pending_requests = self.pending_requests.len(); // We exclude paid and completed orders. @@ -428,6 +449,7 @@ pub(super) enum PeerStateError { UnknownOrderId, InvalidStateTransition(ChannelOrderStateError), TooManyPendingRequests, + OrderNotPrunable, } impl fmt::Display for PeerStateError { @@ -438,6 +460,9 @@ impl fmt::Display for PeerStateError { Self::UnknownOrderId => write!(f, "unknown order id"), Self::InvalidStateTransition(e) => write!(f, "{}", e), Self::TooManyPendingRequests => write!(f, "too many pending requests"), + Self::OrderNotPrunable => { + write!(f, "order is not in a terminal state and cannot be pruned") + }, } } } @@ -778,4 +803,123 @@ mod tests { // Available in CompletedAndChannelOpened assert_eq!(state.channel_info(), Some(&channel_info)); } + + fn create_test_order_params() -> LSPS1OrderParams { + LSPS1OrderParams { + lsp_balance_sat: 100_000, + client_balance_sat: 0, + required_channel_confirmations: 0, + funding_confirms_within_blocks: 6, + channel_expiry_blocks: 144, + token: None, + announce_channel: false, + } + } + + #[test] + fn test_prune_order_completed() { + let mut peer_state = PeerState::default(); + let order_id = LSPS1OrderId("order1".to_string()); + let payment_info = create_test_payment_info_bolt11_only(); + peer_state.new_order( + order_id.clone(), + create_test_order_params(), + LSPSDateTime::from_str("2035-01-01T00:00:00Z").unwrap(), + payment_info, + ); + + peer_state.order_payment_received(&order_id, PaymentMethod::Bolt11).unwrap(); + peer_state.order_channel_opened(&order_id, create_test_channel_info()).unwrap(); + + assert!(peer_state.prune_order(&order_id).is_ok()); + assert!(peer_state.get_order(&order_id).is_err()); + } + + #[test] + fn test_prune_order_failed_and_refunded() { + let mut peer_state = PeerState::default(); + let order_id = LSPS1OrderId("order2".to_string()); + // Use a non-expired invoice (expires_at in the future) to verify we bypass expiry check. + let payment_info = create_test_payment_info_bolt11_only(); + peer_state.new_order( + order_id.clone(), + create_test_order_params(), + LSPSDateTime::from_str("2035-01-01T00:00:00Z").unwrap(), + payment_info, + ); + peer_state.order_failed_and_refunded(&order_id).unwrap(); + + // Must succeed even though the invoice has not expired yet. + assert!(peer_state.prune_order(&order_id).is_ok()); + assert!(peer_state.get_order(&order_id).is_err()); + } + + #[test] + fn test_prune_order_non_terminal_fails() { + let mut peer_state = PeerState::default(); + + // ExpectingPayment is not prunable. + let expecting_id = LSPS1OrderId("expecting".to_string()); + peer_state.new_order( + expecting_id.clone(), + create_test_order_params(), + LSPSDateTime::from_str("2035-01-01T00:00:00Z").unwrap(), + create_test_payment_info_bolt11_only(), + ); + assert!(matches!( + peer_state.prune_order(&expecting_id), + Err(PeerStateError::OrderNotPrunable) + )); + + // OrderPaid is not prunable. + let paid_id = LSPS1OrderId("paid".to_string()); + peer_state.new_order( + paid_id.clone(), + create_test_order_params(), + LSPSDateTime::from_str("2035-01-01T00:00:00Z").unwrap(), + create_test_payment_info_bolt11_only(), + ); + peer_state.order_payment_received(&paid_id, PaymentMethod::Bolt11).unwrap(); + assert!(matches!(peer_state.prune_order(&paid_id), Err(PeerStateError::OrderNotPrunable))); + } + + #[test] + fn test_prune_order_unknown_fails() { + let mut peer_state = PeerState::default(); + let unknown_id = LSPS1OrderId("nonexistent".to_string()); + assert!(matches!(peer_state.prune_order(&unknown_id), Err(PeerStateError::UnknownOrderId))); + } + + #[test] + fn test_prune_order_frees_quota() { + let mut peer_state = PeerState::default(); + + // Fill up to the limit with FailedAndRefunded orders. + for i in 0..MAX_PENDING_REQUESTS_PER_PEER { + let order_id = LSPS1OrderId(format!("order{}", i)); + peer_state.new_order( + order_id.clone(), + create_test_order_params(), + LSPSDateTime::from_str("2035-01-01T00:00:00Z").unwrap(), + create_test_payment_info_bolt11_only(), + ); + peer_state.order_failed_and_refunded(&order_id).unwrap(); + } + + // Registering another request must fail: quota is exhausted. + let dummy_request = LSPS1Request::GetInfo(Default::default()); + assert!(matches!( + peer_state.register_request(LSPSRequestId("r0".to_string()), dummy_request.clone()), + Err(PeerStateError::TooManyPendingRequests) + )); + + // Prune one FailedAndRefunded order. + let first_id = LSPS1OrderId("order0".to_string()); + peer_state.prune_order(&first_id).unwrap(); + + // Now registering a new request must succeed. + assert!(peer_state + .register_request(LSPSRequestId("r1".to_string()), dummy_request) + .is_ok()); + } } diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index 0e139907589..f0468e50788 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -752,6 +752,48 @@ where Ok(()) } + /// Prunes a completed order from state, freeing memory and per-peer quota. + /// + /// Only terminal orders ([`LSPS1OrderState::Completed`] / + /// [`LSPS1OrderState::Failed`]) may be pruned. For `FailedAndRefunded` orders this may be + /// called before the payment invoice has expired, allowing the client to create new orders + /// without being blocked by the per-peer request limit. + /// + /// Returns an [`APIError::APIMisuseError`] if the counterparty has no state, the order is + /// unknown, or the order is in a non-terminal state. + pub async fn prune_order( + &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.prune_order(&order_id).map_err(|e| APIError::APIMisuseError { + err: format!("Failed to prune 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(()) + } + fn generate_order_id(&self) -> LSPS1OrderId { let bytes = self.entropy_source.get_secure_random_bytes(); LSPS1OrderId(utils::hex_str(&bytes[0..16])) @@ -930,6 +972,25 @@ where }, } } + + /// Prunes a completed order from state. + /// + /// Wraps [`LSPS1ServiceHandler::prune_order`]. + pub fn prune_order( + &self, counterparty_node_id: PublicKey, order_id: LSPS1OrderId, + ) -> Result<(), APIError> { + let mut fut = pin!(self.inner.prune_order(counterparty_node_id, order_id)); + + 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"); + }, + } + } } fn check_range(min: u64, max: u64, value: u64) -> bool {