diff --git a/lnclient/ldk/ldk.go b/lnclient/ldk/ldk.go index c83a0cd6..02b52cd1 100644 --- a/lnclient/ldk/ldk.go +++ b/lnclient/ldk/ldk.go @@ -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 } diff --git a/lnclient/ldk/ldk_test.go b/lnclient/ldk/ldk_test.go index 9f5788e9..c068b8b6 100644 --- a/lnclient/ldk/ldk_test.go +++ b/lnclient/ldk/ldk_test.go @@ -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