From b6a298416784e521d719533f977e1e36c7a9c985 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 11 Apr 2024 17:08:36 +0800 Subject: [PATCH] sweep: allow specifying starting fee rate for fee func --- sweep/aggregator.go | 25 +++++++++++++++++-- sweep/fee_bumper.go | 5 ++++ sweep/fee_function.go | 23 +++++++++++------ sweep/fee_function_test.go | 51 ++++++++++++++++++++++++++++++++------ sweep/mock_test.go | 8 ++++++ sweep/sweeper.go | 15 ++++++++--- sweep/sweeper_test.go | 4 +++ sweep/tx_input_set.go | 29 ++++++++++++++++++++++ 8 files changed, 140 insertions(+), 20 deletions(-) diff --git a/sweep/aggregator.go b/sweep/aggregator.go index 72822b26b..7676a52ee 100644 --- a/sweep/aggregator.go +++ b/sweep/aggregator.go @@ -570,6 +570,12 @@ func (b *BudgetAggregator) ClusterInputs(inputs InputsMap, // createInputSet takes a set of inputs which share the same deadline height // and turns them into a list of `InputSet`, each set is then used to create a // sweep transaction. +// +// TODO(yy): by the time we call this method, all the invalid/uneconomical +// inputs have been filtered out, all the inputs have been sorted based on +// their budgets, and we are about to create input sets. The only thing missing +// here is, we need to group the inputs here even further based on whether +// their budgets can cover the starting fee rate used for this input set. func (b *BudgetAggregator) createInputSets(inputs []SweeperInput, deadlineHeight int32) []InputSet { @@ -621,8 +627,10 @@ func (b *BudgetAggregator) createInputSets(inputs []SweeperInput, return sets } -// filterInputs filters out inputs that have a budget below the min relay fee -// or have a required output that's below the dust. +// filterInputs filters out inputs that have, +// - a budget below the min relay fee. +// - a budget below its requested starting fee. +// - a required output that's below the dust. func (b *BudgetAggregator) filterInputs(inputs InputsMap) InputsMap { // Get the current min relay fee for this round. minFeeRate := b.estimator.RelayFeePerKW() @@ -655,6 +663,19 @@ func (b *BudgetAggregator) filterInputs(inputs InputsMap) InputsMap { continue } + // Skip inputs that has cannot cover its starting fees. + startingFeeRate := pi.params.StartingFeeRate.UnwrapOr( + chainfee.SatPerKWeight(0), + ) + startingFee := startingFeeRate.FeeForWeight(int64(size)) + if pi.params.Budget < startingFee { + log.Errorf("Skipped input=%v: has budget=%v, but the "+ + "starting fee requires %v", op, + pi.params.Budget, minFee) + + continue + } + // If the input comes with a required tx out that is below // dust, we won't add it. // diff --git a/sweep/fee_bumper.go b/sweep/fee_bumper.go index 1bccbce2d..0c9ea7e04 100644 --- a/sweep/fee_bumper.go +++ b/sweep/fee_bumper.go @@ -114,6 +114,10 @@ type BumpRequest struct { // MaxFeeRate is the maximum fee rate that can be used for fee bumping. MaxFeeRate chainfee.SatPerKWeight + + // StartingFeeRate is an optional parameter that can be used to specify + // the initial fee rate to use for the fee function. + StartingFeeRate fn.Option[chainfee.SatPerKWeight] } // MaxFeeRateAllowed returns the maximum fee rate allowed for the given @@ -380,6 +384,7 @@ func (t *TxPublisher) initializeFeeFunction( // TODO(yy): return based on differet req.Strategy? return NewLinearFeeFunction( maxFeeRateAllowed, confTarget, t.cfg.Estimator, + req.StartingFeeRate, ) } diff --git a/sweep/fee_function.go b/sweep/fee_function.go index acf6e3d84..1c783304c 100644 --- a/sweep/fee_function.go +++ b/sweep/fee_function.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/btcsuite/btcd/btcutil" + "github.com/lightningnetwork/lnd/fn" "github.com/lightningnetwork/lnd/lnwallet/chainfee" "github.com/lightningnetwork/lnd/lnwire" ) @@ -110,8 +111,10 @@ var _ FeeFunction = (*LinearFeeFunction)(nil) // NewLinearFeeFunction creates a new linear fee function and initializes it // with a starting fee rate which is an estimated value returned from the fee // estimator using the initial conf target. -func NewLinearFeeFunction(maxFeeRate chainfee.SatPerKWeight, confTarget uint32, - estimator chainfee.Estimator) (*LinearFeeFunction, error) { +func NewLinearFeeFunction(maxFeeRate chainfee.SatPerKWeight, + confTarget uint32, estimator chainfee.Estimator, + startingFeeRate fn.Option[chainfee.SatPerKWeight]) ( + *LinearFeeFunction, error) { // If the deadline has already been reached, there's nothing the fee // function can do. In this case, we'll use the max fee rate @@ -130,11 +133,17 @@ func NewLinearFeeFunction(maxFeeRate chainfee.SatPerKWeight, confTarget uint32, estimator: estimator, } - // Estimate the initial fee rate. - // - // NOTE: estimateFeeRate guarantees the returned fee rate is capped by - // the ending fee rate, so we don't need to worry about overpay. - start, err := l.estimateFeeRate(confTarget) + // If the caller specifies the starting fee rate, we'll use it instead + // of estimating it based on the deadline. + start, err := startingFeeRate.UnwrapOrFuncErr( + func() (chainfee.SatPerKWeight, error) { + // Estimate the initial fee rate. + // + // NOTE: estimateFeeRate guarantees the returned fee + // rate is capped by the ending fee rate, so we don't + // need to worry about overpay. + return l.estimateFeeRate(confTarget) + }) if err != nil { return nil, fmt.Errorf("estimate initial fee rate: %w", err) } diff --git a/sweep/fee_function_test.go b/sweep/fee_function_test.go index 461f9e3bb..3b0832946 100644 --- a/sweep/fee_function_test.go +++ b/sweep/fee_function_test.go @@ -3,6 +3,7 @@ package sweep import ( "testing" + "github.com/lightningnetwork/lnd/fn" "github.com/lightningnetwork/lnd/lnwallet/chainfee" "github.com/stretchr/testify/require" ) @@ -21,10 +22,12 @@ func TestLinearFeeFunctionNew(t *testing.T) { estimatedFeeRate := chainfee.SatPerKWeight(500) minRelayFeeRate := chainfee.SatPerKWeight(100) confTarget := uint32(6) + noStartFeeRate := fn.None[chainfee.SatPerKWeight]() + startFeeRate := chainfee.SatPerKWeight(1000) // Assert init fee function with zero conf value will end up using the // max fee rate. - f, err := NewLinearFeeFunction(maxFeeRate, 0, estimator) + f, err := NewLinearFeeFunction(maxFeeRate, 0, estimator, noStartFeeRate) rt.NoError(err) rt.NotNil(f) @@ -39,7 +42,9 @@ func TestLinearFeeFunctionNew(t *testing.T) { estimator.On("EstimateFeePerKW", confTarget).Return( chainfee.SatPerKWeight(0), errDummy).Once() - f, err = NewLinearFeeFunction(maxFeeRate, confTarget, estimator) + f, err = NewLinearFeeFunction( + maxFeeRate, confTarget, estimator, noStartFeeRate, + ) rt.ErrorIs(err, errDummy) rt.Nil(f) @@ -53,7 +58,9 @@ func TestLinearFeeFunctionNew(t *testing.T) { maxFeeRate+1, nil).Once() estimator.On("RelayFeePerKW").Return(estimatedFeeRate).Once() - f, err = NewLinearFeeFunction(maxFeeRate, smallConf, estimator) + f, err = NewLinearFeeFunction( + maxFeeRate, smallConf, estimator, noStartFeeRate, + ) rt.NoError(err) rt.NotNil(f) @@ -65,7 +72,9 @@ func TestLinearFeeFunctionNew(t *testing.T) { maxFeeRate, nil).Once() estimator.On("RelayFeePerKW").Return(estimatedFeeRate).Once() - f, err = NewLinearFeeFunction(maxFeeRate, confTarget, estimator) + f, err = NewLinearFeeFunction( + maxFeeRate, confTarget, estimator, noStartFeeRate, + ) rt.ErrorContains(err, "fee rate delta is zero") rt.Nil(f) @@ -75,7 +84,9 @@ func TestLinearFeeFunctionNew(t *testing.T) { estimator.On("RelayFeePerKW").Return(minRelayFeeRate).Once() largeConf := uint32(1008) - f, err = NewLinearFeeFunction(maxFeeRate, largeConf, estimator) + f, err = NewLinearFeeFunction( + maxFeeRate, largeConf, estimator, noStartFeeRate, + ) rt.NoError(err) rt.NotNil(f) @@ -93,7 +104,9 @@ func TestLinearFeeFunctionNew(t *testing.T) { estimatedFeeRate, nil).Once() estimator.On("RelayFeePerKW").Return(estimatedFeeRate).Once() - f, err = NewLinearFeeFunction(maxFeeRate, confTarget, estimator) + f, err = NewLinearFeeFunction( + maxFeeRate, confTarget, estimator, noStartFeeRate, + ) rt.NoError(err) rt.NotNil(f) @@ -103,6 +116,22 @@ func TestLinearFeeFunctionNew(t *testing.T) { rt.Equal(estimatedFeeRate, f.currentFeeRate) rt.NotZero(f.deltaFeeRate) rt.Equal(confTarget, f.width) + + // Check a successfully created fee function using the specified + // starting fee rate. + // + // NOTE: by NOT mocking the fee estimator, we assert the + // estimateFeeRate is NOT called. + f, err = NewLinearFeeFunction( + maxFeeRate, confTarget, estimator, fn.Some(startFeeRate), + ) + + rt.NoError(err) + rt.NotNil(f) + + // Assert the customized starting fee rate is used. + rt.Equal(startFeeRate, f.startingFeeRate) + rt.Equal(startFeeRate, f.currentFeeRate) } // TestLinearFeeFunctionFeeRateAtPosition checks the expected feerate is @@ -184,7 +213,10 @@ func TestLinearFeeFunctionIncrement(t *testing.T) { estimatedFeeRate, nil).Once() estimator.On("RelayFeePerKW").Return(estimatedFeeRate).Once() - f, err := NewLinearFeeFunction(maxFeeRate, confTarget, estimator) + f, err := NewLinearFeeFunction( + maxFeeRate, confTarget, estimator, + fn.None[chainfee.SatPerKWeight](), + ) rt.NoError(err) // We now increase the position from 1 to 9. @@ -232,7 +264,10 @@ func TestLinearFeeFunctionIncreaseFeeRate(t *testing.T) { estimatedFeeRate, nil).Once() estimator.On("RelayFeePerKW").Return(estimatedFeeRate).Once() - f, err := NewLinearFeeFunction(maxFeeRate, confTarget, estimator) + f, err := NewLinearFeeFunction( + maxFeeRate, confTarget, estimator, + fn.None[chainfee.SatPerKWeight](), + ) rt.NoError(err) // If we are increasing the fee rate using the initial conf target, we diff --git a/sweep/mock_test.go b/sweep/mock_test.go index 94b251eef..6160923c3 100644 --- a/sweep/mock_test.go +++ b/sweep/mock_test.go @@ -8,6 +8,7 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd/fn" "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/lnwallet" "github.com/lightningnetwork/lnd/lnwallet/chainfee" @@ -510,6 +511,13 @@ func (m *MockInputSet) Budget() btcutil.Amount { return args.Get(0).(btcutil.Amount) } +// StartingFeeRate returns the max starting fee rate found in the inputs. +func (m *MockInputSet) StartingFeeRate() fn.Option[chainfee.SatPerKWeight] { + args := m.Called() + + return args.Get(0).(fn.Option[chainfee.SatPerKWeight]) +} + // MockBumper is a mock implementation of the interface Bumper. type MockBumper struct { mock.Mock diff --git a/sweep/sweeper.go b/sweep/sweeper.go index 850d81a26..970bdb1c8 100644 --- a/sweep/sweeper.go +++ b/sweep/sweeper.go @@ -65,6 +65,10 @@ type Params struct { // without waiting for blocks to come to trigger the sweeping of // inputs. Immediate bool + + // StartingFeeRate is an optional parameter that can be used to specify + // the initial fee rate to use for the fee function. + StartingFeeRate fn.Option[chainfee.SatPerKWeight] } // ParamsUpdate contains a new set of parameters to update a pending sweep with. @@ -77,6 +81,10 @@ type ParamsUpdate struct { // Immediate indicates that the input should be swept immediately // without waiting for blocks to come. Immediate bool + + // StartingFeeRate is an optional parameter that can be used to specify + // the initial fee rate to use for the fee function. + StartingFeeRate fn.Option[chainfee.SatPerKWeight] } // String returns a human readable interpretation of the sweep parameters. @@ -91,9 +99,9 @@ func (p Params) String() string { exclusiveGroup = fmt.Sprintf("%d", *p.ExclusiveGroup) } - return fmt.Sprintf("fee=%v, immediate=%v, exclusive_group=%v, budget=%v, "+ - "deadline=%v", p.Fee, p.Immediate, exclusiveGroup, p.Budget, - deadline) + return fmt.Sprintf("startingFeeRate=%v, immediate=%v, "+ + "exclusive_group=%v, budget=%v, deadline=%v", p.StartingFeeRate, + p.Immediate, exclusiveGroup, p.Budget, deadline) } // SweepState represents the current state of a pending input. @@ -830,6 +838,7 @@ func (s *UtxoSweeper) sweep(set InputSet) error { DeadlineHeight: set.DeadlineHeight(), DeliveryAddress: s.currentOutputScript, MaxFeeRate: s.cfg.MaxFeeRate.FeePerKWeight(), + StartingFeeRate: set.StartingFeeRate(), // TODO(yy): pass the strategy here. } diff --git a/sweep/sweeper_test.go b/sweep/sweeper_test.go index 2c6326c04..6a164ccd0 100644 --- a/sweep/sweeper_test.go +++ b/sweep/sweeper_test.go @@ -2734,9 +2734,13 @@ func TestSweepPendingInputs(t *testing.T) { setNeedWallet.On("Inputs").Return(nil).Times(4) setNeedWallet.On("DeadlineHeight").Return(testHeight).Once() setNeedWallet.On("Budget").Return(btcutil.Amount(1)).Once() + setNeedWallet.On("StartingFeeRate").Return( + fn.None[chainfee.SatPerKWeight]()).Once() normalSet.On("Inputs").Return(nil).Times(4) normalSet.On("DeadlineHeight").Return(testHeight).Once() normalSet.On("Budget").Return(btcutil.Amount(1)).Once() + normalSet.On("StartingFeeRate").Return( + fn.None[chainfee.SatPerKWeight]()).Once() // Make pending inputs for testing. We don't need real values here as // the returned clusters are mocked. diff --git a/sweep/tx_input_set.go b/sweep/tx_input_set.go index fc7e4b47a..5d7b3f797 100644 --- a/sweep/tx_input_set.go +++ b/sweep/tx_input_set.go @@ -77,6 +77,10 @@ type InputSet interface { // Budget givens the total amount that can be used as fees by this // input set. Budget() btcutil.Amount + + // StartingFeeRate returns the max starting fee rate found in the + // inputs. + StartingFeeRate() fn.Option[chainfee.SatPerKWeight] } type txInputSetState struct { @@ -205,6 +209,13 @@ func (t *txInputSet) DeadlineHeight() int32 { return 0 } +// StartingFeeRate returns the max starting fee rate found in the inputs. +// +// NOTE: this field is only used for `BudgetInputSet`. +func (t *txInputSet) StartingFeeRate() fn.Option[chainfee.SatPerKWeight] { + return fn.None[chainfee.SatPerKWeight]() +} + // NeedWalletInput returns true if the input set needs more wallet inputs. func (t *txInputSet) NeedWalletInput() bool { return !t.enoughInput() @@ -800,3 +811,21 @@ func (b *BudgetInputSet) Inputs() []input.Input { return inputs } + +// StartingFeeRate returns the max starting fee rate found in the inputs. +// +// NOTE: part of the InputSet interface. +func (b *BudgetInputSet) StartingFeeRate() fn.Option[chainfee.SatPerKWeight] { + maxFeeRate := chainfee.SatPerKWeight(0) + startingFeeRate := fn.None[chainfee.SatPerKWeight]() + + for _, inp := range b.inputs { + feerate := inp.params.StartingFeeRate.UnwrapOr(0) + if feerate > maxFeeRate { + maxFeeRate = feerate + startingFeeRate = fn.Some(maxFeeRate) + } + } + + return startingFeeRate +}