mirror of
https://github.com/lightninglabs/loop.git
synced 2026-08-13 12:33:03 +02:00
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:
parent
30472d0f28
commit
6d18faafa9
2 changed files with 331 additions and 0 deletions
118
cmd/loop/feecap.go
Normal file
118
cmd/loop/feecap.go
Normal 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
213
cmd/loop/feecap_test.go
Normal 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),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue