From ecd471ac753dfb832958c1f4fe5cf86237b3d625 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 29 Feb 2024 13:18:23 +0800 Subject: [PATCH] lnwallet+sweep: calculate max allowed feerate on `BumpResult` This commit adds the method `MaxFeeRateAllowed` to calculate the max fee rate. The caller may specify a large MaxFeeRate value, which cannot be cover by the budget. In that case, we default to use the max feerate calculated using `budget/weight`. --- lnwallet/chainfee/rates.go | 5 ++ sweep/fee_bumper.go | 57 +++++++++++++++++ sweep/fee_bumper_test.go | 121 +++++++++++++++++++++++++++++++++++++ 3 files changed, 183 insertions(+) diff --git a/lnwallet/chainfee/rates.go b/lnwallet/chainfee/rates.go index 6496b39c0..98cefc13b 100644 --- a/lnwallet/chainfee/rates.go +++ b/lnwallet/chainfee/rates.go @@ -58,6 +58,11 @@ func (s SatPerKVByte) String() string { // SatPerKWeight represents a fee rate in sat/kw. type SatPerKWeight btcutil.Amount +// NewSatPerKWeight creates a new fee rate in sat/kw. +func NewSatPerKWeight(fee btcutil.Amount, weight uint64) SatPerKWeight { + return SatPerKWeight(fee.MulF64(1000 / float64(weight))) +} + // FeeForWeight calculates the fee resulting from this fee rate and the given // weight in weight units (wu). func (s SatPerKWeight) FeeForWeight(wu int64) btcutil.Amount { diff --git a/sweep/fee_bumper.go b/sweep/fee_bumper.go index c40614961..b5515d10e 100644 --- a/sweep/fee_bumper.go +++ b/sweep/fee_bumper.go @@ -88,6 +88,63 @@ type BumpRequest struct { MaxFeeRate chainfee.SatPerKWeight } +// MaxFeeRateAllowed returns the maximum fee rate allowed for the given +// request. It calculates the feerate using the supplied budget and the weight, +// compares it with the specified MaxFeeRate, and returns the smaller of the +// two. +func (r *BumpRequest) MaxFeeRateAllowed() (chainfee.SatPerKWeight, error) { + // Get the size of the sweep tx, which will be used to calculate the + // budget fee rate. + size, err := calcSweepTxWeight(r.Inputs, r.DeliveryAddress) + if err != nil { + return 0, err + } + + // Use the budget and MaxFeeRate to decide the max allowed fee rate. + // This is needed as, when the input has a large value and the user + // sets the budget to be proportional to the input value, the fee rate + // can be very high and we need to make sure it doesn't exceed the max + // fee rate. + maxFeeRateAllowed := chainfee.NewSatPerKWeight(r.Budget, size) + if maxFeeRateAllowed > r.MaxFeeRate { + log.Debugf("Budget feerate %v exceeds MaxFeeRate %v, use "+ + "MaxFeeRate instead", maxFeeRateAllowed, r.MaxFeeRate) + + return r.MaxFeeRate, nil + } + + log.Debugf("Budget feerate %v below MaxFeeRate %v, use budget feerate "+ + "instead", maxFeeRateAllowed, r.MaxFeeRate) + + return maxFeeRateAllowed, nil +} + +// calcSweepTxWeight calculates the weight of the sweep tx. It assumes a +// sweeping tx always has a single output(change). +func calcSweepTxWeight(inputs []input.Input, + outputPkScript []byte) (uint64, error) { + + // Use a const fee rate as we only use the weight estimator to + // calculate the size. + const feeRate = 1 + + // Initialize the tx weight estimator with, + // - nil outputs as we only have one single change output. + // - const fee rate as we don't care about the fees here. + // - 0 maxfeerate as we don't care about fees here. + // + // TODO(yy): we should refactor the weight estimator to not require a + // fee rate and max fee rate and make it a pure tx weight calculator. + _, estimator, err := getWeightEstimate( + inputs, nil, feeRate, 0, outputPkScript, + ) + if err != nil { + return 0, err + } + + return uint64(estimator.weight()), nil +} + // BumpResult is used by the Bumper to send updates about the tx being // broadcast. type BumpResult struct { diff --git a/sweep/fee_bumper_test.go b/sweep/fee_bumper_test.go index 22c247b2c..099e0aacd 100644 --- a/sweep/fee_bumper_test.go +++ b/sweep/fee_bumper_test.go @@ -3,10 +3,24 @@ package sweep import ( "testing" + "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" "github.com/stretchr/testify/require" ) +var ( + // Create a taproot change script. + changePkScript = []byte{ + 0x51, 0x20, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + } +) + // TestBumpResultValidate tests the validate method of the BumpResult struct. func TestBumpResultValidate(t *testing.T) { t.Parallel() @@ -50,3 +64,110 @@ func TestBumpResultValidate(t *testing.T) { } require.NoError(t, b.Validate()) } + +// TestCalcSweepTxWeight checks that the weight of the sweep tx is calculated +// correctly. +func TestCalcSweepTxWeight(t *testing.T) { + t.Parallel() + + // Create an input. + inp := createTestInput(100, input.WitnessKeyHash) + + // Use a wrong change script to test the error case. + weight, err := calcSweepTxWeight([]input.Input{&inp}, []byte{0}) + require.Error(t, err) + require.Zero(t, weight) + + // Use a correct change script to test the success case. + weight, err = calcSweepTxWeight([]input.Input{&inp}, changePkScript) + require.NoError(t, err) + + // BaseTxSize 8 bytes + // InputSize 1+41 bytes + // One P2TROutputSize 1+43 bytes + // One P2WKHWitnessSize 2+109 bytes + // Total weight = (8+42+44) * 4 + 111 = 487 + require.EqualValuesf(t, 487, weight, "unexpected weight %v", weight) +} + +// TestBumpRequestMaxFeeRateAllowed tests the max fee rate allowed for a bump +// request. +func TestBumpRequestMaxFeeRateAllowed(t *testing.T) { + t.Parallel() + + // Create a test input. + inp := createTestInput(100, input.WitnessKeyHash) + + // The weight is 487. + weight, err := calcSweepTxWeight([]input.Input{&inp}, changePkScript) + require.NoError(t, err) + + // Define a test budget and calculates its fee rate. + budget := btcutil.Amount(1000) + budgetFeeRate := chainfee.NewSatPerKWeight(budget, weight) + + testCases := []struct { + name string + req *BumpRequest + expectedMaxFeeRate chainfee.SatPerKWeight + expectedErr bool + }{ + { + // Use a wrong change script to test the error case. + name: "error calc weight", + req: &BumpRequest{ + DeliveryAddress: []byte{1}, + }, + expectedMaxFeeRate: 0, + expectedErr: true, + }, + { + // When the budget cannot give a fee rate that matches + // the supplied MaxFeeRate, the max allowed feerate is + // capped by the budget. + name: "use budget as max fee rate", + req: &BumpRequest{ + DeliveryAddress: changePkScript, + Inputs: []input.Input{&inp}, + Budget: budget, + MaxFeeRate: budgetFeeRate + 1, + }, + expectedMaxFeeRate: budgetFeeRate, + }, + { + // When the budget can give a fee rate that matches the + // supplied MaxFeeRate, the max allowed feerate is + // capped by the MaxFeeRate. + name: "use config as max fee rate", + req: &BumpRequest{ + DeliveryAddress: changePkScript, + Inputs: []input.Input{&inp}, + Budget: budget, + MaxFeeRate: budgetFeeRate - 1, + }, + expectedMaxFeeRate: budgetFeeRate - 1, + }, + } + + for _, tc := range testCases { + tc := tc + + t.Run(tc.name, func(t *testing.T) { + // Check the method under test. + maxFeeRate, err := tc.req.MaxFeeRateAllowed() + + // If we expect an error, check the error is returned + // and the feerate is empty. + if tc.expectedErr { + require.Error(t, err) + require.Zero(t, maxFeeRate) + + return + } + + // Otherwise, check the max fee rate is as expected. + require.NoError(t, err) + require.Equal(t, tc.expectedMaxFeeRate, maxFeeRate) + }) + } +}