multi: factor out sweep fee clamping function

This commit is contained in:
Boris Nagaev 2026-01-14 18:41:56 -05:00
parent 9ee7a36f37
commit 0b53296aa8
No known key found for this signature in database
6 changed files with 153 additions and 40 deletions

View file

@ -317,9 +317,16 @@ func (f *FSM) BuildHTLCAction(ctx context.Context,
return f.handleErrorAndUnlockReservations(ctx, err)
}
minRelayFee, err := f.cfg.Wallet.MinRelayFee(ctx)
if err != nil {
return f.handleErrorAndUnlockReservations(ctx, err)
}
// Now that our nonces are set, we can create and sign the htlc
// transaction.
htlcTx, err := f.InstantOut.createHtlcTransaction(f.cfg.Network)
htlcTx, err := f.InstantOut.createHtlcTransaction(
f.cfg.Network, minRelayFee,
)
if err != nil {
return f.handleErrorAndUnlockReservations(ctx, err)
}
@ -382,6 +389,11 @@ func (f *FSM) PushPreimageAction(ctx context.Context,
return f.handleErrorAndUnlockReservations(ctx, err)
}
minRelayFee, err := f.cfg.Wallet.MinRelayFee(ctx)
if err != nil {
return f.handleErrorAndUnlockReservations(ctx, err)
}
pushPreImageRes, err := f.cfg.InstantOutClient.PushPreimage(
ctx,
&swapserverrpc.PushPreimageRequest{
@ -400,7 +412,9 @@ func (f *FSM) PushPreimageAction(ctx context.Context,
// Now that we have the sweepless sweep signatures we can build and
// publish the sweepless sweep transaction.
sweepTx, err := f.InstantOut.createSweeplessSweepTx(feeRate)
sweepTx, err := f.InstantOut.createSweeplessSweepTx(
feeRate, minRelayFee,
)
if err != nil {
f.LastActionError = err
return OnErrorPublishHtlc

View file

@ -18,6 +18,7 @@ import (
"github.com/lightninglabs/loop/instantout/reservation"
"github.com/lightninglabs/loop/loopdb"
"github.com/lightninglabs/loop/swap"
"github.com/lightninglabs/loop/utils"
"github.com/lightningnetwork/lnd/input"
"github.com/lightningnetwork/lnd/keychain"
"github.com/lightningnetwork/lnd/lntypes"
@ -145,8 +146,8 @@ func (i *InstantOut) getInputReservations() (InputReservations, error) {
}
// createHtlcTransaction creates the htlc transaction for the instant out.
func (i *InstantOut) createHtlcTransaction(network *chaincfg.Params) (
*wire.MsgTx, error) {
func (i *InstantOut) createHtlcTransaction(network *chaincfg.Params,
minRelayFeeRate chainfee.SatPerKWeight) (*wire.MsgTx, error) {
if network == nil {
return nil, errors.New("no network provided")
@ -170,7 +171,16 @@ func (i *InstantOut) createHtlcTransaction(network *chaincfg.Params) (
// Estimate the fee
weight := htlcWeight(len(inputReservations))
fee := i.htlcFeeRate.FeeForWeight(weight)
if fee > i.Value/5 {
// We cap the fee at 20% of the deposit value.
_, clamped, err := utils.ClampSweepFee(
fee, i.Value, utils.MaxFeeToAmountRatio, minRelayFeeRate,
weight,
)
if err != nil {
return nil, err
}
if clamped {
return nil, errors.New("fee is higher than 20% of " +
"sweep value")
}
@ -193,8 +203,8 @@ func (i *InstantOut) createHtlcTransaction(network *chaincfg.Params) (
// createSweeplessSweepTx creates the sweepless sweep transaction for the
// instant out.
func (i *InstantOut) createSweeplessSweepTx(feerate chainfee.SatPerKWeight) (
*wire.MsgTx, error) {
func (i *InstantOut) createSweeplessSweepTx(feerate,
minRelayFeeRate chainfee.SatPerKWeight) (*wire.MsgTx, error) {
inputReservations, err := i.getInputReservations()
if err != nil {
@ -214,7 +224,14 @@ func (i *InstantOut) createSweeplessSweepTx(feerate chainfee.SatPerKWeight) (
// Estimate the fee
weight := sweeplessSweepWeight(len(inputReservations))
fee := feerate.FeeForWeight(weight)
if fee > i.Value/5 {
_, clamped, err := utils.ClampSweepFee(
fee, i.Value, utils.MaxFeeToAmountRatio, minRelayFeeRate,
weight,
)
if err != nil {
return nil, err
}
if clamped {
return nil, errors.New("fee is higher than 20% of " +
"sweep value")
}

View file

@ -11,6 +11,7 @@ import (
"github.com/lightninglabs/lndclient"
"github.com/lightninglabs/loop/fsm"
"github.com/lightninglabs/loop/staticaddr/script"
"github.com/lightninglabs/loop/utils"
"github.com/lightningnetwork/lnd/lntypes"
)
@ -47,12 +48,25 @@ func (f *FSM) PublishDepositExpirySweepAction(ctx context.Context,
"estimation failed: %w", err))
}
minRelayFeeRate, err := f.cfg.WalletKit.MinRelayFee(ctx)
if err != nil {
return f.HandleError(fmt.Errorf("timeout sweep min relay "+
"query failed: %w", err))
}
weight := script.ExpirySpendWeight()
fee := feeRateEstimator.FeeForWeight(lntypes.WeightUnit(weight))
// We cap the fee at 20% of the deposit value.
if fee > f.deposit.Value/5 {
_, clamped, err := utils.ClampSweepFee(
fee, f.deposit.Value, utils.MaxFeeToAmountRatio,
minRelayFeeRate, lntypes.WeightUnit(weight),
)
if err != nil {
return f.HandleError(err)
}
if clamped {
return f.HandleError(errors.New("fee is greater than 20% of " +
"the deposit value"))
}

View file

@ -46,10 +46,6 @@ const (
// transaction.
batchConfHeight = 3
// maxFeeToSwapAmtRatio is the maximum fee to swap amount ratio that
// we allow for a batch transaction.
maxFeeToSwapAmtRatio = 0.2
// MaxSweepsPerBatch is the maximum number of sweeps in a single batch.
// It is needed to prevent sweep tx from becoming non-standard. Max
// standard transaction is 400k wu, a non-cooperative input is 393 wu.
@ -1414,9 +1410,9 @@ func constructUnsignedTx(sweeps []sweep, address btcutil.Address,
}
// Clamp the calculated fee to the max allowed fee amount for the batch.
fee, err := clampBatchFee(
fee, _, err := utils.ClampSweepFee(
feeForWeight, batchAmt-btcutil.Amount(sumChange),
minRelayFeeRate, weight,
utils.MaxFeeToAmountRatio, minRelayFeeRate, weight,
)
if err != nil {
return nil, 0, 0, 0, fmt.Errorf("failed to clamp batch "+
@ -2644,28 +2640,3 @@ func (b *batch) persistConfirmedBatch(ctx context.Context,
return b.store.ConfirmBatchWithSweeps(ctx, b.dbBatch(), sweeps)
}
// clampBatchFee takes the fee amount and total amount of the sweeps in the
// batch and makes sure the fee is not too high. If the fee is too high, it is
// clamped to the maximum allowed fee. If the clamped fee results in a fee rate
// below the minimum relay fee, an error is returned.
func clampBatchFee(fee btcutil.Amount, totalAmount btcutil.Amount,
minRelayFeeRate chainfee.SatPerKWeight,
weight lntypes.WeightUnit) (btcutil.Amount, error) {
maxFeeAmount := btcutil.Amount(float64(totalAmount) *
maxFeeToSwapAmtRatio)
clampedFee := fee
if fee > maxFeeAmount {
clampedFee = maxFeeAmount
}
clampedFeeRate := chainfee.NewSatPerKWeight(clampedFee, weight)
if clampedFeeRate < minRelayFeeRate {
return 0, fmt.Errorf("clamped fee rate %v is less than "+
"minimum relay fee %v", clampedFeeRate, minRelayFeeRate)
}
return clampedFee, nil
}

44
utils/fees.go Normal file
View file

@ -0,0 +1,44 @@
package utils
import (
"fmt"
"github.com/btcsuite/btcd/btcutil"
"github.com/lightningnetwork/lnd/lntypes"
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
)
const (
// MaxFeeToAmountRatio is the maximum fee to total amount ratio allowed
// for a sweep transaction.
MaxFeeToAmountRatio = 0.2
)
// ClampSweepFee caps a fee to a percentage of the provided total amount and
// verifies the resulting fee rate is not below the minimum relay fee. It
// returns the clamped fee, whether it was clamped, or an error if the clamped
// fee would fall below the minimum relay fee.
func ClampSweepFee(fee btcutil.Amount, totalAmount btcutil.Amount,
ratio float64, minRelayFeeRate chainfee.SatPerKWeight,
weight lntypes.WeightUnit) (btcutil.Amount, bool, error) {
maxFeeAmount := btcutil.Amount(float64(totalAmount) * ratio)
clampedFee := fee
clamped := false
if fee > maxFeeAmount {
clampedFee = maxFeeAmount
clamped = true
}
if minRelayFeeRate > 0 {
clampedFeeRate := chainfee.NewSatPerKWeight(clampedFee, weight)
if clampedFeeRate < minRelayFeeRate {
return 0, clamped, fmt.Errorf("clamped fee rate %v is "+
"less than minimum relay fee %v",
clampedFeeRate, minRelayFeeRate)
}
}
return clampedFee, clamped, nil
}

53
utils/fees_test.go Normal file
View file

@ -0,0 +1,53 @@
package utils
import (
"testing"
"github.com/btcsuite/btcd/btcutil"
"github.com/lightningnetwork/lnd/lntypes"
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
"github.com/stretchr/testify/require"
)
// TestClampSweepFee verifies clamping still respects min relay fee
// requirements and reports clamping.
func TestClampSweepFee(t *testing.T) {
weight := lntypes.WeightUnit(400)
minRelay := chainfee.SatPerKWeight(253)
total := btcutil.Amount(100_000)
// Fee below the clamp threshold and above min relay should pass
// through.
fee := chainfee.SatPerKWeight(1_000).FeeForWeight(weight)
clamped, clampedFlag, err := ClampSweepFee(
fee, total, MaxFeeToAmountRatio, minRelay, weight,
)
require.NoError(t, err)
require.Equal(t, fee, clamped)
require.False(t, clampedFlag)
// A clamped fee that would fall below min relay should error.
// The fee will be clamped to 20 sats.
fee = btcutil.Amount(10_000)
total = btcutil.Amount(100)
_, clampedFlag, err = ClampSweepFee(
fee, total, MaxFeeToAmountRatio, minRelay, weight,
)
require.True(t, clampedFlag)
require.Error(t, err)
// A fee above the clamp threshold should be clamped without error when
// still above min relay. The fee is 30% of total, will clamp to 20%.
fee = btcutil.Amount(30_000)
total = btcutil.Amount(100_000)
clamped, clampedFlag, err = ClampSweepFee(
fee, total, MaxFeeToAmountRatio, minRelay, weight,
)
require.NoError(t, err)
require.True(t, clampedFlag)
expectedClampedFee := btcutil.Amount(
float64(total) * MaxFeeToAmountRatio,
)
require.Equal(t, expectedClampedFee, clamped)
}