mirror of
https://github.com/getAlby/hub.git
synced 2026-08-13 12:33:39 +02:00
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:
parent
5f9a88843c
commit
474076e462
2 changed files with 173 additions and 8 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue