Skip to content

Add Configurable Fee Functions to Sweeper with Dynamic Selection #9701

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
4 changes: 4 additions & 0 deletions itest/list_on_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ var allTestCases = []*lntest.TestCase{
Name: "basic funding flow with all input types",
TestFunc: testChannelFundingInputTypes,
},
{
Name: "bump_fee_until_max_reached",
TestFunc: testBumpFeeUntilMaxReached,
},
{
Name: "unconfirmed channel funding",
TestFunc: testUnconfirmedChannelFunding,
Expand Down
112 changes: 112 additions & 0 deletions itest/lnd_fee_bump.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import (
"bytes"
"strings"

"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/stretchr/testify/require"

"github.com/lightningnetwork/lnd/lnrpc"
"github.com/lightningnetwork/lnd/lnrpc/walletrpc"
"github.com/lightningnetwork/lnd/lntest"
)

// testBumpFeeUntilMaxReached tests that the sweeper stops bumping the fee when
// the maximum fee rate is reached for a pending sweep.
func testBumpFeeUntilMaxReached(ht *lntest.HarnessTest) {
// Set maxFeeRate to 100 sats/vbyte for this test to reduce iterations.
maxFeeRate := uint64(100)

// Start Alice with a custom max fee rate.
alice := ht.NewNode("Alice", []string{"--sweeper.maxfeerate=100"})

// Fund Alice with 1 BTC to ensure sufficient funds.
ht.FundCoins(btcutil.SatoshiPerBitcoin, alice)

// Create a transaction sending 100,000 sats to an address, generating a change output.
addr := alice.RPC.NewAddress(&lnrpc.NewAddressRequest{
Type: lnrpc.AddressType_WITNESS_PUBKEY_HASH,
})
sendReq := &lnrpc.SendCoinsRequest{
Addr: addr.Address,
Amount: 100000,
TargetConf: 1,
}
txid := alice.RPC.SendCoins(sendReq).Txid

// Mine a block to confirm the transaction.
ht.MineBlocks(1)

// Get the UTXOs and find the change output from the transaction.
utxos := alice.RPC.ListUnspent(&lnrpc.ListUnspentRequest{})
require.Greater(ht, len(utxos.Utxos), 0, "no UTXOs found")

txHash, _ := chainhash.NewHashFromStr(txid)
var out *lnrpc.Utxo
for _, u := range utxos.Utxos {
if bytes.Equal(u.TxidBytes, txHash[:]) {
out = u
break
}
}
require.NotNil(ht, out, "change UTXO not found")

op := &lnrpc.OutPoint{
TxidBytes: out.TxidBytes,
OutputIndex: out.OutputIndex,
}

// Start sweeping with BumpFee, using a generous budget and short deadline.
bumpFeeReq := &walletrpc.BumpFeeRequest{
Outpoint: op,
Immediate: true,
DeadlineDelta: 5, // Short deadline to reach max faster
Budget: uint64(out.AmountSat / 2), // 50% of output value
}
alice.RPC.BumpFee(bumpFeeReq)

// Wait for the sweeping transaction to appear in the mempool.
ht.WaitForTxInMempool(1, defaultTimeout)

// Get initial fee rate from PendingSweeps.
sweeps := ht.AssertNumPendingSweeps(alice, 1)
initialFeeRate := sweeps[0].SatPerVbyte

// Loop: Bump fee until maxFeeRate is reached or fee rate stops increasing.
for i := 0; i < 20; i++ { // Cap iterations to prevent infinite loop
ht.Logf("Bump attempt #%d, current fee rate: %d sats/vbyte", i, initialFeeRate)

// Attempt to bump the fee.
_, err := alice.RPC.WalletKit.BumpFee(ht.Context(), bumpFeeReq)
if err != nil {
// Check for max fee rate error (specific error message may vary).
if strings.Contains(err.Error(), "max fee rate exceeded") ||
strings.Contains(err.Error(), "position already at max") {
ht.Logf("Max fee rate reached at attempt #%d", i)
break
}
require.NoError(ht, err, "unexpected bump fee error")
}

// Wait for the new transaction to appear in the mempool.
ht.WaitForTxInMempool(1, defaultTimeout)

// Get updated fee rate from PendingSweeps.
sweeps = ht.AssertNumPendingSweeps(alice, 1)
currentFeeRate := sweeps[0].SatPerVbyte

// Check if fee rate has reached max or stopped increasing.
if currentFeeRate >= maxFeeRate || currentFeeRate <= initialFeeRate {
break
}
initialFeeRate = currentFeeRate
}

// Final validation: Ensure fee rate equals maxFeeRate.
sweeps = ht.AssertNumPendingSweeps(alice, 1)
finalFeeRate := sweeps[0].SatPerVbyte
require.Equal(ht, maxFeeRate, finalFeeRate, "final fee rate did not reach maxFeeRate")

// Clean up by mining the transaction.
ht.MineBlocks(1)
}
48 changes: 39 additions & 9 deletions lncfg/sweeper.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,30 @@ const (
// MaxAllowedFeeRate is the largest fee rate in sat/vb that we allow
// when configuring the MaxFeeRate.
MaxAllowedFeeRate = 10_000

// DefaultBaseFeeRate is the default starting fee rate in sat/vb.
DefaultBaseFeeRate chainfee.SatPerVByte = 1
)

//nolint:ll
// Sweeper holds configuration for the UTXO sweeper.
type Sweeper struct {
BatchWindowDuration time.Duration `long:"batchwindowduration" description:"Duration of the sweep batch window. The sweep is held back during the batch window to allow more inputs to be added and thereby lower the fee per input." hidden:"true"`
MaxFeeRate chainfee.SatPerVByte `long:"maxfeerate" description:"Maximum fee rate in sat/vb that the sweeper is allowed to use when sweeping funds, the fee rate derived from budgets are capped at this value. Setting this value too low can result in transactions not being confirmed in time, causing HTLCs to expire hence potentially losing funds."`
// BatchWindowDuration is the duration of the sweep batch window.
BatchWindowDuration time.Duration `long:"batchwindowduration" description:"Duration of the sweep batch window. The sweep is held back during the batch window to allow more inputs to be added and thereby lower the fee per input." hidden:"true"`

// MaxFeeRate is the maximum fee rate in sat/vb allowed for sweeping.
MaxFeeRate chainfee.SatPerVByte `long:"maxfeerate" description:"Maximum fee rate in sat/vb that the sweeper is allowed to use when sweeping funds, the fee rate derived from budgets are capped at this value. Setting this value too low can result in transactions not being confirmed in time, causing HTLCs to expire hence potentially losing funds."`

// NoDeadlineConfTarget is the confirmation target for non-time-sensitive sweeps.
NoDeadlineConfTarget uint32 `long:"nodeadlineconftarget" description:"The conf target to use when sweeping non-time-sensitive outputs. This is useful for sweeping outputs that are not time-sensitive, and can be swept at a lower fee rate."`

// Budget configures automatic sweep fee estimation.
Budget *contractcourt.BudgetConfig `group:"sweeper.budget" namespace:"budget" long:"budget" description:"An optional config group that's used for the automatic sweep fee estimation. The Budget config gives options to limits ones fee exposure when sweeping unilateral close outputs and the fee rate calculated from budgets is capped at sweeper.maxfeerate. Check the budget config options for more details."`

// FeeFunctionType specifies the fee function type for sweeping.
FeeFunctionType string `long:"feefunctiontype" description:"The type of fee function to use for sweeping: 'linear' (default), 'cubic_delay', or 'cubic_eager'."`

// BaseFeeRate is the starting fee rate in sat/vb for the fee function.
BaseFeeRate chainfee.SatPerVByte `long:"basefeerate" description:"The base fee rate in sat/vb to start the fee function from. Must be at least 1 sat/vb."`
}

// Validate checks the values configured for the sweeper.
Expand All @@ -35,24 +49,38 @@ func (s *Sweeper) Validate() error {
return fmt.Errorf("batchwindowduration must be positive")
}

// We require the max fee rate to be at least 100 sat/vbyte.
if s.MaxFeeRate < MaxFeeRateFloor {
return fmt.Errorf("maxfeerate must be >= 100 sat/vb")
}

// We require the max fee rate to be no greater than 10_000 sat/vbyte.
if s.MaxFeeRate > MaxAllowedFeeRate {
return fmt.Errorf("maxfeerate must be <= 10000 sat/vb")
}

// Make sure the conf target is at least 144 blocks (1 day).
if s.NoDeadlineConfTarget < 144 {
return fmt.Errorf("nodeadlineconftarget must be at least 144")
}

// Validate the budget configuration.
if err := s.Budget.Validate(); err != nil {
return fmt.Errorf("invalid budget config: %w", err)
if s.Budget != nil {
if err := s.Budget.Validate(); err != nil {
return fmt.Errorf("invalid budget config: %w", err)
}
}

validFeeFunctions := map[string]bool{
"linear": true,
"cubic_delay": true,
"cubic_eager": true,
}
if s.FeeFunctionType == "" {
return fmt.Errorf("feefunctiontype must not be empty")
}
if !validFeeFunctions[s.FeeFunctionType] {
return fmt.Errorf("feefunctiontype must be one of: linear, cubic_delay, cubic_eager; got %v", s.FeeFunctionType)
}

if s.BaseFeeRate < 1 {
return fmt.Errorf("basefeerate must be >= 1 sat/vb")
}

return nil
Expand All @@ -64,5 +92,7 @@ func DefaultSweeperConfig() *Sweeper {
MaxFeeRate: sweep.DefaultMaxFeeRate,
NoDeadlineConfTarget: uint32(sweep.DefaultDeadlineDelta),
Budget: contractcourt.DefaultBudgetConfig(),
FeeFunctionType: "linear", // Default fee function.
BaseFeeRate: DefaultBaseFeeRate, // Default base fee rate.
}
}
Loading