diff --git a/cmd/loop/feecap.go b/cmd/loop/feecap.go new file mode 100644 index 00000000..dfe580b5 --- /dev/null +++ b/cmd/loop/feecap.go @@ -0,0 +1,118 @@ +package main + +import ( + "fmt" + "math/big" + + "github.com/btcsuite/btcd/btcutil" + "github.com/lightninglabs/loop/looprpc" +) + +const ( + // feePPMBase converts ppm fee limits into satoshi portions. + feePPMBase = 1_000_000 + + // maxSwapFeeSatLimit is the largest absolute swap-fee cap accepted by + // the CLI for static loop-ins. + maxSwapFeeSatLimit = uint64(10_000_000) +) + +// resolveMaxSwapFee computes the effective maximum swap fee (in satoshis) for a +// static address loop-in, applying user-supplied caps when present. If both a +// satoshi cap and a ppm cap are provided, the tighter (lower) of the two is +// used. When neither override is set the quoted fee from the server is returned +// unchanged. +// +// The function also performs an early check against the current quote: if the +// server-quoted fee already exceeds the resolved cap the caller receives an +// error so the swap can be rejected before confirmation. +func resolveMaxSwapFee(quoteReq *looprpc.QuoteRequest, + quote *looprpc.InQuoteResponse, + satIsSet bool, maxFeeSat uint64, + ppmIsSet bool, maxFeePpm uint64) (btcutil.Amount, error) { + + // If a flag is used, make sure the value is within a reasonable range. + if satIsSet && maxFeeSat == 0 { + return 0, fmt.Errorf("--max_swap_fee_sat must be positive") + } + if satIsSet && maxFeeSat > maxSwapFeeSatLimit { + return 0, fmt.Errorf("--max_swap_fee_sat must be <= %d", + maxSwapFeeSatLimit) + } + if ppmIsSet && maxFeePpm == 0 { + return 0, fmt.Errorf("--max_swap_fee_ppm must be positive") + } + if ppmIsSet && maxFeePpm > feePPMBase { + return 0, fmt.Errorf("--max_swap_fee_ppm must be <= %d", + feePPMBase) + } + + // When no override is set, fall back to the quoted fee. + if !satIsSet && !ppmIsSet { + return btcutil.Amount(quote.SwapFeeSat), nil + } + + // Determine the effective swap amount. For static loop-ins the user + // may omit the amount, in which case the server derives it from the + // selected deposits and returns it in QuotedAmt. + swapAmt := quoteReq.Amt + if swapAmt == 0 { + swapAmt = quote.QuotedAmt + } + + var ppmCapSat uint64 + + if ppmIsSet { + if swapAmt <= 0 { + return 0, fmt.Errorf("swap amount %d invalid for "+ + "ppm fee cap", swapAmt) + } + + ppmCapSat = ppmCapForSwapAmount(swapAmt, maxFeePpm) + if ppmCapSat == 0 { + return 0, fmt.Errorf("ppm cap rounds to 0 sat for "+ + "swap amount %d; use --max_swap_fee_sat "+ + "instead", swapAmt) + } + } + + // Pick the tighter cap when both are set. + var resolvedSat uint64 + switch { + case satIsSet && ppmIsSet: + resolvedSat = min(maxFeeSat, ppmCapSat) + + case satIsSet: + resolvedSat = maxFeeSat + + // maxFeePpm is bounded to feePPMBase, so the ppm-derived cap + // cannot exceed swapAmt and therefore fits into uint64. + default: + resolvedSat = ppmCapSat + } + + // Reject early if the quote already exceeds the cap. + if quote.SwapFeeSat > int64(resolvedSat) { + return 0, fmt.Errorf("quoted swap fee %d sat exceeds "+ + "maximum allowed %d sat", quote.SwapFeeSat, resolvedSat) + } + + return btcutil.Amount(int64(resolvedSat)), nil +} + +// ppmCapForSwapAmount converts a ppm fee limit to a satoshi cap for the given +// swap amount, rounding down to whole satoshis. Big integers are used +// internally to avoid intermediate multiplication overflow, but the returned +// value always fits into uint64 because callers bound maxFeePpm to +// feePPMBase. +func ppmCapForSwapAmount(swapAmt int64, maxFeePpm uint64) uint64 { + swapAmtBig := new(big.Int).SetInt64(swapAmt) + maxFeePpmBig := new(big.Int).SetUint64(maxFeePpm) + + capSat := new(big.Int).Quo( + new(big.Int).Mul(swapAmtBig, maxFeePpmBig), + big.NewInt(feePPMBase), + ) + + return capSat.Uint64() +} diff --git a/cmd/loop/feecap_test.go b/cmd/loop/feecap_test.go new file mode 100644 index 00000000..0f284c5a --- /dev/null +++ b/cmd/loop/feecap_test.go @@ -0,0 +1,213 @@ +package main + +import ( + "math" + "testing" + + "github.com/btcsuite/btcd/btcutil" + "github.com/lightninglabs/loop/looprpc" + "github.com/stretchr/testify/require" +) + +// TestResolveMaxSwapFee tests the fee cap resolution logic for static address +// loop-ins covering sat-only, ppm-only, combined caps, and edge cases. +func TestResolveMaxSwapFee(t *testing.T) { + tests := []struct { + name string + reqAmt int64 + quotedAmt int64 + quotedFee int64 + satIsSet bool + maxSat uint64 + ppmIsSet bool + maxPpm uint64 + wantFee btcutil.Amount + wantErr string + }{ + { + name: "no override uses quoted fee", + reqAmt: 500_000, + quotedFee: 1_000, + wantFee: 1_000, + }, + { + name: "sat cap above quote forwards cap", + reqAmt: 500_000, + quotedFee: 1_000, + satIsSet: true, + maxSat: 2_000, + wantFee: 2_000, + }, + { + name: "sat cap below quote returns error", + reqAmt: 500_000, + quotedFee: 1_000, + satIsSet: true, + maxSat: 500, + wantErr: "quoted swap fee 1000 sat exceeds maximum " + + "allowed 500 sat", + }, + { + name: "ppm uses QuotedAmt when req amt is 0", + quotedAmt: 1_000_000, + quotedFee: 800, + ppmIsSet: true, + maxPpm: 1_000, + wantFee: 1_000, + }, + { + name: "both flags set picks tighter sat cap", + reqAmt: 1_000_000, + quotedFee: 500, + satIsSet: true, + maxSat: 600, + ppmIsSet: true, + maxPpm: 2_000, // = 2000 sat + wantFee: 600, + }, + { + name: "both flags set picks tighter ppm cap", + reqAmt: 1_000_000, + quotedFee: 500, + satIsSet: true, + maxSat: 5_000, + ppmIsSet: true, + maxPpm: 1_000, // = 1000 sat + wantFee: 1_000, + }, + { + name: "both flags set quote exceeds tighter cap", + reqAmt: 1_000_000, + quotedFee: 700, + satIsSet: true, + maxSat: 5_000, + ppmIsSet: true, + maxPpm: 500, // = 500 sat + wantErr: "quoted swap fee 700 sat exceeds maximum " + + "allowed 500 sat", + }, + { + name: "ppm cap rounds to zero returns error", + reqAmt: 100, + quotedFee: 1, + ppmIsSet: true, + maxPpm: 1, + wantErr: "ppm cap rounds to 0 sat for swap amount " + + "100; use --max_swap_fee_sat instead", + }, + { + name: "explicit zero sat cap rejected", + reqAmt: 500_000, + satIsSet: true, + maxSat: 0, + wantErr: "--max_swap_fee_sat must be positive", + }, + { + name: "explicit zero ppm cap rejected", + reqAmt: 500_000, + ppmIsSet: true, + maxPpm: 0, + wantErr: "--max_swap_fee_ppm must be positive", + }, + { + name: "sat cap above hard limit rejected", + reqAmt: 500_000, + satIsSet: true, + maxSat: 10_000_001, + wantErr: "--max_swap_fee_sat must be <= 10000000", + }, + { + name: "max ppm on huge amount defers to tighter " + + "sat cap", + reqAmt: math.MaxInt64, + quotedFee: 1_000, + satIsSet: true, + maxSat: 2_000, + ppmIsSet: true, + maxPpm: feePPMBase, + wantFee: 2_000, + }, + { + name: "ppm above hard limit rejected", + reqAmt: math.MaxInt64, + ppmIsSet: true, + maxPpm: feePPMBase + 1, + wantErr: "--max_swap_fee_ppm must be <= 1000000", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + quoteReq := &looprpc.QuoteRequest{ + Amt: tc.reqAmt, + } + quote := &looprpc.InQuoteResponse{ + SwapFeeSat: tc.quotedFee, + QuotedAmt: tc.quotedAmt, + } + + got, err := resolveMaxSwapFee( + quoteReq, quote, + tc.satIsSet, tc.maxSat, + tc.ppmIsSet, tc.maxPpm, + ) + if tc.wantErr != "" { + require.ErrorContains(t, err, tc.wantErr) + } else { + require.NoError(t, err) + require.Equal(t, tc.wantFee, got) + } + }) + } +} + +// TestPPMCapForSwapAmount checks that ppm-to-sat conversion rounds down as +// expected and remains correct near the int64 swap-amount limit. +func TestPPMCapForSwapAmount(t *testing.T) { + tests := []struct { + name string + swapAmt int64 + maxFeePpm uint64 + want uint64 + }{ + { + name: "one sat at 100 percent", + swapAmt: 1, + maxFeePpm: feePPMBase, + want: 1, + }, + { + name: "rounds down fractional sat", + swapAmt: 100, + maxFeePpm: 1, + want: 0, + }, + { + name: "simple proportional cap", + swapAmt: 1_000_000, + maxFeePpm: 1_000, + want: 1_000, + }, + { + name: "max int64 at 100 percent", + swapAmt: math.MaxInt64, + maxFeePpm: feePPMBase, + want: uint64(math.MaxInt64), + }, + { + name: "max int64 at one ppm", + swapAmt: math.MaxInt64, + maxFeePpm: 1, + want: uint64(math.MaxInt64) / feePPMBase, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + require.Equal( + t, tc.want, + ppmCapForSwapAmount(tc.swapAmt, tc.maxFeePpm), + ) + }) + } +}