diff --git a/sweep/fee_function.go b/sweep/fee_function.go new file mode 100644 index 000000000..db3fb7851 --- /dev/null +++ b/sweep/fee_function.go @@ -0,0 +1,265 @@ +package sweep + +import ( + "errors" + "fmt" + + "github.com/btcsuite/btcd/btcutil" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" +) + +var ( + // ErrMaxPosition is returned when trying to increase the position of + // the fee function while it's already at its max. + ErrMaxPosition = errors.New("position already at max") +) + +// FeeFunction defines an interface that is used to calculate fee rates for +// transactions. It's expected the implementations use three params, the +// starting fee rate, the ending fee rate, and number of blocks till deadline +// block height, to build an algorithm to calculate the fee rate based on the +// current block height. +type FeeFunction interface { + // FeeRate returns the current fee rate calculated by the fee function. + FeeRate() chainfee.SatPerKWeight + + // Increment increases the fee rate by one step. The definition of one + // step is up to the implementation. After calling this method, it's + // expected to change the state of the fee function such that calling + // `FeeRate` again will return the increased value. + // + // It returns a boolean to indicate whether the fee rate is increased, + // as fee bump should not be attempted if the increased fee rate is not + // greater than the current fee rate, which may happen if the algorithm + // gives the same fee rates at two positions. + // + // An error is returned when the max fee rate is reached. + // + // NOTE: we intentionally don't return the new fee rate here, so both + // the implementation and the caller are aware of the state change. + Increment() (bool, error) + + // IncreaseFeeRate increases the fee rate to the new position + // calculated using (width - confTarget). It returns a boolean to + // indicate whether the fee rate is increased, and an error if the + // position is greater than the width. + // + // NOTE: this method is provided to allow the caller to increase the + // fee rate based on a conf target without taking care of the fee + // function's current state (position). + IncreaseFeeRate(confTarget uint32) (bool, error) +} + +// LinearFeeFunction implements the FeeFunction interface with a linear +// function: +// +// feeRate = startingFeeRate + position * delta. +// - width: deadlineBlockHeight - startingBlockHeight +// - delta: (endingFeeRate - startingFeeRate) / width +// - position: currentBlockHeight - startingBlockHeight +// +// The fee rate will be capped at endingFeeRate. +// +// TODO(yy): implement more functions specified here: +// - https://github.com/lightningnetwork/lnd/issues/4215 +type LinearFeeFunction struct { + // startingFeeRate specifies the initial fee rate to begin with. + startingFeeRate chainfee.SatPerKWeight + + // endingFeeRate specifies the max allowed fee rate. + endingFeeRate chainfee.SatPerKWeight + + // currentFeeRate specifies the current calculated fee rate. + currentFeeRate chainfee.SatPerKWeight + + // width is the number of blocks between the starting block height + // and the deadline block height. + width uint32 + + // position is the number of blocks between the starting block height + // and the current block height. + position uint32 + + // deltaFeeRate is the fee rate increase per block. + deltaFeeRate chainfee.SatPerKWeight + + // estimator is the fee estimator used to estimate the fee rate. We use + // it to get the initial fee rate and, use it as a benchmark to decide + // whether we want to used the estimated fee rate or the calculated fee + // rate based on different strategies. + estimator chainfee.Estimator +} + +// Compile-time check to ensure LinearFeeFunction satisfies the FeeFunction. +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) { + + // Sanity check conf target. + if confTarget == 0 { + return nil, fmt.Errorf("width must be greater than zero") + } + + l := &LinearFeeFunction{ + endingFeeRate: maxFeeRate, + width: confTarget, + 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 err != nil { + return nil, fmt.Errorf("estimate initial fee rate: %w", err) + } + + // Calculate how much fee rate should be increased per block. + end := l.endingFeeRate + delta := btcutil.Amount(end - start).MulF64(1 / float64(confTarget)) + + // We only allow the delta to be zero if the width is one - when the + // delta is zero, it means the starting and ending fee rates are the + // same, which means there's nothing to increase, so any width greater + // than 1 doesn't provide any utility. This could happen when the + // sweeper is offered to sweep an input that has passed its deadline. + if delta == 0 && l.width != 1 { + return nil, fmt.Errorf("fee rate delta is zero") + } + + // Attach the calculated values to the fee function. + l.startingFeeRate = start + l.currentFeeRate = start + l.deltaFeeRate = chainfee.SatPerKWeight(delta) + + log.Debugf("Linear fee function initialized with startingFeeRate=%v, "+ + "endingFeeRate=%v, width=%v, delta=%v", start, end, + confTarget, l.deltaFeeRate) + + return l, nil +} + +// FeeRate returns the current fee rate. +// +// NOTE: part of the FeeFunction interface. +func (l *LinearFeeFunction) FeeRate() chainfee.SatPerKWeight { + return l.currentFeeRate +} + +// Increment increases the fee rate by one position, returns a boolean to +// indicate whether the fee rate was increased, and an error if the position is +// greater than the width. The increased fee rate will be set as the current +// fee rate, and the internal position will be incremented. +// +// NOTE: this method will change the state of the fee function as it increases +// its current fee rate. +// +// NOTE: part of the FeeFunction interface. +func (l *LinearFeeFunction) Increment() (bool, error) { + return l.increaseFeeRate(l.position + 1) +} + +// IncreaseFeeRate calculate a new position using the given conf target, and +// increases the fee rate to the new position by calling the Increment method. +// +// NOTE: this method will change the state of the fee function as it increases +// its current fee rate. +// +// NOTE: part of the FeeFunction interface. +func (l *LinearFeeFunction) IncreaseFeeRate(confTarget uint32) (bool, error) { + // If the new position is already at the end, we return an error. + if confTarget == 0 { + return false, ErrMaxPosition + } + + newPosition := uint32(0) + + // Only calculate the new position when the conf target is less than + // the function's width - the width is the initial conf target, and we + // expect the current conf target to decrease over time. However, we + // still allow the supplied conf target to be greater than the width, + // and we won't increase the fee rate in that case. + if confTarget < l.width { + newPosition = l.width - confTarget + log.Tracef("Increasing position from %v to %v", l.position, + newPosition) + } + + if newPosition <= l.position { + log.Tracef("Skipped increase feerate: position=%v, "+ + "newPosition=%v ", l.position, newPosition) + + return false, nil + } + + return l.increaseFeeRate(newPosition) +} + +// increaseFeeRate increases the fee rate by the specified position, returns a +// boolean to indicate whether the fee rate was increased, and an error if the +// position is greater than the width. The increased fee rate will be set as +// the current fee rate, and the internal position will be set to the specified +// position. +// +// NOTE: this method will change the state of the fee function as it increases +// its current fee rate. +func (l *LinearFeeFunction) increaseFeeRate(position uint32) (bool, error) { + // If the new position is already at the end, we return an error. + if l.position >= l.width { + return false, ErrMaxPosition + } + + // Get the old fee rate. + oldFeeRate := l.currentFeeRate + + // Update its internal state. + l.position = position + l.currentFeeRate = l.feeRateAtPosition(position) + + log.Tracef("Fee rate increased from %v to %v at position %v", + oldFeeRate, l.currentFeeRate, l.position) + + return l.currentFeeRate > oldFeeRate, nil +} + +// feeRateAtPosition calculates the fee rate at a given position and caps it at +// the ending fee rate. +func (l *LinearFeeFunction) feeRateAtPosition(p uint32) chainfee.SatPerKWeight { + if p >= l.width { + return l.endingFeeRate + } + + feeRateDelta := btcutil.Amount(l.deltaFeeRate).MulF64(float64(p)) + + feeRate := l.startingFeeRate + chainfee.SatPerKWeight(feeRateDelta) + if feeRate > l.endingFeeRate { + return l.endingFeeRate + } + + return feeRate +} + +// estimateFeeRate asks the fee estimator to estimate the fee rate based on its +// conf target. +func (l *LinearFeeFunction) estimateFeeRate( + confTarget uint32) (chainfee.SatPerKWeight, error) { + + fee := FeeEstimateInfo{ + ConfTarget: confTarget, + } + + // endingFeeRate comes from budget/txWeight, which means the returned + // fee rate will always be capped by this value, hence we don't need to + // worry about overpay. + estimatedFeeRate, err := fee.Estimate(l.estimator, l.endingFeeRate) + if err != nil { + return 0, err + } + + return estimatedFeeRate, nil +} diff --git a/sweep/fee_function_test.go b/sweep/fee_function_test.go new file mode 100644 index 000000000..e7f80819a --- /dev/null +++ b/sweep/fee_function_test.go @@ -0,0 +1,247 @@ +package sweep + +import ( + "testing" + + "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/stretchr/testify/require" +) + +// TestLinearFeeFunctionNew tests the NewLinearFeeFunction function. +func TestLinearFeeFunctionNew(t *testing.T) { + t.Parallel() + + rt := require.New(t) + + // Create a mock fee estimator. + estimator := &chainfee.MockEstimator{} + + // Create testing params. + maxFeeRate := chainfee.SatPerKWeight(10000) + estimatedFeeRate := chainfee.SatPerKWeight(500) + confTarget := uint32(6) + + // Assert init fee function with zero conf value returns an error. + f, err := NewLinearFeeFunction(maxFeeRate, 0, estimator) + rt.ErrorContains(err, "width must be greater than zero") + rt.Nil(f) + + // When the fee estimator returns an error, it's returned. + // + // Mock the fee estimator to return an error. + estimator.On("EstimateFeePerKW", confTarget).Return( + chainfee.SatPerKWeight(0), errDummy).Once() + + f, err = NewLinearFeeFunction(maxFeeRate, confTarget, estimator) + rt.ErrorIs(err, errDummy) + rt.Nil(f) + + // When the starting feerate is greater than the ending feerate, the + // starting feerate is capped. + // + // Mock the fee estimator to return the fee rate. + smallConf := uint32(1) + estimator.On("EstimateFeePerKW", smallConf).Return( + // The fee rate is greater than the max fee rate. + maxFeeRate+1, nil).Once() + estimator.On("RelayFeePerKW").Return(estimatedFeeRate).Once() + + f, err = NewLinearFeeFunction(maxFeeRate, smallConf, estimator) + rt.NoError(err) + rt.NotNil(f) + + // When the calculated fee rate delta is 0, an error should be returned. + // + // Mock the fee estimator to return the fee rate. + estimator.On("EstimateFeePerKW", confTarget).Return( + // The starting fee rate is 1 sat/kw less than the max fee rate. + maxFeeRate-1, nil).Once() + estimator.On("RelayFeePerKW").Return(estimatedFeeRate).Once() + + f, err = NewLinearFeeFunction(maxFeeRate, confTarget, estimator) + rt.ErrorContains(err, "fee rate delta is zero") + rt.Nil(f) + + // Check a successfully created fee function. + // + // Mock the fee estimator to return the fee rate. + estimator.On("EstimateFeePerKW", confTarget).Return( + estimatedFeeRate, nil).Once() + estimator.On("RelayFeePerKW").Return(estimatedFeeRate).Once() + + f, err = NewLinearFeeFunction(maxFeeRate, confTarget, estimator) + rt.NoError(err) + rt.NotNil(f) + + // Assert the internal state. + rt.Equal(estimatedFeeRate, f.startingFeeRate) + rt.Equal(maxFeeRate, f.endingFeeRate) + rt.Equal(estimatedFeeRate, f.currentFeeRate) + rt.NotZero(f.deltaFeeRate) + rt.Equal(confTarget, f.width) +} + +// TestLinearFeeFunctionFeeRateAtPosition checks the expected feerate is +// calculated and returned. +func TestLinearFeeFunctionFeeRateAtPosition(t *testing.T) { + t.Parallel() + + rt := require.New(t) + + // Create a fee func which has three positions: + // - position 0: 1000 + // - position 1: 2000 + // - position 2: 3000 + f := &LinearFeeFunction{ + startingFeeRate: 1000, + endingFeeRate: 3000, + position: 0, + deltaFeeRate: 1000, + width: 3, + } + + testCases := []struct { + name string + pos uint32 + expectedFeerate chainfee.SatPerKWeight + }{ + { + name: "position 0", + pos: 0, + expectedFeerate: 1000, + }, + { + name: "position 1", + pos: 1, + expectedFeerate: 2000, + }, + { + name: "position 2", + pos: 2, + expectedFeerate: 3000, + }, + { + name: "position 3", + pos: 3, + expectedFeerate: 3000, + }, + } + + for _, tc := range testCases { + tc := tc + + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + result := f.feeRateAtPosition(tc.pos) + rt.Equal(tc.expectedFeerate, result) + }) + } +} + +// TestLinearFeeFunctionIncrement checks the internal state is updated +// correctly when the fee rate is incremented. +func TestLinearFeeFunctionIncrement(t *testing.T) { + t.Parallel() + + rt := require.New(t) + + // Create a mock fee estimator. + estimator := &chainfee.MockEstimator{} + + // Create testing params. These params are chosen so the delta value is + // 100. + maxFeeRate := chainfee.SatPerKWeight(1000) + estimatedFeeRate := chainfee.SatPerKWeight(100) + confTarget := uint32(9) + + // Mock the fee estimator to return the fee rate. + estimator.On("EstimateFeePerKW", confTarget).Return( + estimatedFeeRate, nil).Once() + estimator.On("RelayFeePerKW").Return(estimatedFeeRate).Once() + + f, err := NewLinearFeeFunction(maxFeeRate, confTarget, estimator) + rt.NoError(err) + + // We now increase the position from 1 to 9. + for i := uint32(1); i <= confTarget; i++ { + // Increase the fee rate. + increased, err := f.Increment() + rt.NoError(err) + rt.True(increased) + + // Assert the internal state. + rt.Equal(i, f.position) + + delta := chainfee.SatPerKWeight(i * 100) + rt.Equal(estimatedFeeRate+delta, f.currentFeeRate) + + // Check public method returns the expected fee rate. + rt.Equal(estimatedFeeRate+delta, f.FeeRate()) + } + + // Now the position is at 9th, increase it again should give us an + // error. + increased, err := f.Increment() + rt.ErrorIs(err, ErrMaxPosition) + rt.False(increased) +} + +// TestLinearFeeFunctionIncreaseFeeRate checks the internal state is updated +// correctly when the fee rate is increased using conf targets. +func TestLinearFeeFunctionIncreaseFeeRate(t *testing.T) { + t.Parallel() + + rt := require.New(t) + + // Create a mock fee estimator. + estimator := &chainfee.MockEstimator{} + + // Create testing params. These params are chosen so the delta value is + // 100. + maxFeeRate := chainfee.SatPerKWeight(1000) + estimatedFeeRate := chainfee.SatPerKWeight(100) + confTarget := uint32(9) + + // Mock the fee estimator to return the fee rate. + estimator.On("EstimateFeePerKW", confTarget).Return( + estimatedFeeRate, nil).Once() + estimator.On("RelayFeePerKW").Return(estimatedFeeRate).Once() + + f, err := NewLinearFeeFunction(maxFeeRate, confTarget, estimator) + rt.NoError(err) + + // If we are increasing the fee rate using the initial conf target, we + // should get a nil error and false. + increased, err := f.IncreaseFeeRate(confTarget) + rt.NoError(err) + rt.False(increased) + + // Test that we are allowed to use a larger conf target. + increased, err = f.IncreaseFeeRate(confTarget + 1) + rt.NoError(err) + rt.False(increased) + + // Test that when we use a conf target of 0, we get an error. + increased, err = f.IncreaseFeeRate(0) + rt.ErrorIs(err, ErrMaxPosition) + rt.False(increased) + + // We now increase the fee rate from conf target 8 to 1 and assert we + // get no error and true. + for i := uint32(1); i < confTarget; i++ { + // Increase the fee rate. + increased, err := f.IncreaseFeeRate(confTarget - i) + rt.NoError(err) + rt.True(increased) + + // Assert the internal state. + rt.Equal(i, f.position) + + delta := chainfee.SatPerKWeight(i * 100) + rt.Equal(estimatedFeeRate+delta, f.currentFeeRate) + + // Check public method returns the expected fee rate. + rt.Equal(estimatedFeeRate+delta, f.FeeRate()) + } +}