cmd/loop: add static loop-in fee cap helper

Add resolveMaxSwapFee helper that computes an effective maximum swap fee
from --max_swap_fee_sat and --max_swap_fee_ppm CLI flags. When both are
set the tighter (lower) cap wins. The helper also rejects early if the
server-quoted fee already exceeds the resolved cap.
This commit is contained in:
Boris Nagaev 2026-04-06 15:54:28 -05:00
parent 30472d0f28
commit 6d18faafa9
No known key found for this signature in database
2 changed files with 331 additions and 0 deletions

118
cmd/loop/feecap.go Normal file
View file

@ -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()
}

213
cmd/loop/feecap_test.go Normal file
View file

@ -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),
)
})
}
}