Skip to content

Reject LSPS2 fees above payment amounts - #1172

Open
tnull wants to merge 2 commits into
lightningdevkit:mainfrom
tnull:2026-10-bound-lsps2-opening-fees
Open

tnull wants to merge 2 commits into
lightningdevkit:mainfrom
tnull:2026-10-bound-lsps2-opening-fees

Conversation

@tnull

@tnull tnull commented Oct 16, 2026

Copy link
Copy Markdown
Collaborator

LSPS2 clients could request a fixed-amount invoice whose opening fee exceeded the payment, leaving rejection to the LSP. Variable-amount payments also checked their fee against the net receipt, rejecting otherwise valid payments.

Bound fixed-amount fees by the invoice amount even without an application cap. Check variable-amount fees against the gross payment when it arrives, allowing equality while rejecting excessive fees and arithmetic overflow.

Co-Authored-By: HAL 9000

@tnull tnull added this to the 0.8 milestone Oct 16, 2026
@ldk-reviews-bot

ldk-reviews-bot commented Oct 16, 2026 •

Copy link
Copy Markdown

👋 Thanks for assigning @jkczyz as a reviewer!
I'll wait for their review and will help manage the review process.
Once they submit their review, I'll check if a second reviewer would be helpful.

@tnull
tnull requested a review from jkczyz October 16, 2026 08:29
@tnull tnull mentioned this pull request Oct 16, 2026
}
let max_total_lsp_fee_limit_msat =
max_total_lsp_fee_limit_msat.unwrap_or(amount_msat).min(amount_msat);
if min_total_fee_msat > max_total_lsp_fee_limit_msat {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This allows fee == amount, but the LSP side doesn't: lightning-liquidity rejects a buy request when opening_fee >= payment_size_msat, and only opens the channel if amt_to_forward_msat > 0.

So fee == amount still goes to the LSP and gets rejected there. Since we don't handle BuyRequestFailed, the user gets LiquidityRequestFailed after the 5s timeout. I think that's what the (1_000, None, Err(LiquidityRequestFailed)) test case is hitting.

Should this be >=? Same for .filter(|fee| *fee <= gross_amount_msat) in event.rs.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, we're more liberal what we accept here. No, this is intentional == as in theory it could make sense that the client just wants a channel and is fine if the payment all goes to fees. That said, we have higher HTLC minimum values anyways, so in practice we won't ever see a 0-value HTLC coming from the LSP.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense.

Comment thread src/event.rs Outdated
.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(gross_amount_msat, 0, max_prop_fee)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For variable-amount payments this uses 0 as the minimum fee, but the LSP charges max(min_fee_msat, ceil(gross * ppm / 1e6)). We don't store min_fee_msat in LSPS2Parameters, so when the minimum fee is the bigger one, we reject a correctly charged payment, after the LSP has already opened the channel.

With this test's LSP config (1_000 msat min, 10_000 ppm), any variable payment under 100_000 msat would hit this. It was already there before this PR, so a follow-up is fine too.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, we should finally persist min_fee_msat and use it when checking the opening fees. I believe we did it in another PR already, but that never landed. Will update shortly.

@tnull

tnull commented Oct 16, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased to fix CI.

@tnull
tnull force-pushed the 2026-10-bound-lsps2-opening-fees branch from 21125ab to 23ba291 Compare October 16, 2026 12:34
tnull added 2 commits October 16, 2026 14:38
Variable-amount JIT invoices retained only a proportional fee limit.
When an LSP's minimum fee was larger, the client rejected an otherwise
valid payment after the channel had been opened.

Retain the negotiated minimum in invoice metadata and use it when
validating variable payments. Continue decoding historical payment
records that do not carry a minimum.

Co-Authored-By: HAL 9000
LSPS2 clients could request a fixed-amount invoice whose opening fee
exceeded the payment, leaving rejection to the LSP. Variable-amount
payments also checked their fee against the net receipt, rejecting
otherwise valid payments.

Bound fixed-amount fees by the invoice amount even without an
application cap. Check variable-amount fees against the gross payment
when it arrives, allowing equality while rejecting excessive fees and
arithmetic overflow.

Co-Authored-By: HAL 9000
@tnull
tnull force-pushed the 2026-10-bound-lsps2-opening-fees branch from 23ba291 to bdc6484 Compare October 16, 2026 12:40
Comment thread src/event.rs
})
})
})
.filter(|fee| *fee <= gross_amount_msat)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude:

This filter also applies to max_total_opening_fee_msat, but no test exercises that path; lsps2_payment_metadata_decodes_total_fee_limit only passes a gross amount above the limit. Worth a case with the gross amount below 42_000 returning None.

Comment thread src/event.rs
Comment on lines +2825 to +2828
// Variable-amount metadata with 10,000 ppm (type 2) and a 1,000-msat minimum
// (type 3). Use the wire format so this also exercises readers predating the minimum.
let metadata =
crate::hex_utils::to_vec("1700151402080000000000002710030800000000000003e8").unwrap();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I follow the comment. How is this exercising readers predating the minimum?

Comment on lines +96 to +97
let max_total_lsp_fee_limit_msat =
max_total_lsp_fee_limit_msat.unwrap_or(amount_msat).min(amount_msat);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude:

The receive_via_jit_channel doc for max_total_lsp_fee_limit_msat (bolt11.rs:676-677) still says we use the cheapest offer when it's unset. It should mention the fee is now also bounded by amount_msat, with LiquidityFeeTooHigh returned either way.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants