Skip to content

Commit e7b8fa9

Browse files
committed
lsps2: Match variable leases to payments
Use resolved BOLT12 amounts to reject variable leases outside the payment range or total fee policy. Keep BOLT11 selection amountless until payment, while recording the exact BOLT12 fee limit. Co-Authored-By: HAL 9000
1 parent 09bab5c commit e7b8fa9

2 files changed

Lines changed: 117 additions & 61 deletions

File tree

src/liquidity/client/lsps2/mod.rs

Lines changed: 67 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -225,18 +225,17 @@ where
225225
},
226226
)
227227
},
228-
JitInvoiceRequest::Variable { .. } => {
229-
let (lease, proportional_fee, _, _) =
230-
self.acquire_variable_lease(&connection_manager).await?;
228+
JitInvoiceRequest::Variable { amount_msat, .. } => {
229+
// A BOLT12 invoice request has already resolved the payment amount. Use it to avoid
230+
// consuming a cached variable lease outside its advertised range, and record the exact
231+
// fee for that amount so payment validation need not accept the broader node-wide cap.
232+
let (lease, total_fee_msat, _, _) =
233+
self.acquire_variable_lease(Some(amount_msat), &connection_manager).await?;
231234
(
232235
lease,
233236
LSPS2Parameters {
234-
max_total_opening_fee_msat: self.config.lsps2_max_total_lsp_fee_limit_msat,
235-
max_proportional_opening_fee_ppm_msat: self
236-
.config
237-
.lsps2_max_total_lsp_fee_limit_msat
238-
.is_none()
239-
.then_some(proportional_fee),
237+
max_total_opening_fee_msat: Some(total_fee_msat),
238+
max_proportional_opening_fee_ppm_msat: None,
240239
},
241240
)
242241
},
@@ -300,7 +299,7 @@ where
300299
payment_hash: Option<PaymentHash>, connection_manager: Arc<ConnectionManager<L>>,
301300
) -> Result<(Bolt11Invoice, LspConfig), Error> {
302301
let (lease, proportional_fee, lsp, was_negotiated) =
303-
self.acquire_variable_lease(&connection_manager).await?;
302+
self.acquire_variable_lease(None, &connection_manager).await?;
304303
let lsps2_parameters = LSPS2Parameters {
305304
max_total_opening_fee_msat: self.config.lsps2_max_total_lsp_fee_limit_msat,
306305
max_proportional_opening_fee_ppm_msat: self
@@ -459,36 +458,36 @@ where
459458
}
460459

461460
async fn acquire_variable_lease(
462-
self: &Arc<Self>, connection_manager: &Arc<ConnectionManager<L>>,
461+
self: &Arc<Self>, amount_msat: Option<u64>, connection_manager: &Arc<ConnectionManager<L>>,
463462
) -> Result<(PaymentLease, u64, LspConfig, bool), Error> {
464-
if let Some((lease, proportional_fee, lsp)) = self.take_cached_variable_lease().await? {
463+
if let Some((lease, fee, lsp)) = self.take_cached_variable_lease(amount_msat).await? {
465464
self.schedule_variable_lease_refill(connection_manager);
466-
return Ok((lease, proportional_fee, lsp, false));
465+
return Ok((lease, fee, lsp, false));
467466
}
468467
let request_lock = self
469468
.pending_lease_request_state
470469
.lock()
471470
.expect("lock")
472471
.request_lock(LeaseRequestKey::Variable);
473472
let _request_guard = request_lock.lock().await;
474-
if let Some((lease, proportional_fee, lsp)) = self.take_cached_variable_lease().await? {
473+
if let Some((lease, fee, lsp)) = self.take_cached_variable_lease(amount_msat).await? {
475474
self.schedule_variable_lease_refill(connection_manager);
476-
return Ok((lease, proportional_fee, lsp, false));
475+
return Ok((lease, fee, lsp, false));
477476
}
478477

479-
let (negotiated_lease, min_prop_fee_ppm_msat, cheapest_lsp) =
480-
self.negotiate_variable_lease(connection_manager).await?;
478+
let (negotiated_lease, fee, cheapest_lsp) =
479+
self.negotiate_variable_lease(amount_msat, connection_manager).await?;
481480
let lease = self.consume_lease(&negotiated_lease.id).await?;
482481
self.schedule_variable_lease_refill(connection_manager);
483-
Ok((lease, min_prop_fee_ppm_msat, cheapest_lsp, true))
482+
Ok((lease, fee, cheapest_lsp, true))
484483
}
485484

486485
async fn negotiate_variable_lease(
487-
self: &Arc<Self>, connection_manager: &Arc<ConnectionManager<L>>,
486+
self: &Arc<Self>, amount_msat: Option<u64>, connection_manager: &Arc<ConnectionManager<L>>,
488487
) -> Result<(PaymentLease, u64, LspConfig), Error> {
489488
let mut attempt = 1;
490489
loop {
491-
let result = self.negotiate_variable_lease_once(connection_manager).await;
490+
let result = self.negotiate_variable_lease_once(amount_msat, connection_manager).await;
492491
match result {
493492
Err(error) if should_retry_lease_negotiation(error, attempt) => {
494493
log_warn!(
@@ -505,7 +504,7 @@ where
505504
}
506505

507506
async fn negotiate_variable_lease_once(
508-
self: &Arc<Self>, connection_manager: &Arc<ConnectionManager<L>>,
507+
self: &Arc<Self>, amount_msat: Option<u64>, connection_manager: &Arc<ConnectionManager<L>>,
509508
) -> Result<(PaymentLease, u64, LspConfig), Error> {
510509
let all_offers = self.gather_lsps2_offers(connection_manager).await?;
511510
let mut rejected_for_fee = false;
@@ -514,20 +513,34 @@ where
514513
.flat_map(|(lsp, resp)| {
515514
resp.opening_fee_params_menu.into_iter().map(move |params| (lsp.clone(), params))
516515
})
517-
.map(|(lsp, params)| {
518-
let ppm = params.proportional as u64;
519-
(lsp, ppm, params)
520-
})
521-
.filter(|(_, _, params)| {
522-
let allowed = self
516+
.filter_map(|(lsp, params)| {
517+
// BOLT12 supplies a resolved amount here, while a BOLT11 zero-amount invoice does not.
518+
// In the former case, only negotiate parameters that can carry that exact payment and
519+
// compare providers by the total fee the payment would actually incur.
520+
let selection_fee = if let Some(amount_msat) = amount_msat {
521+
if amount_msat < params.min_payment_size_msat
522+
|| amount_msat > params.max_payment_size_msat
523+
{
524+
return None;
525+
}
526+
compute_opening_fee(
527+
amount_msat,
528+
params.min_fee_msat,
529+
params.proportional as u64,
530+
)?
531+
} else {
532+
params.proportional as u64
533+
};
534+
let fee_for_limit = amount_msat.map_or(params.min_fee_msat, |_| selection_fee);
535+
let fee_allowed = self
523536
.config
524537
.lsps2_max_total_lsp_fee_limit_msat
525-
.map_or(true, |limit| params.min_fee_msat <= limit);
526-
rejected_for_fee |= !allowed;
527-
allowed
538+
.map_or(true, |limit| fee_for_limit <= limit);
539+
rejected_for_fee |= !fee_allowed;
540+
fee_allowed.then_some((lsp, selection_fee, params))
528541
})
529542
.collect::<Vec<_>>();
530-
candidates.sort_unstable_by_key(|(_, ppm, _)| *ppm);
543+
candidates.sort_unstable_by_key(|(_, fee, _)| *fee);
531544
if candidates.is_empty() {
532545
return Err(if rejected_for_fee {
533546
log_error!(
@@ -544,20 +557,29 @@ where
544557
try_lease_candidates(
545558
candidates,
546559
|(lsp, _, _)| lsp.node_id,
547-
|(lsp, proportional_fee_ppm_msat, opening_params)| {
560+
|(lsp, fee, opening_params)| {
548561
let client = Arc::clone(self);
549562
async move {
550-
log_debug!(
551-
client.logger,
552-
"Choosing liquidity offer from LSP {}, will pay {}ppm msat in proportional LSP fees",
553-
lsp.node_id,
554-
proportional_fee_ppm_msat
555-
);
563+
if amount_msat.is_some() {
564+
log_debug!(
565+
client.logger,
566+
"Choosing liquidity offer from LSP {}, will pay {}msat in total LSP fees",
567+
lsp.node_id,
568+
fee
569+
);
570+
} else {
571+
log_debug!(
572+
client.logger,
573+
"Choosing liquidity offer from LSP {}, will pay {}ppm msat in proportional LSP fees",
574+
lsp.node_id,
575+
fee
576+
);
577+
}
556578
match client
557579
.lsps2_send_buy_request(None, opening_params, Some(&lsp.node_id))
558580
.await
559581
{
560-
Ok(lease) => Ok((lease, proportional_fee_ppm_msat, lsp)),
582+
Ok(lease) => Ok((lease, fee, lsp)),
561583
Err(error) => {
562584
log_warn!(
563585
client.logger,
@@ -632,11 +654,11 @@ where
632654
.lease_state
633655
.lock()
634656
.expect("lock")
635-
.has_variable_amount(self.config.lsps2_max_total_lsp_fee_limit_msat)
657+
.has_variable_amount(None, self.config.lsps2_max_total_lsp_fee_limit_msat)
636658
{
637659
return Ok(());
638660
}
639-
self.negotiate_variable_lease(connection_manager).await?;
661+
self.negotiate_variable_lease(None, connection_manager).await?;
640662
Ok(())
641663
}
642664

@@ -833,22 +855,22 @@ where
833855
}
834856

835857
async fn take_cached_variable_lease(
836-
&self,
858+
&self, amount_msat: Option<u64>,
837859
) -> Result<Option<(PaymentLease, u64, LspConfig)>, Error> {
838860
loop {
839-
let Some((lease, proportional_fee)) = self
861+
let Some((lease, fee)) = self
840862
.lease_state
841863
.lock()
842864
.expect("lock")
843-
.variable_amount(self.config.lsps2_max_total_lsp_fee_limit_msat)
865+
.variable_amount(amount_msat, self.config.lsps2_max_total_lsp_fee_limit_msat)
844866
else {
845867
return Ok(None);
846868
};
847869
let lease = self.consume_selected_lease(lease).await?;
848870
if let Some(lsp) =
849871
select_lsps_for_protocol(&self.lsp_nodes, 2, Some(&lease.id.lsp_node_id))
850872
{
851-
return Ok(Some((lease, proportional_fee, lsp)));
873+
return Ok(Some((lease, fee, lsp)));
852874
}
853875
}
854876
}

src/liquidity/client/lsps2/state.rs

Lines changed: 50 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -347,27 +347,42 @@ impl LSPS2LeaseState {
347347
}
348348

349349
pub(crate) fn variable_amount(
350-
&self, max_total_fee_msat: Option<u64>,
350+
&self, amount_msat: Option<u64>, max_total_fee_msat: Option<u64>,
351351
) -> Option<(PaymentLease, u64)> {
352-
let (id, proportional_fee) = self
352+
let (id, selection_fee) = self
353353
.leases
354354
.iter()
355355
.filter(|(_, lease)| lease.payment_size_msat.is_none())
356356
.filter(|(_, lease)| is_lease_usable(lease))
357-
.filter(|(_, lease)| {
358-
max_total_fee_msat.map_or(true, |max| lease.params.min_fee_msat <= max)
357+
.filter_map(|(id, lease)| {
358+
let selection_fee = if let Some(amount_msat) = amount_msat {
359+
if amount_msat < lease.params.min_payment_size_msat
360+
|| amount_msat > lease.params.max_payment_size_msat
361+
{
362+
return None;
363+
}
364+
compute_opening_fee(
365+
amount_msat,
366+
lease.params.min_fee_msat,
367+
lease.params.proportional as u64,
368+
)?
369+
} else {
370+
lease.params.proportional as u64
371+
};
372+
let fee_for_limit =
373+
amount_msat.map_or(lease.params.min_fee_msat, |_| selection_fee);
374+
max_total_fee_msat
375+
.map_or(true, |max| fee_for_limit <= max)
376+
.then_some((*id, selection_fee))
359377
})
360-
.map(|(id, lease)| (*id, lease.params.proportional as u64))
361378
.min_by_key(|(_, fee)| *fee)?;
362-
self.leases.get(&id).cloned().map(|lease| (lease, proportional_fee))
379+
self.leases.get(&id).cloned().map(|lease| (lease, selection_fee))
363380
}
364381

365-
pub(crate) fn has_variable_amount(&self, max_total_fee_msat: Option<u64>) -> bool {
366-
self.leases
367-
.values()
368-
.filter(|lease| lease.payment_size_msat.is_none())
369-
.filter(|lease| is_lease_usable(lease))
370-
.any(|lease| max_total_fee_msat.map_or(true, |max| lease.params.min_fee_msat <= max))
382+
pub(crate) fn has_variable_amount(
383+
&self, amount_msat: Option<u64>, max_total_fee_msat: Option<u64>,
384+
) -> bool {
385+
self.variable_amount(amount_msat, max_total_fee_msat).is_some()
371386
}
372387

373388
pub(crate) fn prune(&mut self) {
@@ -458,8 +473,27 @@ mod tests {
458473
let variable = lease(2, 46, 50, None, valid_until);
459474
let state = LSPS2LeaseState::from_leases(vec![variable.clone()]);
460475

461-
assert!(state.variable_amount(Some(49)).is_none());
462-
assert_eq!(state.variable_amount(Some(50)).unwrap().0.id, variable.id);
476+
assert!(state.variable_amount(None, Some(49)).is_none());
477+
assert_eq!(state.variable_amount(None, Some(50)).unwrap().0.id, variable.id);
478+
}
479+
480+
#[test]
481+
fn variable_lease_matches_resolved_payment_amount() {
482+
let valid_until = now_secs() + MIN_LEASE_REMAINING_SECS + 60;
483+
let mut incompatible = lease(2, 47, 1, None, valid_until);
484+
incompatible.params.max_payment_size_msat = 1_000;
485+
let mut compatible = lease(3, 48, 2, None, valid_until);
486+
compatible.params.proportional = 500_000;
487+
compatible.params.max_payment_size_msat = 3_000;
488+
let state = LSPS2LeaseState::from_leases(vec![incompatible, compatible.clone()]);
489+
490+
let (selected, total_fee_msat) = state.variable_amount(Some(2_000), None).unwrap();
491+
assert_eq!(
492+
selected.id, compatible.id,
493+
"selected variable lease must accept the resolved payment amount"
494+
);
495+
assert_eq!(total_fee_msat, 1_000);
496+
assert!(state.variable_amount(Some(2_000), Some(999)).is_none());
463497
}
464498

465499
#[test]
@@ -472,8 +506,8 @@ mod tests {
472506
assert!(state.has_fixed_amount(1_000, Some(100)));
473507
assert!(!state.has_fixed_amount(1_000, Some(99)));
474508
assert!(!state.has_fixed_amount(2_000, None));
475-
assert!(state.has_variable_amount(Some(50)));
476-
assert!(!state.has_variable_amount(Some(49)));
509+
assert!(state.has_variable_amount(None, Some(50)));
510+
assert!(!state.has_variable_amount(None, Some(49)));
477511
}
478512

479513
fn cache_target(

0 commit comments

Comments
 (0)