Skip to content

Commit 25a876b

Browse files
committed
lsps1: Add prune_order API to remove completed order state
`LSPS1ServiceHandler` accumulates `ChannelOrder` records indefinitely. `CompletedAndChannelOpened` orders were never pruned automatically, and `FailedAndRefunded` orders were only pruned once all payment invoices in them had expired — which can be days away. Add `LSPS1ServiceHandler::prune_order` (and a sync wrapper on `LSPS1ServiceHandlerSync`) that lets the LSP operator explicitly remove any order that has reached a terminal state. The call persists the change to the KVStore before returning. Non-terminal orders (`ExpectingPayment`, `OrderPaid`) are rejected with the new `PeerStateError::OrderNotPrunable` variant so that in-progress orders cannot be silently discarded.
1 parent 47122e8 commit 25a876b

2 files changed

Lines changed: 205 additions & 0 deletions

File tree

lightning-liquidity/src/lsps1/peer_state.rs

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,27 @@ impl PeerState {
397397
});
398398
}
399399

400+
/// Removes a terminal order from state, allowing the operator to reclaim memory and free
401+
/// per-peer quota.
402+
///
403+
/// Only orders in the `CompletedAndChannelOpened` or `FailedAndRefunded` terminal states
404+
/// may be pruned. For `FailedAndRefunded` orders this may be called before the payment
405+
/// invoice has expired, allowing the client to create new orders without being blocked by
406+
/// DoS protections.
407+
pub(super) fn prune_order(&mut self, order_id: &LSPS1OrderId) -> Result<(), PeerStateError> {
408+
match self.outbound_channels_by_order_id.get(order_id) {
409+
None => return Err(PeerStateError::UnknownOrderId),
410+
Some(order) => match order.state {
411+
ChannelOrderState::CompletedAndChannelOpened { .. }
412+
| ChannelOrderState::FailedAndRefunded { .. } => {},
413+
_ => return Err(PeerStateError::OrderNotPrunable),
414+
},
415+
}
416+
self.outbound_channels_by_order_id.remove(order_id);
417+
self.needs_persist |= true;
418+
Ok(())
419+
}
420+
400421
fn pending_requests_and_unpaid_orders(&self) -> usize {
401422
let pending_requests = self.pending_requests.len();
402423
// We exclude paid and completed orders.
@@ -428,6 +449,7 @@ pub(super) enum PeerStateError {
428449
UnknownOrderId,
429450
InvalidStateTransition(ChannelOrderStateError),
430451
TooManyPendingRequests,
452+
OrderNotPrunable,
431453
}
432454

433455
impl fmt::Display for PeerStateError {
@@ -438,6 +460,9 @@ impl fmt::Display for PeerStateError {
438460
Self::UnknownOrderId => write!(f, "unknown order id"),
439461
Self::InvalidStateTransition(e) => write!(f, "{}", e),
440462
Self::TooManyPendingRequests => write!(f, "too many pending requests"),
463+
Self::OrderNotPrunable => {
464+
write!(f, "order is not in a terminal state and cannot be pruned")
465+
},
441466
}
442467
}
443468
}
@@ -778,4 +803,123 @@ mod tests {
778803
// Available in CompletedAndChannelOpened
779804
assert_eq!(state.channel_info(), Some(&channel_info));
780805
}
806+
807+
fn create_test_order_params() -> LSPS1OrderParams {
808+
LSPS1OrderParams {
809+
lsp_balance_sat: 100_000,
810+
client_balance_sat: 0,
811+
required_channel_confirmations: 0,
812+
funding_confirms_within_blocks: 6,
813+
channel_expiry_blocks: 144,
814+
token: None,
815+
announce_channel: false,
816+
}
817+
}
818+
819+
#[test]
820+
fn test_prune_order_completed() {
821+
let mut peer_state = PeerState::default();
822+
let order_id = LSPS1OrderId("order1".to_string());
823+
let payment_info = create_test_payment_info_bolt11_only();
824+
peer_state.new_order(
825+
order_id.clone(),
826+
create_test_order_params(),
827+
LSPSDateTime::from_str("2035-01-01T00:00:00Z").unwrap(),
828+
payment_info,
829+
);
830+
831+
peer_state.order_payment_received(&order_id, PaymentMethod::Bolt11).unwrap();
832+
peer_state.order_channel_opened(&order_id, create_test_channel_info()).unwrap();
833+
834+
assert!(peer_state.prune_order(&order_id).is_ok());
835+
assert!(peer_state.get_order(&order_id).is_err());
836+
}
837+
838+
#[test]
839+
fn test_prune_order_failed_and_refunded() {
840+
let mut peer_state = PeerState::default();
841+
let order_id = LSPS1OrderId("order2".to_string());
842+
// Use a non-expired invoice (expires_at in the future) to verify we bypass expiry check.
843+
let payment_info = create_test_payment_info_bolt11_only();
844+
peer_state.new_order(
845+
order_id.clone(),
846+
create_test_order_params(),
847+
LSPSDateTime::from_str("2035-01-01T00:00:00Z").unwrap(),
848+
payment_info,
849+
);
850+
peer_state.order_failed_and_refunded(&order_id).unwrap();
851+
852+
// Must succeed even though the invoice has not expired yet.
853+
assert!(peer_state.prune_order(&order_id).is_ok());
854+
assert!(peer_state.get_order(&order_id).is_err());
855+
}
856+
857+
#[test]
858+
fn test_prune_order_non_terminal_fails() {
859+
let mut peer_state = PeerState::default();
860+
861+
// ExpectingPayment is not prunable.
862+
let expecting_id = LSPS1OrderId("expecting".to_string());
863+
peer_state.new_order(
864+
expecting_id.clone(),
865+
create_test_order_params(),
866+
LSPSDateTime::from_str("2035-01-01T00:00:00Z").unwrap(),
867+
create_test_payment_info_bolt11_only(),
868+
);
869+
assert!(matches!(
870+
peer_state.prune_order(&expecting_id),
871+
Err(PeerStateError::OrderNotPrunable)
872+
));
873+
874+
// OrderPaid is not prunable.
875+
let paid_id = LSPS1OrderId("paid".to_string());
876+
peer_state.new_order(
877+
paid_id.clone(),
878+
create_test_order_params(),
879+
LSPSDateTime::from_str("2035-01-01T00:00:00Z").unwrap(),
880+
create_test_payment_info_bolt11_only(),
881+
);
882+
peer_state.order_payment_received(&paid_id, PaymentMethod::Bolt11).unwrap();
883+
assert!(matches!(peer_state.prune_order(&paid_id), Err(PeerStateError::OrderNotPrunable)));
884+
}
885+
886+
#[test]
887+
fn test_prune_order_unknown_fails() {
888+
let mut peer_state = PeerState::default();
889+
let unknown_id = LSPS1OrderId("nonexistent".to_string());
890+
assert!(matches!(peer_state.prune_order(&unknown_id), Err(PeerStateError::UnknownOrderId)));
891+
}
892+
893+
#[test]
894+
fn test_prune_order_frees_quota() {
895+
let mut peer_state = PeerState::default();
896+
897+
// Fill up to the limit with FailedAndRefunded orders.
898+
for i in 0..MAX_PENDING_REQUESTS_PER_PEER {
899+
let order_id = LSPS1OrderId(format!("order{}", i));
900+
peer_state.new_order(
901+
order_id.clone(),
902+
create_test_order_params(),
903+
LSPSDateTime::from_str("2035-01-01T00:00:00Z").unwrap(),
904+
create_test_payment_info_bolt11_only(),
905+
);
906+
peer_state.order_failed_and_refunded(&order_id).unwrap();
907+
}
908+
909+
// Registering another request must fail: quota is exhausted.
910+
let dummy_request = LSPS1Request::GetInfo(Default::default());
911+
assert!(matches!(
912+
peer_state.register_request(LSPSRequestId("r0".to_string()), dummy_request.clone()),
913+
Err(PeerStateError::TooManyPendingRequests)
914+
));
915+
916+
// Prune one FailedAndRefunded order.
917+
let first_id = LSPS1OrderId("order0".to_string());
918+
peer_state.prune_order(&first_id).unwrap();
919+
920+
// Now registering a new request must succeed.
921+
assert!(peer_state
922+
.register_request(LSPSRequestId("r1".to_string()), dummy_request)
923+
.is_ok());
924+
}
781925
}

lightning-liquidity/src/lsps1/service.rs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -752,6 +752,48 @@ where
752752
Ok(())
753753
}
754754

755+
/// Prunes a completed order from state, freeing memory and per-peer quota.
756+
///
757+
/// Only terminal orders ([`LSPS1OrderState::Completed`] /
758+
/// [`LSPS1OrderState::Failed`]) may be pruned. For `FailedAndRefunded` orders this may be
759+
/// called before the payment invoice has expired, allowing the client to create new orders
760+
/// without being blocked by the per-peer request limit.
761+
///
762+
/// Returns an [`APIError::APIMisuseError`] if the counterparty has no state, the order is
763+
/// unknown, or the order is in a non-terminal state.
764+
pub async fn prune_order(
765+
&self, counterparty_node_id: PublicKey, order_id: LSPS1OrderId,
766+
) -> Result<(), APIError> {
767+
let mut should_persist = false;
768+
match self.per_peer_state.read().unwrap().get(&counterparty_node_id) {
769+
Some(inner_state_lock) => {
770+
let mut peer_state_lock = inner_state_lock.lock().unwrap();
771+
peer_state_lock.prune_order(&order_id).map_err(|e| APIError::APIMisuseError {
772+
err: format!("Failed to prune order: {}", e),
773+
})?;
774+
should_persist |= peer_state_lock.needs_persist();
775+
},
776+
None => {
777+
return Err(APIError::APIMisuseError {
778+
err: format!("No existing state with counterparty {}", counterparty_node_id),
779+
});
780+
},
781+
}
782+
783+
if should_persist {
784+
self.persist_peer_state(counterparty_node_id).await.map_err(|e| {
785+
APIError::APIMisuseError {
786+
err: format!(
787+
"Failed to persist peer state for {}: {}",
788+
counterparty_node_id, e
789+
),
790+
}
791+
})?;
792+
}
793+
794+
Ok(())
795+
}
796+
755797
fn generate_order_id(&self) -> LSPS1OrderId {
756798
let bytes = self.entropy_source.get_secure_random_bytes();
757799
LSPS1OrderId(utils::hex_str(&bytes[0..16]))
@@ -930,6 +972,25 @@ where
930972
},
931973
}
932974
}
975+
976+
/// Prunes a completed order from state.
977+
///
978+
/// Wraps [`LSPS1ServiceHandler::prune_order`].
979+
pub fn prune_order(
980+
&self, counterparty_node_id: PublicKey, order_id: LSPS1OrderId,
981+
) -> Result<(), APIError> {
982+
let mut fut = pin!(self.inner.prune_order(counterparty_node_id, order_id));
983+
984+
let mut waker = dummy_waker();
985+
let mut ctx = task::Context::from_waker(&mut waker);
986+
match fut.as_mut().poll(&mut ctx) {
987+
task::Poll::Ready(result) => result,
988+
task::Poll::Pending => {
989+
// In a sync context, we can't wait for the future to complete.
990+
unreachable!("Should not be pending in a sync context");
991+
},
992+
}
993+
}
933994
}
934995

935996
fn check_range(min: u64, max: u64, value: u64) -> bool {

0 commit comments

Comments
 (0)