Skip to content

Commit 57d90ea

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 825cb3a commit 57d90ea

2 files changed

Lines changed: 133 additions & 57 deletions

File tree

src/liquidity/client/lsps2/mod.rs

Lines changed: 67 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -218,18 +218,17 @@ where
218218
},
219219
)
220220
},
221-
JitInvoiceRequest::Variable { .. } => {
222-
let (lease, proportional_fee, _, _) =
223-
self.acquire_variable_lease(&connection_manager).await?;
221+
JitInvoiceRequest::Variable { amount_msat, .. } => {
222+
// A BOLT12 invoice request has already resolved the payment amount. Use it to avoid
223+
// consuming a cached variable lease outside its advertised range, and record the exact
224+
// fee for that amount so payment validation need not accept the broader node-wide cap.
225+
let (lease, total_fee_msat, _, _) =
226+
self.acquire_variable_lease(Some(amount_msat), &connection_manager).await?;
224227
(
225228
lease,
226229
LSPS2Parameters {
227-
max_total_opening_fee_msat: self.config.lsps2_max_total_lsp_fee_limit_msat,
228-
max_proportional_opening_fee_ppm_msat: self
229-
.config
230-
.lsps2_max_total_lsp_fee_limit_msat
231-
.is_none()
232-
.then_some(proportional_fee),
230+
max_total_opening_fee_msat: Some(total_fee_msat),
231+
max_proportional_opening_fee_ppm_msat: None,
233232
},
234233
)
235234
},
@@ -293,7 +292,7 @@ where
293292
payment_hash: Option<PaymentHash>, connection_manager: Arc<ConnectionManager<L>>,
294293
) -> Result<(Bolt11Invoice, LspConfig), Error> {
295294
let (lease, proportional_fee, lsp, was_negotiated) =
296-
self.acquire_variable_lease(&connection_manager).await?;
295+
self.acquire_variable_lease(None, &connection_manager).await?;
297296
let lsps2_parameters = LSPS2Parameters {
298297
max_total_opening_fee_msat: self.config.lsps2_max_total_lsp_fee_limit_msat,
299298
max_proportional_opening_fee_ppm_msat: self
@@ -452,36 +451,36 @@ where
452451
}
453452

454453
async fn acquire_variable_lease(
455-
self: &Arc<Self>, connection_manager: &Arc<ConnectionManager<L>>,
454+
self: &Arc<Self>, amount_msat: Option<u64>, connection_manager: &Arc<ConnectionManager<L>>,
456455
) -> Result<(PaymentLease, u64, LspConfig, bool), Error> {
457-
if let Some((lease, proportional_fee, lsp)) = self.take_cached_variable_lease().await? {
456+
if let Some((lease, fee, lsp)) = self.take_cached_variable_lease(amount_msat).await? {
458457
self.schedule_variable_lease_refill(connection_manager);
459-
return Ok((lease, proportional_fee, lsp, false));
458+
return Ok((lease, fee, lsp, false));
460459
}
461460
let request_lock = self
462461
.pending_lease_request_state
463462
.lock()
464463
.expect("lock")
465464
.request_lock(LeaseRequestKey::Variable);
466465
let _request_guard = request_lock.lock().await;
467-
if let Some((lease, proportional_fee, lsp)) = self.take_cached_variable_lease().await? {
466+
if let Some((lease, fee, lsp)) = self.take_cached_variable_lease(amount_msat).await? {
468467
self.schedule_variable_lease_refill(connection_manager);
469-
return Ok((lease, proportional_fee, lsp, false));
468+
return Ok((lease, fee, lsp, false));
470469
}
471470

472-
let (negotiated_lease, min_prop_fee_ppm_msat, cheapest_lsp) =
473-
self.negotiate_variable_lease(connection_manager).await?;
471+
let (negotiated_lease, fee, cheapest_lsp) =
472+
self.negotiate_variable_lease(amount_msat, connection_manager).await?;
474473
let lease = self.consume_lease(&negotiated_lease.id).await?;
475474
self.schedule_variable_lease_refill(connection_manager);
476-
Ok((lease, min_prop_fee_ppm_msat, cheapest_lsp, true))
475+
Ok((lease, fee, cheapest_lsp, true))
477476
}
478477

479478
async fn negotiate_variable_lease(
480-
self: &Arc<Self>, connection_manager: &Arc<ConnectionManager<L>>,
479+
self: &Arc<Self>, amount_msat: Option<u64>, connection_manager: &Arc<ConnectionManager<L>>,
481480
) -> Result<(PaymentLease, u64, LspConfig), Error> {
482481
let mut attempt = 1;
483482
loop {
484-
let result = self.negotiate_variable_lease_once(connection_manager).await;
483+
let result = self.negotiate_variable_lease_once(amount_msat, connection_manager).await;
485484
match result {
486485
Err(error) if should_retry_lease_negotiation(error, attempt) => {
487486
log_warn!(
@@ -498,7 +497,7 @@ where
498497
}
499498

500499
async fn negotiate_variable_lease_once(
501-
self: &Arc<Self>, connection_manager: &Arc<ConnectionManager<L>>,
500+
self: &Arc<Self>, amount_msat: Option<u64>, connection_manager: &Arc<ConnectionManager<L>>,
502501
) -> Result<(PaymentLease, u64, LspConfig), Error> {
503502
let all_offers = self.gather_lsps2_offers(connection_manager).await?;
504503
let mut rejected_for_fee = false;
@@ -507,20 +506,34 @@ where
507506
.flat_map(|(lsp, resp)| {
508507
resp.opening_fee_params_menu.into_iter().map(move |params| (lsp.clone(), params))
509508
})
510-
.map(|(lsp, params)| {
511-
let ppm = params.proportional as u64;
512-
(lsp, ppm, params)
513-
})
514-
.filter(|(_, _, params)| {
515-
let allowed = self
509+
.filter_map(|(lsp, params)| {
510+
// BOLT12 supplies a resolved amount here, while a BOLT11 zero-amount invoice does not.
511+
// In the former case, only negotiate parameters that can carry that exact payment and
512+
// compare providers by the total fee the payment would actually incur.
513+
let selection_fee = if let Some(amount_msat) = amount_msat {
514+
if amount_msat < params.min_payment_size_msat
515+
|| amount_msat > params.max_payment_size_msat
516+
{
517+
return None;
518+
}
519+
compute_opening_fee(
520+
amount_msat,
521+
params.min_fee_msat,
522+
params.proportional as u64,
523+
)?
524+
} else {
525+
params.proportional as u64
526+
};
527+
let fee_for_limit = amount_msat.map_or(params.min_fee_msat, |_| selection_fee);
528+
let fee_allowed = self
516529
.config
517530
.lsps2_max_total_lsp_fee_limit_msat
518-
.map_or(true, |limit| params.min_fee_msat <= limit);
519-
rejected_for_fee |= !allowed;
520-
allowed
531+
.map_or(true, |limit| fee_for_limit <= limit);
532+
rejected_for_fee |= !fee_allowed;
533+
fee_allowed.then_some((lsp, selection_fee, params))
521534
})
522535
.collect::<Vec<_>>();
523-
candidates.sort_unstable_by_key(|(_, ppm, _)| *ppm);
536+
candidates.sort_unstable_by_key(|(_, fee, _)| *fee);
524537
if candidates.is_empty() {
525538
return Err(if rejected_for_fee {
526539
log_error!(
@@ -537,20 +550,29 @@ where
537550
try_lease_candidates(
538551
candidates,
539552
|(lsp, _, _)| lsp.node_id,
540-
|(lsp, proportional_fee_ppm_msat, opening_params)| {
553+
|(lsp, fee, opening_params)| {
541554
let client = Arc::clone(self);
542555
async move {
543-
log_debug!(
544-
client.logger,
545-
"Choosing liquidity offer from LSP {}, will pay {}ppm msat in proportional LSP fees",
546-
lsp.node_id,
547-
proportional_fee_ppm_msat
548-
);
556+
if amount_msat.is_some() {
557+
log_debug!(
558+
client.logger,
559+
"Choosing liquidity offer from LSP {}, will pay {}msat in total LSP fees",
560+
lsp.node_id,
561+
fee
562+
);
563+
} else {
564+
log_debug!(
565+
client.logger,
566+
"Choosing liquidity offer from LSP {}, will pay {}ppm msat in proportional LSP fees",
567+
lsp.node_id,
568+
fee
569+
);
570+
}
549571
match client
550572
.lsps2_send_buy_request(None, opening_params, Some(&lsp.node_id))
551573
.await
552574
{
553-
Ok(lease) => Ok((lease, proportional_fee_ppm_msat, lsp)),
575+
Ok(lease) => Ok((lease, fee, lsp)),
554576
Err(error) => {
555577
log_warn!(
556578
client.logger,
@@ -625,11 +647,11 @@ where
625647
.lease_state
626648
.lock()
627649
.expect("lock")
628-
.has_variable_amount(self.config.lsps2_max_total_lsp_fee_limit_msat)
650+
.has_variable_amount(None, self.config.lsps2_max_total_lsp_fee_limit_msat)
629651
{
630652
return Ok(());
631653
}
632-
self.negotiate_variable_lease(connection_manager).await?;
654+
self.negotiate_variable_lease(None, connection_manager).await?;
633655
Ok(())
634656
}
635657

@@ -829,22 +851,22 @@ where
829851
}
830852

831853
async fn take_cached_variable_lease(
832-
&self,
854+
&self, amount_msat: Option<u64>,
833855
) -> Result<Option<(PaymentLease, u64, LspConfig)>, Error> {
834856
loop {
835-
let Some((lease, proportional_fee)) = self
857+
let Some((lease, fee)) = self
836858
.lease_state
837859
.lock()
838860
.expect("lock")
839-
.variable_amount(self.config.lsps2_max_total_lsp_fee_limit_msat)
861+
.variable_amount(amount_msat, self.config.lsps2_max_total_lsp_fee_limit_msat)
840862
else {
841863
return Ok(None);
842864
};
843865
let lease = self.consume_selected_lease(lease).await?;
844866
if let Some(lsp) =
845867
select_lsps_for_protocol(&self.lsp_nodes, 2, Some(&lease.id.lsp_node_id))
846868
{
847-
return Ok(Some((lease, proportional_fee, lsp)));
869+
return Ok(Some((lease, fee, lsp)));
848870
}
849871
}
850872
}

src/liquidity/client/lsps2/state.rs

Lines changed: 66 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -423,27 +423,62 @@ impl LSPS2LeaseState {
423423

424424
/// Selects and removes a variable-amount lease in one operation.
425425
pub(crate) fn variable_amount(
426-
&mut self, max_total_fee_msat: Option<u64>,
426+
&mut self, amount_msat: Option<u64>, max_total_fee_msat: Option<u64>,
427427
) -> Option<(PaymentLease, u64)> {
428-
let (id, proportional_fee) = self
428+
let (id, selection_fee) = self
429429
.leases
430430
.iter()
431431
.filter(|(_, lease)| lease.payment_size_msat.is_none())
432432
.filter(|(_, lease)| is_lease_usable(lease))
433-
.filter(|(_, lease)| {
434-
max_total_fee_msat.map_or(true, |max| lease.params.min_fee_msat <= max)
433+
.filter_map(|(id, lease)| {
434+
let selection_fee = if let Some(amount_msat) = amount_msat {
435+
if amount_msat < lease.params.min_payment_size_msat
436+
|| amount_msat > lease.params.max_payment_size_msat
437+
{
438+
return None;
439+
}
440+
compute_opening_fee(
441+
amount_msat,
442+
lease.params.min_fee_msat,
443+
lease.params.proportional as u64,
444+
)?
445+
} else {
446+
lease.params.proportional as u64
447+
};
448+
let fee_for_limit =
449+
amount_msat.map_or(lease.params.min_fee_msat, |_| selection_fee);
450+
max_total_fee_msat
451+
.map_or(true, |max| fee_for_limit <= max)
452+
.then_some((*id, selection_fee))
435453
})
436-
.map(|(id, lease)| (*id, lease.params.proportional as u64))
437454
.min_by_key(|(_, fee)| *fee)?;
438-
self.leases.remove(&id).map(|lease| (lease, proportional_fee))
455+
self.leases.remove(&id).map(|lease| (lease, selection_fee))
439456
}
440457

441-
pub(crate) fn has_variable_amount(&self, max_total_fee_msat: Option<u64>) -> bool {
458+
pub(crate) fn has_variable_amount(
459+
&self, amount_msat: Option<u64>, max_total_fee_msat: Option<u64>,
460+
) -> bool {
442461
self.leases
443462
.values()
444463
.filter(|lease| lease.payment_size_msat.is_none())
445464
.filter(|lease| is_lease_usable(lease))
446-
.any(|lease| max_total_fee_msat.map_or(true, |max| lease.params.min_fee_msat <= max))
465+
.filter_map(|lease| {
466+
if let Some(amount_msat) = amount_msat {
467+
if amount_msat < lease.params.min_payment_size_msat
468+
|| amount_msat > lease.params.max_payment_size_msat
469+
{
470+
return None;
471+
}
472+
compute_opening_fee(
473+
amount_msat,
474+
lease.params.min_fee_msat,
475+
lease.params.proportional as u64,
476+
)
477+
} else {
478+
Some(lease.params.min_fee_msat)
479+
}
480+
})
481+
.any(|fee_msat| max_total_fee_msat.map_or(true, |max| fee_msat <= max))
447482
}
448483

449484
pub(crate) fn prune(&mut self) {
@@ -591,8 +626,27 @@ mod tests {
591626
let variable = lease(2, 46, 50, None, valid_until);
592627
let mut state = LSPS2LeaseState::from_leases(vec![variable.clone()]);
593628

594-
assert!(state.variable_amount(Some(49)).is_none());
595-
assert_eq!(state.variable_amount(Some(50)).unwrap().0.id, variable.id);
629+
assert!(state.variable_amount(None, Some(49)).is_none());
630+
assert_eq!(state.variable_amount(None, Some(50)).unwrap().0.id, variable.id);
631+
}
632+
633+
#[test]
634+
fn variable_lease_matches_resolved_payment_amount() {
635+
let valid_until = now_secs() + MIN_LEASE_REMAINING_SECS + 60;
636+
let mut incompatible = lease(2, 47, 1, None, valid_until);
637+
incompatible.params.max_payment_size_msat = 1_000;
638+
let mut compatible = lease(3, 48, 2, None, valid_until);
639+
compatible.params.proportional = 500_000;
640+
compatible.params.max_payment_size_msat = 3_000;
641+
let mut state = LSPS2LeaseState::from_leases(vec![incompatible, compatible.clone()]);
642+
643+
assert!(state.variable_amount(Some(2_000), Some(999)).is_none());
644+
let (selected, total_fee_msat) = state.variable_amount(Some(2_000), None).unwrap();
645+
assert_eq!(
646+
selected.id, compatible.id,
647+
"selected variable lease must accept the resolved payment amount"
648+
);
649+
assert_eq!(total_fee_msat, 1_000);
596650
}
597651

598652
#[test]
@@ -605,8 +659,8 @@ mod tests {
605659
assert!(state.has_fixed_amount(1_000, Some(100)));
606660
assert!(!state.has_fixed_amount(1_000, Some(99)));
607661
assert!(!state.has_fixed_amount(2_000, None));
608-
assert!(state.has_variable_amount(Some(50)));
609-
assert!(!state.has_variable_amount(Some(49)));
662+
assert!(state.has_variable_amount(None, Some(50)));
663+
assert!(!state.has_variable_amount(None, Some(49)));
610664
}
611665

612666
fn cache_target(

0 commit comments

Comments
 (0)