Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions lightning-liquidity/src/lsps1/peer_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -428,6 +449,7 @@ pub(super) enum PeerStateError {
UnknownOrderId,
InvalidStateTransition(ChannelOrderStateError),
TooManyPendingRequests,
OrderNotPrunable,
}

impl fmt::Display for PeerStateError {
Expand All @@ -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")
},
}
}
}
Expand Down Expand Up @@ -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());
}
}
61 changes: 61 additions & 0 deletions lightning-liquidity/src/lsps1/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]))
Expand Down Expand Up @@ -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 {
Expand Down
Loading