mirror of
https://github.com/lightninglabs/loop.git
synced 2026-08-13 12:33:03 +02:00
Merge pull request #1114 from starius/max-fee-rate
cmd/loop: add max swap fee flags for static in
This commit is contained in:
commit
0aab352991
5 changed files with 382 additions and 16 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),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -453,6 +453,22 @@ var staticAddressLoopInCommand = &cli.Command{
|
|||
"higher fee, so the change output is " +
|
||||
"available sooner",
|
||||
},
|
||||
&cli.Uint64Flag{
|
||||
Name: "max_swap_fee_sat",
|
||||
Usage: fmt.Sprintf("the maximum swap fee in satoshis. "+
|
||||
"If set, the swap is rejected when the quoted "+
|
||||
"fee exceeds this cap. The maximum allowed "+
|
||||
"value is %d. On-chain fees for creating "+
|
||||
"static deposits are unaffected.",
|
||||
maxSwapFeeSatLimit),
|
||||
},
|
||||
&cli.Uint64Flag{
|
||||
Name: "max_swap_fee_ppm",
|
||||
Usage: "the maximum swap fee expressed in " +
|
||||
"parts per million of the swap amount. " +
|
||||
"If set together with --max_swap_fee_sat " +
|
||||
"the tighter cap is used.",
|
||||
},
|
||||
lastHopFlag,
|
||||
labelFlag,
|
||||
routeHintsFlag,
|
||||
|
|
@ -585,7 +601,18 @@ func staticAddressLoopIn(ctx context.Context, cmd *cli.Command) error {
|
|||
return err
|
||||
}
|
||||
|
||||
limits := getInLimits(quote)
|
||||
// Resolve the effective swap fee cap. When the user provides
|
||||
// --max_swap_fee_sat and/or --max_swap_fee_ppm the tighter of
|
||||
// the two is used and checked against the current quote. Without
|
||||
// overrides the quoted fee is forwarded as before.
|
||||
maxSwapFee, err := resolveMaxSwapFee(
|
||||
quoteReq, quote,
|
||||
cmd.IsSet("max_swap_fee_sat"), cmd.Uint64("max_swap_fee_sat"),
|
||||
cmd.IsSet("max_swap_fee_ppm"), cmd.Uint64("max_swap_fee_ppm"),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !(cmd.Bool("force") || cmd.Bool("f")) {
|
||||
err = displayInDetails(quoteReq, quote, cmd.Bool("verbose"))
|
||||
|
|
@ -601,7 +628,7 @@ func staticAddressLoopIn(ctx context.Context, cmd *cli.Command) error {
|
|||
req := &looprpc.StaticAddressLoopInRequest{
|
||||
Amount: quoteReq.Amt,
|
||||
Outpoints: depositOutpoints,
|
||||
MaxSwapFeeSatoshis: int64(limits.maxSwapFee),
|
||||
MaxSwapFeeSatoshis: int64(maxSwapFee),
|
||||
LastHop: lastHop,
|
||||
Label: label,
|
||||
Initiator: defaultInitiator,
|
||||
|
|
|
|||
|
|
@ -567,6 +567,12 @@ Loop in funds from static address deposits.
|
|||
.PP
|
||||
\fB--last_hop\fP="": the pubkey of the last hop to use for this swap
|
||||
|
||||
.PP
|
||||
\fB--max_swap_fee_ppm\fP="": the maximum swap fee expressed in parts per million of the swap amount. If set together with --max_swap_fee_sat the tighter cap is used. (default: 0)
|
||||
|
||||
.PP
|
||||
\fB--max_swap_fee_sat\fP="": the maximum swap fee in satoshis. If set, the swap is rejected when the quoted fee exceeds this cap. The maximum allowed value is 10000000. On-chain fees for creating static deposits are unaffected. (default: 0)
|
||||
|
||||
.PP
|
||||
\fB--payment_timeout\fP="": the maximum time in seconds that the server is allowed to take for the swap payment. The client can retry the swap with adjusted parameters after the payment timed out. (default: 0s)
|
||||
|
||||
|
|
|
|||
30
docs/loop.md
30
docs/loop.md
|
|
@ -669,20 +669,22 @@ $ loop [GLOBAL FLAGS] static in [COMMAND FLAGS] [amt] [--all | --utxo xxx:xx]
|
|||
|
||||
The following flags are supported:
|
||||
|
||||
| Name | Description | Type | Default value |
|
||||
|--------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|:-------------:|
|
||||
| `--utxo="…"` | specify the utxos of deposits as outpoints(tx:idx) that should be looped in | string | `[]` |
|
||||
| `--all` | loop in all static address deposits | bool | `false` |
|
||||
| `--payment_timeout="…"` | the maximum time in seconds that the server is allowed to take for the swap payment. The client can retry the swap with adjusted parameters after the payment timed out | duration | `0s` |
|
||||
| `--amt="…"` (`--amount`) | the number of satoshis that should be swapped from the selected deposits. If thereis change it is sent back to the static address | uint | `0` |
|
||||
| `--fast` | Usage: complete the swap faster by paying a higher fee, so the change output is available sooner | bool | `false` |
|
||||
| `--last_hop="…"` | the pubkey of the last hop to use for this swap | string |
|
||||
| `--label="…"` | an optional label for this swap,limited to 500 characters. The label may not start with our reserved prefix: [reserved] | string |
|
||||
| `--route_hints="…"` | route hints that can each be individually used to assist in reaching the invoice's destination | string | `[]` |
|
||||
| `--private` | generates and passes routehints. Should be used if the connected node is only reachable via private channels | bool | `false` |
|
||||
| `--force` | Assumes yes during confirmation. Using this option will result in an immediate swap | bool | `false` |
|
||||
| `--verbose` (`-v`) | show expanded details | bool | `false` |
|
||||
| `--help` (`-h`) | show help | bool | `false` |
|
||||
| Name | Description | Type | Default value |
|
||||
|--------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|:-------------:|
|
||||
| `--utxo="…"` | specify the utxos of deposits as outpoints(tx:idx) that should be looped in | string | `[]` |
|
||||
| `--all` | loop in all static address deposits | bool | `false` |
|
||||
| `--payment_timeout="…"` | the maximum time in seconds that the server is allowed to take for the swap payment. The client can retry the swap with adjusted parameters after the payment timed out | duration | `0s` |
|
||||
| `--amt="…"` (`--amount`) | the number of satoshis that should be swapped from the selected deposits. If thereis change it is sent back to the static address | uint | `0` |
|
||||
| `--fast` | Usage: complete the swap faster by paying a higher fee, so the change output is available sooner | bool | `false` |
|
||||
| `--max_swap_fee_sat="…"` | the maximum swap fee in satoshis. If set, the swap is rejected when the quoted fee exceeds this cap. The maximum allowed value is 10000000. On-chain fees for creating static deposits are unaffected | uint | `0` |
|
||||
| `--max_swap_fee_ppm="…"` | the maximum swap fee expressed in parts per million of the swap amount. If set together with --max_swap_fee_sat the tighter cap is used | uint | `0` |
|
||||
| `--last_hop="…"` | the pubkey of the last hop to use for this swap | string |
|
||||
| `--label="…"` | an optional label for this swap,limited to 500 characters. The label may not start with our reserved prefix: [reserved] | string |
|
||||
| `--route_hints="…"` | route hints that can each be individually used to assist in reaching the invoice's destination | string | `[]` |
|
||||
| `--private` | generates and passes routehints. Should be used if the connected node is only reachable via private channels | bool | `false` |
|
||||
| `--force` | Assumes yes during confirmation. Using this option will result in an immediate swap | bool | `false` |
|
||||
| `--verbose` (`-v`) | show expanded details | bool | `false` |
|
||||
| `--help` (`-h`) | show help | bool | `false` |
|
||||
|
||||
### `static openchannel` subcommand
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue