Skip to content

Commit 11c727f

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 02b8f28 commit 11c727f

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
@@ -214,18 +214,17 @@ where
214214
},
215215
)
216216
},
217-
JitInvoiceRequest::Variable { .. } => {
218-
let (lease, proportional_fee, _, _) =
219-
self.acquire_variable_lease(&connection_manager).await?;
217+
JitInvoiceRequest::Variable { amount_msat, .. } => {
218+
// A BOLT12 invoice request has already resolved the payment amount. Use it to avoid
219+
// consuming a cached variable lease outside its advertised range, and record the exact
220+
// fee for that amount so payment validation need not accept the broader node-wide cap.
221+
let (lease, total_fee_msat, _, _) =
222+
self.acquire_variable_lease(Some(amount_msat), &connection_manager).await?;
220223
(
221224
lease,
222225
LSPS2Parameters {
223-
max_total_opening_fee_msat: self.config.lsps2_max_total_lsp_fee_limit_msat,
224-
max_proportional_opening_fee_ppm_msat: self
225-
.config
226-
.lsps2_max_total_lsp_fee_limit_msat
227-
.is_none()
228-
.then_some(proportional_fee),
226+
max_total_opening_fee_msat: Some(total_fee_msat),
227+
max_proportional_opening_fee_ppm_msat: None,
229228
},
230229
)
231230
},
@@ -289,7 +288,7 @@ where
289288
payment_hash: Option<PaymentHash>, connection_manager: Arc<ConnectionManager<L>>,
290289
) -> Result<(Bolt11Invoice, LspConfig), Error> {
291290
let (lease, proportional_fee, lsp, was_negotiated) =
292-
self.acquire_variable_lease(&connection_manager).await?;
291+
self.acquire_variable_lease(None, &connection_manager).await?;
293292
let lsps2_parameters = LSPS2Parameters {
294293
max_total_opening_fee_msat: self.config.lsps2_max_total_lsp_fee_limit_msat,
295294
max_proportional_opening_fee_ppm_msat: self
@@ -448,36 +447,36 @@ where
448447
}
449448

450449
async fn acquire_variable_lease(
451-
self: &Arc<Self>, connection_manager: &Arc<ConnectionManager<L>>,
450+
self: &Arc<Self>, amount_msat: Option<u64>, connection_manager: &Arc<ConnectionManager<L>>,
452451
) -> Result<(PaymentLease, u64, LspConfig, bool), Error> {
453-
if let Some((lease, proportional_fee, lsp)) = self.take_cached_variable_lease().await? {
452+
if let Some((lease, fee, lsp)) = self.take_cached_variable_lease(amount_msat).await? {
454453
self.schedule_variable_lease_refill(connection_manager);
455-
return Ok((lease, proportional_fee, lsp, false));
454+
return Ok((lease, fee, lsp, false));
456455
}
457456
let request_lock = self
458457
.pending_lease_request_state
459458
.lock()
460459
.expect("lock")
461460
.request_lock(LeaseRequestKey::Variable);
462461
let _request_guard = request_lock.lock().await;
463-
if let Some((lease, proportional_fee, lsp)) = self.take_cached_variable_lease().await? {
462+
if let Some((lease, fee, lsp)) = self.take_cached_variable_lease(amount_msat).await? {
464463
self.schedule_variable_lease_refill(connection_manager);
465-
return Ok((lease, proportional_fee, lsp, false));
464+
return Ok((lease, fee, lsp, false));
466465
}
467466

468-
let (negotiated_lease, min_prop_fee_ppm_msat, cheapest_lsp) =
469-
self.negotiate_variable_lease(connection_manager).await?;
467+
let (negotiated_lease, fee, cheapest_lsp) =
468+
self.negotiate_variable_lease(amount_msat, connection_manager).await?;
470469
let lease = self.consume_lease(&negotiated_lease.id).await?;
471470
self.schedule_variable_lease_refill(connection_manager);
472-
Ok((lease, min_prop_fee_ppm_msat, cheapest_lsp, true))
471+
Ok((lease, fee, cheapest_lsp, true))
473472
}
474473

475474
async fn negotiate_variable_lease(
476-
self: &Arc<Self>, connection_manager: &Arc<ConnectionManager<L>>,
475+
self: &Arc<Self>, amount_msat: Option<u64>, connection_manager: &Arc<ConnectionManager<L>>,
477476
) -> Result<(PaymentLease, u64, LspConfig), Error> {
478477
let mut attempt = 1;
479478
loop {
480-
let result = self.negotiate_variable_lease_once(connection_manager).await;
479+
let result = self.negotiate_variable_lease_once(amount_msat, connection_manager).await;
481480
match result {
482481
Err(error) if should_retry_lease_negotiation(error, attempt) => {
483482
log_warn!(
@@ -494,7 +493,7 @@ where
494493
}
495494

496495
async fn negotiate_variable_lease_once(
497-
self: &Arc<Self>, connection_manager: &Arc<ConnectionManager<L>>,
496+
self: &Arc<Self>, amount_msat: Option<u64>, connection_manager: &Arc<ConnectionManager<L>>,
498497
) -> Result<(PaymentLease, u64, LspConfig), Error> {
499498
let all_offers = self.gather_lsps2_offers(connection_manager).await?;
500499
let mut rejected_for_fee = false;
@@ -503,20 +502,34 @@ where
503502
.flat_map(|(lsp, resp)| {
504503
resp.opening_fee_params_menu.into_iter().map(move |params| (lsp.clone(), params))
505504
})
506-
.map(|(lsp, params)| {
507-
let ppm = params.proportional as u64;
508-
(lsp, ppm, params)
509-
})
510-
.filter(|(_, _, params)| {
511-
let allowed = self
505+
.filter_map(|(lsp, params)| {
506+
// BOLT12 supplies a resolved amount here, while a BOLT11 zero-amount invoice does not.
507+
// In the former case, only negotiate parameters that can carry that exact payment and
508+
// compare providers by the total fee the payment would actually incur.
509+
let selection_fee = if let Some(amount_msat) = amount_msat {
510+
if amount_msat < params.min_payment_size_msat
511+
|| amount_msat > params.max_payment_size_msat
512+
{
513+
return None;
514+
}
515+
compute_opening_fee(
516+
amount_msat,
517+
params.min_fee_msat,
518+
params.proportional as u64,
519+
)?
520+
} else {
521+
params.proportional as u64
522+
};
523+
let fee_for_limit = amount_msat.map_or(params.min_fee_msat, |_| selection_fee);
524+
let fee_allowed = self
512525
.config
513526
.lsps2_max_total_lsp_fee_limit_msat
514-
.map_or(true, |limit| params.min_fee_msat <= limit);
515-
rejected_for_fee |= !allowed;
516-
allowed
527+
.map_or(true, |limit| fee_for_limit <= limit);
528+
rejected_for_fee |= !fee_allowed;
529+
fee_allowed.then_some((lsp, selection_fee, params))
517530
})
518531
.collect::<Vec<_>>();
519-
candidates.sort_unstable_by_key(|(_, ppm, _)| *ppm);
532+
candidates.sort_unstable_by_key(|(_, fee, _)| *fee);
520533
if candidates.is_empty() {
521534
return Err(if rejected_for_fee {
522535
log_error!(
@@ -533,20 +546,29 @@ where
533546
try_lease_candidates(
534547
candidates,
535548
|(lsp, _, _)| lsp.node_id,
536-
|(lsp, proportional_fee_ppm_msat, opening_params)| {
549+
|(lsp, fee, opening_params)| {
537550
let client = Arc::clone(self);
538551
async move {
539-
log_debug!(
540-
client.logger,
541-
"Choosing liquidity offer from LSP {}, will pay {}ppm msat in proportional LSP fees",
542-
lsp.node_id,
543-
proportional_fee_ppm_msat
544-
);
552+
if amount_msat.is_some() {
553+
log_debug!(
554+
client.logger,
555+
"Choosing liquidity offer from LSP {}, will pay {}msat in total LSP fees",
556+
lsp.node_id,
557+
fee
558+
);
559+
} else {
560+
log_debug!(
561+
client.logger,
562+
"Choosing liquidity offer from LSP {}, will pay {}ppm msat in proportional LSP fees",
563+
lsp.node_id,
564+
fee
565+
);
566+
}
545567
match client
546568
.lsps2_send_buy_request(None, opening_params, Some(&lsp.node_id))
547569
.await
548570
{
549-
Ok(lease) => Ok((lease, proportional_fee_ppm_msat, lsp)),
571+
Ok(lease) => Ok((lease, fee, lsp)),
550572
Err(error) => {
551573
log_warn!(
552574
client.logger,
@@ -621,11 +643,11 @@ where
621643
.lease_state
622644
.lock()
623645
.expect("lock")
624-
.has_variable_amount(self.config.lsps2_max_total_lsp_fee_limit_msat)
646+
.has_variable_amount(None, self.config.lsps2_max_total_lsp_fee_limit_msat)
625647
{
626648
return Ok(());
627649
}
628-
self.negotiate_variable_lease(connection_manager).await?;
650+
self.negotiate_variable_lease(None, connection_manager).await?;
629651
Ok(())
630652
}
631653

@@ -822,22 +844,22 @@ where
822844
}
823845

824846
async fn take_cached_variable_lease(
825-
&self,
847+
&self, amount_msat: Option<u64>,
826848
) -> Result<Option<(PaymentLease, u64, LspConfig)>, Error> {
827849
loop {
828-
let Some((lease, proportional_fee)) = self
850+
let Some((lease, fee)) = self
829851
.lease_state
830852
.lock()
831853
.expect("lock")
832-
.variable_amount(self.config.lsps2_max_total_lsp_fee_limit_msat)
854+
.variable_amount(amount_msat, self.config.lsps2_max_total_lsp_fee_limit_msat)
833855
else {
834856
return Ok(None);
835857
};
836858
let lease = self.consume_selected_lease(lease).await?;
837859
if let Some(lsp) =
838860
select_lsps_for_protocol(&self.lsp_nodes, 2, Some(&lease.id.lsp_node_id))
839861
{
840-
return Ok(Some((lease, proportional_fee, lsp)));
862+
return Ok(Some((lease, fee, lsp)));
841863
}
842864
}
843865
}

src/liquidity/client/lsps2/state.rs

Lines changed: 50 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -421,27 +421,42 @@ impl LSPS2LeaseState {
421421
}
422422

423423
pub(crate) fn variable_amount(
424-
&self, max_total_fee_msat: Option<u64>,
424+
&self, amount_msat: Option<u64>, max_total_fee_msat: Option<u64>,
425425
) -> Option<(PaymentLease, u64)> {
426-
let (id, proportional_fee) = self
426+
let (id, selection_fee) = self
427427
.leases
428428
.iter()
429429
.filter(|(_, lease)| lease.payment_size_msat.is_none())
430430
.filter(|(_, lease)| is_lease_usable(lease))
431-
.filter(|(_, lease)| {
432-
max_total_fee_msat.map_or(true, |max| lease.params.min_fee_msat <= max)
431+
.filter_map(|(id, lease)| {
432+
let selection_fee = if let Some(amount_msat) = amount_msat {
433+
if amount_msat < lease.params.min_payment_size_msat
434+
|| amount_msat > lease.params.max_payment_size_msat
435+
{
436+
return None;
437+
}
438+
compute_opening_fee(
439+
amount_msat,
440+
lease.params.min_fee_msat,
441+
lease.params.proportional as u64,
442+
)?
443+
} else {
444+
lease.params.proportional as u64
445+
};
446+
let fee_for_limit =
447+
amount_msat.map_or(lease.params.min_fee_msat, |_| selection_fee);
448+
max_total_fee_msat
449+
.map_or(true, |max| fee_for_limit <= max)
450+
.then_some((*id, selection_fee))
433451
})
434-
.map(|(id, lease)| (*id, lease.params.proportional as u64))
435452
.min_by_key(|(_, fee)| *fee)?;
436-
self.leases.get(&id).cloned().map(|lease| (lease, proportional_fee))
453+
self.leases.get(&id).cloned().map(|lease| (lease, selection_fee))
437454
}
438455

439-
pub(crate) fn has_variable_amount(&self, max_total_fee_msat: Option<u64>) -> bool {
440-
self.leases
441-
.values()
442-
.filter(|lease| lease.payment_size_msat.is_none())
443-
.filter(|lease| is_lease_usable(lease))
444-
.any(|lease| max_total_fee_msat.map_or(true, |max| lease.params.min_fee_msat <= max))
456+
pub(crate) fn has_variable_amount(
457+
&self, amount_msat: Option<u64>, max_total_fee_msat: Option<u64>,
458+
) -> bool {
459+
self.variable_amount(amount_msat, max_total_fee_msat).is_some()
445460
}
446461

447462
pub(crate) fn prune(&mut self) {
@@ -582,8 +597,27 @@ mod tests {
582597
let variable = lease(2, 46, 50, None, valid_until);
583598
let state = LSPS2LeaseState::from_leases(vec![variable.clone()]);
584599

585-
assert!(state.variable_amount(Some(49)).is_none());
586-
assert_eq!(state.variable_amount(Some(50)).unwrap().0.id, variable.id);
600+
assert!(state.variable_amount(None, Some(49)).is_none());
601+
assert_eq!(state.variable_amount(None, Some(50)).unwrap().0.id, variable.id);
602+
}
603+
604+
#[test]
605+
fn variable_lease_matches_resolved_payment_amount() {
606+
let valid_until = now_secs() + MIN_LEASE_REMAINING_SECS + 60;
607+
let mut incompatible = lease(2, 47, 1, None, valid_until);
608+
incompatible.params.max_payment_size_msat = 1_000;
609+
let mut compatible = lease(3, 48, 2, None, valid_until);
610+
compatible.params.proportional = 500_000;
611+
compatible.params.max_payment_size_msat = 3_000;
612+
let state = LSPS2LeaseState::from_leases(vec![incompatible, compatible.clone()]);
613+
614+
let (selected, total_fee_msat) = state.variable_amount(Some(2_000), None).unwrap();
615+
assert_eq!(
616+
selected.id, compatible.id,
617+
"selected variable lease must accept the resolved payment amount"
618+
);
619+
assert_eq!(total_fee_msat, 1_000);
620+
assert!(state.variable_amount(Some(2_000), Some(999)).is_none());
587621
}
588622

589623
#[test]
@@ -596,8 +630,8 @@ mod tests {
596630
assert!(state.has_fixed_amount(1_000, Some(100)));
597631
assert!(!state.has_fixed_amount(1_000, Some(99)));
598632
assert!(!state.has_fixed_amount(2_000, None));
599-
assert!(state.has_variable_amount(Some(50)));
600-
assert!(!state.has_variable_amount(Some(49)));
633+
assert!(state.has_variable_amount(None, Some(50)));
634+
assert!(!state.has_variable_amount(None, Some(49)));
601635
}
602636

603637
fn cache_target(

0 commit comments

Comments
 (0)