fix: limit LSP opening fees for JIT channel invoices (#2535)

* fix: limit LSP opening fees for JIT channel invoices

JIT channel invoices are now created with a maximum LSP opening fee
instead of no limit: the fee the LSP advertises in its LSPS2 opening fee
menu for the payment size, bounded by an absolute ceiling of 5000 sats
or 10% of the payment, whichever is greater. Invoice creation fails if
the LSP quotes a fee above this limit.

The minimum JIT payment size calculation now uses the same ceiling so
the advertised receivable range matches what invoice creation accepts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: validate invoice expiry range and guard LSPS2 cache reads

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Roland 2026-08-12 13:38:13 +07:00 committed by GitHub
parent f4010e239a
commit 979644cf68
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 195 additions and 22 deletions

View file

@ -61,6 +61,7 @@ type LDKService struct {
lsps2InfoFetchedAt time.Time
lsps2MinPaymentSizeMsat *uint64
lsps2MaxPaymentSizeMsat *uint64
lsps2OpeningFeeParamsMenu []ldk_node.Lsps2OpeningFeeParams
shuttingDown bool
eventHandlingMutex sync.Mutex
}
@ -69,6 +70,17 @@ const resetRouterKey = "ResetRouter"
const maxInvoiceExpiry = 24 * time.Hour
const lsps2InfoCacheTTL = 60 * time.Minute
// cached opening fee params must be at most this old when used to derive the
// maximum LSP fee for a new JIT channel invoice
const lsps2FeeCapCacheTTL = 1 * time.Minute
// absolute ceiling on the LSPS2 opening fee accepted for a JIT channel,
// regardless of the fee menu the LSP advertises: the greater of a base amount
// and a percentage of the payment, so small payments can absorb the fixed
// cost of a channel open while larger payments cannot be overcharged.
const lsps2MaxOpeningFeeBaseMsat = 5_000_000
const lsps2MaxOpeningFeePercent = 10
func NewLDKService(ctx context.Context, cfg config.Config, eventPublisher events.EventPublisher, mnemonic, workDir string, vssToken string, setStartupState func(startupState string), channelPeerSuggestions []alby.ChannelPeerSuggestion) (result lnclient.LNClient, err error) {
if mnemonic == "" || workDir == "" {
return nil, errors.New("one or more required LDK configuration are missing")
@ -786,10 +798,10 @@ func (ls *LDKService) getMaxSpendable() uint64 {
return spendable
}
func (ls *LDKService) MakeInvoice(ctx context.Context, amountMsat int64, description string, descriptionHash string, expiry int64, throughNodePubkey *string) (transaction *lnclient.Transaction, err error) {
func (ls *LDKService) MakeInvoice(ctx context.Context, amountMsat int64, description string, descriptionHash string, expirySeconds int64, throughNodePubkey *string) (transaction *lnclient.Transaction, err error) {
if time.Duration(expiry)*time.Second > maxInvoiceExpiry {
return nil, errors.New("expiry is too long")
if expirySeconds < 0 || expirySeconds > int64(maxInvoiceExpiry/time.Second) {
return nil, errors.New("invalid invoice expiry")
}
maxReceivable := ls.getMaxReceivable()
@ -814,8 +826,8 @@ func (ls *LDKService) MakeInvoice(ctx context.Context, amountMsat int64, descrip
})
}
if expiry == 0 {
expiry = lnclient.DEFAULT_INVOICE_EXPIRY
if expirySeconds == 0 {
expirySeconds = lnclient.DEFAULT_INVOICE_EXPIRY
}
var descriptionType ldk_node.Bolt11InvoiceDescription
@ -830,17 +842,19 @@ func (ls *LDKService) MakeInvoice(ctx context.Context, amountMsat int64, descrip
var invoiceObj *ldk_node.Bolt11Invoice
if isJitInvoice {
// cap the opening fee the LSP may deduct from the incoming payment
maxLspFeeLimitMsat := ls.getLsps2MaxTotalOpeningFeeMsat(uint64(amountMsat))
invoiceObj, err = ls.node.Bolt11Payment().ReceiveViaJitChannel(
uint64(amountMsat),
descriptionType,
uint32(expiry),
nil,
uint32(expirySeconds),
&maxLspFeeLimitMsat,
)
} else {
invoiceObj, err = ls.node.Bolt11Payment().Receive(
uint64(amountMsat),
descriptionType,
uint32(expiry),
uint32(expirySeconds),
)
}
@ -2440,9 +2454,9 @@ func (ls *LDKService) ExecuteCustomNodeCommand(ctx context.Context, command *lnc
return nil, lnclient.ErrUnknownCustomNodeCommand
}
func (ls *LDKService) MakeHoldInvoice(ctx context.Context, amountMsat int64, description string, descriptionHash string, expiry int64, paymentHash string, minCltvExpiryDelta *uint64) (*lnclient.Transaction, error) {
if time.Duration(expiry)*time.Second > maxInvoiceExpiry {
return nil, errors.New("expiry is too long")
func (ls *LDKService) MakeHoldInvoice(ctx context.Context, amountMsat int64, description string, descriptionHash string, expirySeconds int64, paymentHash string, minCltvExpiryDelta *uint64) (*lnclient.Transaction, error) {
if expirySeconds < 0 || expirySeconds > int64(maxInvoiceExpiry/time.Second) {
return nil, errors.New("invalid invoice expiry")
}
maxReceivable := ls.getMaxReceivable()
@ -2456,8 +2470,8 @@ func (ls *LDKService) MakeHoldInvoice(ctx context.Context, amountMsat int64, des
})
}
if expiry == 0 {
expiry = lnclient.DEFAULT_INVOICE_EXPIRY
if expirySeconds == 0 {
expirySeconds = lnclient.DEFAULT_INVOICE_EXPIRY
}
var descriptionType ldk_node.Bolt11InvoiceDescription
@ -2491,7 +2505,7 @@ func (ls *LDKService) MakeHoldInvoice(ctx context.Context, amountMsat int64, des
invoiceObj, err = ls.node.Bolt11Payment().ReceiveForHashWithMinCltvExpiryDelta(
uint64(amountMsat),
descriptionType,
uint32(expiry),
uint32(expirySeconds),
ldkPaymentHash,
uint16(*minCltvExpiryDelta),
)
@ -2499,7 +2513,7 @@ func (ls *LDKService) MakeHoldInvoice(ctx context.Context, amountMsat int64, des
invoiceObj, err = ls.node.Bolt11Payment().ReceiveForHash(
uint64(amountMsat),
descriptionType,
uint32(expiry),
uint32(expirySeconds),
ldkPaymentHash,
)
}
@ -2662,16 +2676,36 @@ func (ls *LDKService) GetLiquiditySourceLsps2() string {
}
func (ls *LDKService) GetLiquiditySourceLsps2MinPaymentSizeMsat() *uint64 {
ls.fetchLsps2OpeningFeeParams()
ls.fetchLsps2OpeningFeeParams(lsps2InfoCacheTTL)
ls.lsps2InfoMu.Lock()
defer ls.lsps2InfoMu.Unlock()
return ls.lsps2MinPaymentSizeMsat
}
func (ls *LDKService) GetLiquiditySourceLsps2MaxPaymentSizeMsat() *uint64 {
ls.fetchLsps2OpeningFeeParams()
ls.fetchLsps2OpeningFeeParams(lsps2InfoCacheTTL)
ls.lsps2InfoMu.Lock()
defer ls.lsps2InfoMu.Unlock()
return ls.lsps2MaxPaymentSizeMsat
}
func (ls *LDKService) fetchLsps2OpeningFeeParams() {
// getLsps2MaxTotalOpeningFeeMsat returns the maximum opening fee to accept
// for a JIT channel invoice of the given payment size, derived from the
// LSP's advertised opening fee menu and an absolute ceiling.
func (ls *LDKService) getLsps2MaxTotalOpeningFeeMsat(paymentSizeMsat uint64) uint64 {
ls.fetchLsps2OpeningFeeParams(lsps2FeeCapCacheTTL)
ls.lsps2InfoMu.Lock()
defer ls.lsps2InfoMu.Unlock()
return computeLsps2MaxTotalOpeningFeeMsat(paymentSizeMsat, ls.lsps2OpeningFeeParamsMenu)
}
func (ls *LDKService) fetchLsps2OpeningFeeParams(maxCacheAge time.Duration) {
if ls.lsps2Pubkey == "" || ls.lsps2Address == "" {
return
}
@ -2679,7 +2713,7 @@ func (ls *LDKService) fetchLsps2OpeningFeeParams() {
ls.lsps2InfoMu.Lock()
defer ls.lsps2InfoMu.Unlock()
if !ls.lsps2InfoFetchedAt.IsZero() && time.Since(ls.lsps2InfoFetchedAt) < lsps2InfoCacheTTL {
if !ls.lsps2InfoFetchedAt.IsZero() && time.Since(ls.lsps2InfoFetchedAt) < maxCacheAge {
return
}
@ -2708,11 +2742,46 @@ func (ls *LDKService) fetchLsps2OpeningFeeParams() {
ls.lsps2MinPaymentSizeMsat = minPaymentSizeMsat
ls.lsps2MaxPaymentSizeMsat = maxPaymentSizeMsat
ls.lsps2OpeningFeeParamsMenu = response.OpeningFeeParamsMenu
ls.lsps2InfoFetchedAt = time.Now()
}
// computeLsps2MaxTotalOpeningFeeMsat returns the maximum LSPS2 opening fee to
// accept for a payment of the given size: the highest fee the advertised fee
// menu allows for that size, further limited by the absolute fee ceiling. The
// ceiling alone is used when no menu entry covers the payment size.
func computeLsps2MaxTotalOpeningFeeMsat(paymentSizeMsat uint64, menu []ldk_node.Lsps2OpeningFeeParams) uint64 {
maxAcceptableFeeMsat := lsps2MaxAcceptableOpeningFeeMsat(paymentSizeMsat)
var menuMaxFeeMsat *uint64
for _, params := range menu {
if paymentSizeMsat < params.MinPaymentSizeMsat || paymentSizeMsat > params.MaxPaymentSizeMsat {
continue
}
feeMsat := ldk_node.Lsps2ComputeOpeningFeeMsat(paymentSizeMsat, params)
if feeMsat == nil {
continue
}
if menuMaxFeeMsat == nil || *feeMsat > *menuMaxFeeMsat {
menuMaxFeeMsat = feeMsat
}
}
if menuMaxFeeMsat != nil && *menuMaxFeeMsat < maxAcceptableFeeMsat {
return *menuMaxFeeMsat
}
return maxAcceptableFeeMsat
}
// the absolute ceiling on the LSPS2 opening fee for a payment of the given
// size, independent of the fees the LSP advertises
func lsps2MaxAcceptableOpeningFeeMsat(paymentSizeMsat uint64) uint64 {
return max(lsps2MaxOpeningFeeBaseMsat, paymentSizeMsat/100*lsps2MaxOpeningFeePercent)
}
// finds the smallest incoming payment for which the user is left
// with a usable amount after the LSP skims its LSPS2 opening fee.
// with a usable amount after the LSP skims its LSPS2 opening fee and the fee
// stays within the absolute fee ceiling applied when creating JIT invoices.
func computeLsps2MinPaymentSizeMsat(params ldk_node.Lsps2OpeningFeeParams) (uint64, bool) {
// The smallest amount the user must net after the opening fee. We require a
// whole satoshi rather than a single millisat so the minimum payment size
@ -2728,12 +2797,20 @@ func computeLsps2MinPaymentSizeMsat(params ldk_node.Lsps2OpeningFeeParams) (uint
}
// The incoming amount must exceed the opening fee by at least 1 sat,
// otherwise the user receives a sub-satoshi (effectively zero) amount
// after the LSP skims its fee.
if *openingFeeMsat+minNetReceiveMsat <= paymentSizeMsat {
// after the LSP skims its fee. The fee must also stay within the
// absolute fee ceiling, otherwise invoices of this size are rejected.
if *openingFeeMsat+minNetReceiveMsat <= paymentSizeMsat &&
*openingFeeMsat <= lsps2MaxAcceptableOpeningFeeMsat(paymentSizeMsat) {
return paymentSizeMsat, paymentSizeMsat <= params.MaxPaymentSizeMsat
}
nextPaymentSizeMsat := *openingFeeMsat + minNetReceiveMsat
if *openingFeeMsat > lsps2MaxOpeningFeeBaseMsat {
// the smallest payment size at which a fee this large stays within
// the percentage part of the ceiling
minSizeForFeeMsat := (*openingFeeMsat + lsps2MaxOpeningFeePercent - 1) / lsps2MaxOpeningFeePercent * 100
nextPaymentSizeMsat = max(nextPaymentSizeMsat, minSizeForFeeMsat)
}
if nextPaymentSizeMsat <= paymentSizeMsat || nextPaymentSizeMsat > params.MaxPaymentSizeMsat {
return 0, false
}

View file

@ -3,6 +3,7 @@ package ldk
import (
"testing"
"github.com/getAlby/ldk-node-go/ldk_node"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -36,6 +37,101 @@ func TestGetVssNodeIdentifier2(t *testing.T) {
assert.Equal(t, expectedVssNodeIdentifier, vssNodeIdentifier)
}
func makeLsps2OpeningFeeParams(minFeeMsat uint64, proportional uint32, minPaymentSizeMsat uint64, maxPaymentSizeMsat uint64) ldk_node.Lsps2OpeningFeeParams {
return ldk_node.Lsps2OpeningFeeParams{
MinFeeMsat: minFeeMsat,
Proportional: proportional,
ValidUntil: "2035-01-01T00:00:00Z",
MinLifetime: 4032,
MaxClientToSelfDelay: 2016,
MinPaymentSizeMsat: minPaymentSizeMsat,
MaxPaymentSizeMsat: maxPaymentSizeMsat,
Promise: "promise",
}
}
func TestComputeLsps2MaxTotalOpeningFeeMsat(t *testing.T) {
t.Run("proportional fee above minimum fee", func(t *testing.T) {
menu := []ldk_node.Lsps2OpeningFeeParams{
// 0.5% of 10M msat = 50k msat > 10k msat minimum
makeLsps2OpeningFeeParams(10_000, 5_000, 1_000_000, 100_000_000),
}
assert.Equal(t, uint64(50_000), computeLsps2MaxTotalOpeningFeeMsat(10_000_000, menu))
})
t.Run("minimum fee above proportional fee", func(t *testing.T) {
menu := []ldk_node.Lsps2OpeningFeeParams{
// 0.5% of 1M msat = 5k msat < 10k msat minimum
makeLsps2OpeningFeeParams(10_000, 5_000, 1_000_000, 100_000_000),
}
assert.Equal(t, uint64(10_000), computeLsps2MaxTotalOpeningFeeMsat(1_000_000, menu))
})
t.Run("highest fee across menu entries", func(t *testing.T) {
menu := []ldk_node.Lsps2OpeningFeeParams{
makeLsps2OpeningFeeParams(10_000, 5_000, 1_000_000, 100_000_000),
makeLsps2OpeningFeeParams(10_000, 20_000, 1_000_000, 100_000_000),
}
assert.Equal(t, uint64(200_000), computeLsps2MaxTotalOpeningFeeMsat(10_000_000, menu))
})
t.Run("entries not covering the payment size are skipped", func(t *testing.T) {
menu := []ldk_node.Lsps2OpeningFeeParams{
makeLsps2OpeningFeeParams(10_000, 5_000, 1_000_000, 100_000_000),
// covers larger payments only, would otherwise win with 2%
makeLsps2OpeningFeeParams(10_000, 20_000, 20_000_000, 100_000_000),
}
assert.Equal(t, uint64(50_000), computeLsps2MaxTotalOpeningFeeMsat(10_000_000, menu))
})
t.Run("menu fee above ceiling is clamped to ceiling", func(t *testing.T) {
menu := []ldk_node.Lsps2OpeningFeeParams{
// 20% of 100M msat = 20M msat, above the 10% / 10M msat ceiling
makeLsps2OpeningFeeParams(5_000_000, 200_000, 1_000_000, 1_000_000_000),
}
assert.Equal(t, uint64(10_000_000), computeLsps2MaxTotalOpeningFeeMsat(100_000_000, menu))
})
t.Run("base ceiling applies when no entry covers the payment size", func(t *testing.T) {
menu := []ldk_node.Lsps2OpeningFeeParams{
makeLsps2OpeningFeeParams(10_000, 5_000, 1_000_000, 100_000_000),
}
// 10% of 200M msat = 20M msat
assert.Equal(t, uint64(20_000_000), computeLsps2MaxTotalOpeningFeeMsat(200_000_000, menu))
})
t.Run("base ceiling applies on empty menu", func(t *testing.T) {
assert.Equal(t, uint64(5_000_000), computeLsps2MaxTotalOpeningFeeMsat(10_000_000, nil))
})
}
func TestComputeLsps2MinPaymentSizeMsat(t *testing.T) {
t.Run("minimum fee below ceiling base", func(t *testing.T) {
// 1000 sat minimum fee: smallest usable payment nets 1 sat above the fee
params := makeLsps2OpeningFeeParams(1_000_000, 10_000, 1_000, 100_000_000_000)
minPaymentSizeMsat, ok := computeLsps2MinPaymentSizeMsat(params)
require.True(t, ok)
assert.Equal(t, uint64(1_001_000), minPaymentSizeMsat)
})
t.Run("minimum fee above ceiling base", func(t *testing.T) {
// 8000 sat minimum fee exceeds the 5000 sat ceiling base, so the
// smallest payment is where the fee equals 10% of the payment
params := makeLsps2OpeningFeeParams(8_000_000, 10_000, 1_000, 100_000_000_000)
minPaymentSizeMsat, ok := computeLsps2MinPaymentSizeMsat(params)
require.True(t, ok)
assert.Equal(t, uint64(80_000_000), minPaymentSizeMsat)
})
t.Run("proportional fee above ceiling percentage never fits", func(t *testing.T) {
// 30% proportional fee with a minimum fee above the ceiling base can
// never satisfy the 10% ceiling
params := makeLsps2OpeningFeeParams(6_000_000, 300_000, 1_000, 1_000_000_000)
_, ok := computeLsps2MinPaymentSizeMsat(params)
assert.False(t, ok)
})
}
func TestSanitizeChainEndpointForBitcoind(t *testing.T) {
tests := []struct {
name string