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>
This commit is contained in:
Roland Bewick 2026-08-11 16:45:46 +07:00
parent 5f9a88843c
commit 474076e462
2 changed files with 173 additions and 8 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")
@ -830,11 +842,13 @@ 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,
&maxLspFeeLimitMsat,
)
} else {
invoiceObj, err = ls.node.Bolt11Payment().Receive(
@ -2662,16 +2676,28 @@ func (ls *LDKService) GetLiquiditySourceLsps2() string {
}
func (ls *LDKService) GetLiquiditySourceLsps2MinPaymentSizeMsat() *uint64 {
ls.fetchLsps2OpeningFeeParams()
ls.fetchLsps2OpeningFeeParams(lsps2InfoCacheTTL)
return ls.lsps2MinPaymentSizeMsat
}
func (ls *LDKService) GetLiquiditySourceLsps2MaxPaymentSizeMsat() *uint64 {
ls.fetchLsps2OpeningFeeParams()
ls.fetchLsps2OpeningFeeParams(lsps2InfoCacheTTL)
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 +2705,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 +2734,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 +2789,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