loop/cmd/loop/feecap.go
Boris Nagaev 6d18faafa9
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.
2026-04-06 23:06:55 -05:00

118 lines
3.5 KiB
Go

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