Skip to content
Open
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
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
# Pending

## Compatibility Notes
- Pending JIT-channel payments created before upgrading may fail after upgrade because the
prior LSPS2 fee-limit state stored in `PaymentKind::Bolt11Jit` is not migrated.

# 0.7.0 - Dec. 3, 2025
This seventh minor release introduces numerous new features, bug fixes, and API improvements. In particular, it adds support for channel Splicing, Async Payments, as well as sourcing chain data from a Bitcoin Core REST backend.

Expand Down Expand Up @@ -419,4 +425,3 @@ integrated LDK and BDK-based wallets.

**Note:** This release is still considered experimental, should not be run in
production, and no compatibility guarantees are given until the release of 0.1.

142 changes: 101 additions & 41 deletions src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ use crate::payment::asynchronous::static_invoice_store::StaticInvoiceStore;
use crate::payment::store::{
PaymentDetails, PaymentDetailsUpdate, PaymentDirection, PaymentKind, PaymentStatus,
};
use crate::payment::Bolt11PaymentMetadata;
use crate::runtime::Runtime;
use crate::types::{
CustomTlvRecord, DynStore, KeysManager, OnionMessenger, PaymentStore, Sweeper, Wallet,
Expand Down Expand Up @@ -581,6 +582,35 @@ where
}
}

fn fail_claimable_payment(
&self, payment_id: PaymentId, payment_hash: &PaymentHash,
) -> Result<(), ReplayEvent> {
self.channel_manager.fail_htlc_backwards(payment_hash);

let update = PaymentDetailsUpdate {
status: Some(PaymentStatus::Failed),
..PaymentDetailsUpdate::new(payment_id)
};
match self.payment_store.update(update) {
Ok(_) => Ok(()),
Err(e) => {
log_error!(self.logger, "Failed to access payment store: {}", e);
Err(ReplayEvent())
},
}
}

fn lsps2_max_total_opening_fee_msat(payment_metadata: &[u8], amount_msat: u64) -> Option<u64> {
let metadata = Bolt11PaymentMetadata::read(&mut &payment_metadata[..]).ok()?;
let lsps2_parameters = metadata.lsps2_parameters?;
lsps2_parameters.max_total_opening_fee_msat.or_else(|| {
lsps2_parameters.max_proportional_opening_fee_ppm_msat.and_then(|max_prop_fee| {
// If it's a variable amount payment, compute the actual fee.
compute_opening_fee(amount_msat, 0, max_prop_fee)
})
})
}

pub async fn handle_event(&self, event: LdkEvent) -> Result<(), ReplayEvent> {
match event {
LdkEvent::FundingGenerationReady {
Expand Down Expand Up @@ -694,7 +724,8 @@ where
..
} => {
let payment_id = PaymentId(payment_hash.0);
if let Some(info) = self.payment_store.get(&payment_id) {
let payment_info = self.payment_store.get(&payment_id);
if let Some(info) = payment_info.as_ref() {
if info.direction == PaymentDirection::Outbound {
log_info!(
self.logger,
Expand All @@ -717,14 +748,13 @@ where
}

if info.status == PaymentStatus::Succeeded
|| matches!(info.kind, PaymentKind::Spontaneous { .. })
|| matches!(&info.kind, PaymentKind::Spontaneous { .. })
{
let stored_preimage = match info.kind {
let stored_preimage = match &info.kind {
PaymentKind::Bolt11 { preimage, .. }
| PaymentKind::Bolt11Jit { preimage, .. }
| PaymentKind::Bolt12Offer { preimage, .. }
| PaymentKind::Bolt12Refund { preimage, .. }
| PaymentKind::Spontaneous { preimage, .. } => preimage,
| PaymentKind::Spontaneous { preimage, .. } => *preimage,
_ => None,
};

Expand Down Expand Up @@ -759,22 +789,28 @@ where
},
};
}
}

let max_total_opening_fee_msat = match info.kind {
PaymentKind::Bolt11Jit { lsp_fee_limits, .. } => {
lsp_fee_limits
.max_total_opening_fee_msat
.or_else(|| {
lsp_fee_limits.max_proportional_opening_fee_ppm_msat.and_then(
|max_prop_fee| {
// If it's a variable amount payment, compute the actual fee.
compute_opening_fee(amount_msat, 0, max_prop_fee)
},
)
})
.unwrap_or(0)
},
_ => 0,
if counterparty_skimmed_fee_msat > 0 {
let max_total_opening_fee_msat = match &purpose {
PaymentPurpose::Bolt11InvoicePayment { .. } => onion_fields
.as_ref()
.and_then(|fields| fields.payment_metadata.as_ref())
.and_then(|metadata| {
Self::lsps2_max_total_opening_fee_msat(metadata, amount_msat)
}),
_ => None,
};

let Some(max_total_opening_fee_msat) = max_total_opening_fee_msat else {
log_info!(
self.logger,
"Refusing inbound payment with hash {} as the counterparty withheld {}msat without valid BOLT11 LSPS2 payment metadata",
hex_utils::to_string(&payment_hash.0),
counterparty_skimmed_fee_msat,
);
self.fail_claimable_payment(payment_id, &payment_hash)?;
return Ok(());
};

if counterparty_skimmed_fee_msat > max_total_opening_fee_msat {
Expand All @@ -785,26 +821,13 @@ where
counterparty_skimmed_fee_msat,
max_total_opening_fee_msat,
);
self.channel_manager.fail_htlc_backwards(&payment_hash);

let update = PaymentDetailsUpdate {
hash: Some(Some(payment_hash)),
status: Some(PaymentStatus::Failed),
..PaymentDetailsUpdate::new(payment_id)
};
match self.payment_store.update(update) {
Ok(_) => return Ok(()),
Err(e) => {
log_error!(self.logger, "Failed to access payment store: {}", e);
return Err(ReplayEvent());
},
};
self.fail_claimable_payment(payment_id, &payment_hash)?;
return Ok(());
}

// If the LSP skimmed anything, update our stored payment.
if counterparty_skimmed_fee_msat > 0 {
match info.kind {
PaymentKind::Bolt11Jit { .. } => {
if let Some(info) = payment_info.as_ref() {
match &info.kind {
PaymentKind::Bolt11 { .. } => {
let update = PaymentDetailsUpdate {
counterparty_skimmed_fee_msat: Some(Some(counterparty_skimmed_fee_msat)),
..PaymentDetailsUpdate::new(payment_id)
Expand All @@ -817,16 +840,17 @@ where
},
};
}
_ => debug_assert!(false, "We only expect the counterparty to get away with withholding fees for JIT payments."),
_ => debug_assert!(false, "We only expect the counterparty to get away with withholding fees for BOLT11 payments."),
}
}
}

if let Some(info) = payment_info {
// If this is known by the store but ChannelManager doesn't know the preimage,
// the payment has been registered via `_for_hash` variants and needs to be manually claimed via
// user interaction.
match info.kind {
PaymentKind::Bolt11 { preimage, .. }
| PaymentKind::Bolt11Jit { preimage, .. } => {
PaymentKind::Bolt11 { preimage, .. } => {
if purpose.preimage().is_none() {
debug_assert!(
preimage.is_none(),
Expand Down Expand Up @@ -1897,8 +1921,44 @@ mod tests {

use super::*;
use crate::io::test_utils::InMemoryStore;
use crate::payment::store::LSPS2Parameters;
use crate::types::DynStoreWrapper;

#[test]
fn lsps2_payment_metadata_decodes_total_fee_limit() {
let metadata = Bolt11PaymentMetadata {
lsps2_parameters: Some(LSPS2Parameters {
max_total_opening_fee_msat: Some(42_000),
max_proportional_opening_fee_ppm_msat: None,
}),
};

assert_eq!(
EventHandler::<Arc<TestLogger>>::lsps2_max_total_opening_fee_msat(
&metadata.encode(),
100_000
),
Some(42_000)
);
}

#[test]
fn lsps2_payment_metadata_missing_or_malformed_limit_is_rejected() {
let empty_metadata = Bolt11PaymentMetadata { lsps2_parameters: None }.encode();

assert_eq!(
EventHandler::<Arc<TestLogger>>::lsps2_max_total_opening_fee_msat(
&empty_metadata,
100_000
),
None
);
assert_eq!(
EventHandler::<Arc<TestLogger>>::lsps2_max_total_opening_fee_msat(&[0xff], 100_000),
None
);
}

#[tokio::test]
async fn event_queue_persistence() {
let store: Arc<DynStore> = Arc::new(DynStoreWrapper(InMemoryStore::new()));
Expand Down
29 changes: 23 additions & 6 deletions src/liquidity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ use lightning::ln::msgs::SocketAddress;
use lightning::ln::types::ChannelId;
use lightning::routing::router::{RouteHint, RouteHintHop};
use lightning::sign::EntropySource;
use lightning::util::ser::Writeable;
use lightning_invoice::{Bolt11Invoice, Bolt11InvoiceDescription, InvoiceBuilder, RoutingFees};
use lightning_liquidity::events::LiquidityEvent;
use lightning_liquidity::lsps0::ser::{LSPSDateTime, LSPSRequestId};
Expand All @@ -41,6 +42,8 @@ use tokio::sync::oneshot;
use crate::builder::BuildError;
use crate::connection::ConnectionManager;
use crate::logger::{log_debug, log_error, log_info, LdkLogger, Logger};
use crate::payment::store::LSPS2Parameters;
use crate::payment::Bolt11PaymentMetadata;
use crate::runtime::Runtime;
use crate::types::{
Broadcaster, ChannelManager, DynStore, KeysManager, LiquidityManager, PeerManager, Wallet,
Expand Down Expand Up @@ -1113,7 +1116,7 @@ where
pub(crate) async fn lsps2_receive_to_jit_channel(
&self, amount_msat: u64, description: &Bolt11InvoiceDescription, expiry_secs: u32,
max_total_lsp_fee_limit_msat: Option<u64>, payment_hash: Option<PaymentHash>,
) -> Result<(Bolt11Invoice, u64), Error> {
) -> Result<Bolt11Invoice, Error> {
let fee_response = self.lsps2_request_opening_fee_params().await?;

let (min_total_fee_msat, min_opening_params) = fee_response
Expand Down Expand Up @@ -1159,22 +1162,27 @@ where

let buy_response =
self.lsps2_send_buy_request(Some(amount_msat), min_opening_params).await?;
let lsps2_parameters = LSPS2Parameters {
max_total_opening_fee_msat: Some(min_total_fee_msat),
max_proportional_opening_fee_ppm_msat: None,
};
let invoice = self.lsps2_create_jit_invoice(
buy_response,
Some(amount_msat),
description,
expiry_secs,
payment_hash,
lsps2_parameters,
)?;

log_info!(self.logger, "JIT-channel invoice created: {}", invoice);
Ok((invoice, min_total_fee_msat))
Ok(invoice)
}

pub(crate) async fn lsps2_receive_variable_amount_to_jit_channel(
&self, description: &Bolt11InvoiceDescription, expiry_secs: u32,
max_proportional_lsp_fee_limit_ppm_msat: Option<u64>, payment_hash: Option<PaymentHash>,
) -> Result<(Bolt11Invoice, u64), Error> {
) -> Result<Bolt11Invoice, Error> {
let fee_response = self.lsps2_request_opening_fee_params().await?;

let (min_prop_fee_ppm_msat, min_opening_params) = fee_response
Expand Down Expand Up @@ -1207,16 +1215,21 @@ where
);

let buy_response = self.lsps2_send_buy_request(None, min_opening_params).await?;
let lsps2_parameters = LSPS2Parameters {
max_total_opening_fee_msat: None,
max_proportional_opening_fee_ppm_msat: Some(min_prop_fee_ppm_msat),
};
let invoice = self.lsps2_create_jit_invoice(
buy_response,
None,
description,
expiry_secs,
payment_hash,
lsps2_parameters,
)?;

log_info!(self.logger, "JIT-channel invoice created: {}", invoice);
Ok((invoice, min_prop_fee_ppm_msat))
Ok(invoice)
}

async fn lsps2_request_opening_fee_params(&self) -> Result<LSPS2FeeResponse, Error> {
Expand Down Expand Up @@ -1298,7 +1311,7 @@ where
fn lsps2_create_jit_invoice(
&self, buy_response: LSPS2BuyResponse, amount_msat: Option<u64>,
description: &Bolt11InvoiceDescription, expiry_secs: u32,
payment_hash: Option<PaymentHash>,
payment_hash: Option<PaymentHash>, lsps2_parameters: LSPS2Parameters,
) -> Result<Bolt11Invoice, Error> {
let lsps2_client = self.lsps2_client.as_ref().ok_or(Error::LiquiditySourceUnavailable)?;

Expand Down Expand Up @@ -1346,7 +1359,11 @@ where
.current_timestamp()
.min_final_cltv_expiry_delta(min_final_cltv_expiry_delta.into())
.expiry_time(Duration::from_secs(expiry_secs.into()))
.private_route(route_hint);
.private_route(route_hint)
.payment_metadata(
Bolt11PaymentMetadata { lsps2_parameters: Some(lsps2_parameters) }.encode(),
)
.require_payment_metadata();

if let Some(amount_msat) = amount_msat {
invoice_builder = invoice_builder.amount_milli_satoshis(amount_msat).basic_mpp();
Expand Down
Loading
Loading