From 6d18faafa9c96cb6f723693bf7c749dff6501e8c Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Mon, 6 Apr 2026 15:54:28 -0500 Subject: [PATCH 1/3] 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. --- cmd/loop/feecap.go | 118 ++++++++++++++++++++++ cmd/loop/feecap_test.go | 213 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 331 insertions(+) create mode 100644 cmd/loop/feecap.go create mode 100644 cmd/loop/feecap_test.go 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), + ) + }) + } +} From 659d8126342f34e62e6c521c807a36519d8040ca Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Mon, 6 Apr 2026 15:56:39 -0500 Subject: [PATCH 2/3] cmd/loop: add max swap fee flags for static in Add --max_swap_fee_sat and --max_swap_fee_ppm flags to `loop static in`. When set, the resolved cap is checked against the current quote before confirmation and sent in MaxSwapFeeSatoshis instead of the quoted fee, giving scripts a budget ceiling without the quote-then-retry workaround. --- cmd/loop/staticaddr.go | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/cmd/loop/staticaddr.go b/cmd/loop/staticaddr.go index 0e0ded7f..fc36597e 100644 --- a/cmd/loop/staticaddr.go +++ b/cmd/loop/staticaddr.go @@ -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, From ad7b92e839361b718210c4f69fc09481e405e17c Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Mon, 6 Apr 2026 16:15:22 -0500 Subject: [PATCH 3/3] docs: regenerate loop CLI reference --- docs/loop.1 | 6 ++++++ docs/loop.md | 30 ++++++++++++++++-------------- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/docs/loop.1 b/docs/loop.1 index 7df39c98..7d767cdf 100644 --- a/docs/loop.1 +++ b/docs/loop.1 @@ -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) diff --git a/docs/loop.md b/docs/loop.md index 7fbb76c8..3a847e96 100644 --- a/docs/loop.md +++ b/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