From e7400f6a94b1a36129d9bef7df68ca984d43af2d Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 27 Feb 2024 17:52:47 +0800 Subject: [PATCH] sweep: introduce `BudgetInputSet` to manage budget-based inputs This commit adds `BudgetInputSet` which implements `InputSet`. It handles the pending inputs based on the supplied budgets and will be used in the following commit. --- sweep/sweeper.go | 16 ++ sweep/tx_input_set.go | 274 +++++++++++++++++++++++ sweep/tx_input_set_test.go | 433 +++++++++++++++++++++++++++++++++++++ 3 files changed, 723 insertions(+) diff --git a/sweep/sweeper.go b/sweep/sweeper.go index 92c6646d2..659c1d340 100644 --- a/sweep/sweeper.go +++ b/sweep/sweeper.go @@ -52,11 +52,22 @@ type Params struct { // Force indicates whether the input should be swept regardless of // whether it is economical to do so. + // + // TODO(yy): Remove this param once deadline based sweeping is in place. Force bool // ExclusiveGroup is an identifier that, if set, prevents other inputs // with the same identifier from being batched together. ExclusiveGroup *uint64 + + // DeadlineHeight specifies an absolute block height that this input + // should be confirmed by. This value is used by the fee bumper to + // decide its urgency and adjust its feerate used. + DeadlineHeight fn.Option[int32] + + // Budget specifies the maximum amount of satoshis that can be spent on + // fees for this sweep. + Budget btcutil.Amount } // ParamsUpdate contains a new set of parameters to update a pending sweep with. @@ -196,6 +207,11 @@ type pendingInput struct { rbf fn.Option[RBFInfo] } +// String returns a human readable interpretation of the pending input. +func (p *pendingInput) String() string { + return fmt.Sprintf("%v (%v)", p.Input.OutPoint(), p.Input.WitnessType()) +} + // parameters returns the sweep parameters for this input. // // NOTE: Part of the txInput interface. diff --git a/sweep/tx_input_set.go b/sweep/tx_input_set.go index 789bb277b..0354c6c1d 100644 --- a/sweep/tx_input_set.go +++ b/sweep/tx_input_set.go @@ -35,6 +35,14 @@ var ( // ErrNotEnoughInputs is returned when there are not enough wallet // inputs to construct a non-dust change output for an input set. ErrNotEnoughInputs = fmt.Errorf("not enough inputs") + + // ErrDeadlinesMismatch is returned when the deadlines of the input + // sets do not match. + ErrDeadlinesMismatch = fmt.Errorf("deadlines mismatch") + + // ErrDustOutput is returned when the output value is below the dust + // limit. + ErrDustOutput = fmt.Errorf("dust output") ) // InputSet defines an interface that's responsible for filtering a set of @@ -542,3 +550,269 @@ func createWalletTxInput(utxo *lnwallet.Utxo) (input.Input, error) { &utxo.OutPoint, witnessType, signDesc, heightHint, ), nil } + +// BudgetInputSet implements the interface `InputSet`. It takes a list of +// pending inputs which share the same deadline height and groups them into a +// set conditionally based on their economical values. +type BudgetInputSet struct { + // inputs is the set of inputs that have been added to the set after + // considering their economical contribution. + inputs []*pendingInput + + // deadlineHeight is the height which the inputs in this set must be + // confirmed by. + deadlineHeight fn.Option[int32] +} + +// Compile-time constraint to ensure budgetInputSet implements InputSet. +var _ InputSet = (*BudgetInputSet)(nil) + +// validateInputs is used when creating new BudgetInputSet to ensure there are +// no duplicate inputs and they all share the same deadline heights, if set. +func validateInputs(inputs []pendingInput) error { + // Sanity check the input slice to ensure it's non-empty. + if len(inputs) == 0 { + return fmt.Errorf("inputs slice is empty") + } + + // dedupInputs is a map used to track unique outpoints of the inputs. + dedupInputs := make(map[*wire.OutPoint]struct{}) + + // deadlineSet stores unique deadline heights. + deadlineSet := make(map[fn.Option[int32]]struct{}) + + for _, input := range inputs { + input.params.DeadlineHeight.WhenSome(func(h int32) { + deadlineSet[input.params.DeadlineHeight] = struct{}{} + }) + + dedupInputs[input.OutPoint()] = struct{}{} + } + + // Make sure the inputs share the same deadline height when there is + // one. + if len(deadlineSet) > 1 { + return fmt.Errorf("inputs have different deadline heights") + } + + // Provide a defensive check to ensure that we don't have any duplicate + // inputs within the set. + if len(dedupInputs) != len(inputs) { + return fmt.Errorf("duplicate inputs") + } + + return nil +} + +// NewBudgetInputSet creates a new BudgetInputSet. +func NewBudgetInputSet(inputs []pendingInput) (*BudgetInputSet, error) { + // Validate the supplied inputs. + if err := validateInputs(inputs); err != nil { + return nil, err + } + + // TODO(yy): all the inputs share the same deadline height, which means + // there exists an opportunity to refactor the deadline height to be + // tracked on the set-level, not per input. This would allow us to + // avoid the overhead of tracking the same height for each input in the + // set. + deadlineHeight := inputs[0].params.DeadlineHeight + bi := &BudgetInputSet{ + deadlineHeight: deadlineHeight, + inputs: make([]*pendingInput, 0, len(inputs)), + } + + for _, input := range inputs { + bi.addInput(input) + } + + log.Tracef("Created %v", bi.String()) + + return bi, nil +} + +// String returns a human-readable description of the input set. +func (b *BudgetInputSet) String() string { + deadlineDesc := "none" + b.deadlineHeight.WhenSome(func(h int32) { + deadlineDesc = fmt.Sprintf("%d", h) + }) + + inputsDesc := "" + for _, input := range b.inputs { + inputsDesc += fmt.Sprintf("\n%v", input) + } + + return fmt.Sprintf("BudgetInputSet(budget=%v, deadline=%v, "+ + "inputs=[%v])", b.Budget(), deadlineDesc, inputsDesc) +} + +// addInput adds an input to the input set. +func (b *BudgetInputSet) addInput(input pendingInput) { + b.inputs = append(b.inputs, &input) +} + +// NeedWalletInput returns true if the input set needs more wallet inputs. +// +// A set may need wallet inputs when it has a required output or its total +// value cannot cover its total budget. +func (b *BudgetInputSet) NeedWalletInput() bool { + var ( + // budgetNeeded is the amount that needs to be covered from + // other inputs. + budgetNeeded btcutil.Amount + + // budgetBorrowable is the amount that can be borrowed from + // other inputs. + budgetBorrowable btcutil.Amount + ) + + for _, inp := range b.inputs { + // If this input has a required output, we can assume it's a + // second-level htlc txns input. Although this input must have + // a value that can cover its budget, it cannot be used to pay + // fees. Instead, we need to borrow budget from other inputs to + // make the sweep happen. Once swept, the input value will be + // credited to the wallet. + if inp.RequiredTxOut() != nil { + budgetNeeded += inp.params.Budget + continue + } + + // Get the amount left after covering the input's own budget. + // This amount can then be lent to the above input. + budget := inp.params.Budget + output := btcutil.Amount(inp.SignDesc().Output.Value) + budgetBorrowable += output - budget + + // If the input's budget is not even covered by itself, we need + // to borrow outputs from other inputs. + if budgetBorrowable < 0 { + log.Debugf("Input %v specified a budget that exceeds "+ + "its output value: %v > %v", inp, budget, + output) + } + } + + log.Tracef("NeedWalletInput: budgetNeeded=%v, budgetBorrowable=%v", + budgetNeeded, budgetBorrowable) + + // If we don't have enough extra budget to borrow, we need wallet + // inputs. + return budgetBorrowable < budgetNeeded +} + +// copyInputs returns a copy of the slice of the inputs in the set. +func (b *BudgetInputSet) copyInputs() []*pendingInput { + inputs := make([]*pendingInput, len(b.inputs)) + copy(inputs, b.inputs) + return inputs +} + +// AddWalletInputs adds wallet inputs to the set until the specified budget is +// met. When sweeping inputs with required outputs, although there's budget +// specified, it cannot be directly spent from these required outputs. Instead, +// we need to borrow budget from other inputs to make the sweep happen. +// There are two sources to borrow from: 1) other inputs, 2) wallet utxos. If +// we are calling this method, it means other inputs cannot cover the specified +// budget, so we need to borrow from wallet utxos. +// +// Return an error if there are not enough wallet inputs, and the budget set is +// set to its initial state by removing any wallet inputs added. +// +// NOTE: must be called with the wallet lock held via `WithCoinSelectLock`. +func (b *BudgetInputSet) AddWalletInputs(wallet Wallet) error { + // Retrieve wallet utxos. Only consider confirmed utxos to prevent + // problems around RBF rules for unconfirmed inputs. This currently + // ignores the configured coin selection strategy. + utxos, err := wallet.ListUnspentWitnessFromDefaultAccount( + 1, math.MaxInt32, + ) + if err != nil { + return fmt.Errorf("list unspent witness: %w", err) + } + + // Sort the UTXOs by putting smaller values at the start of the slice + // to avoid locking large UTXO for sweeping. + // + // TODO(yy): add more choices to CoinSelectionStrategy and use the + // configured value here. + sort.Slice(utxos, func(i, j int) bool { + return utxos[i].Value < utxos[j].Value + }) + + // Make a copy of the current inputs. If the wallet doesn't have enough + // utxos to cover the budget, we will revert the current set to its + // original state by removing the added wallet inputs. + originalInputs := b.copyInputs() + + // Add wallet inputs to the set until the specified budget is covered. + for _, utxo := range utxos { + input, err := createWalletTxInput(utxo) + if err != nil { + return err + } + + pi := pendingInput{ + Input: input, + params: Params{ + // Inherit the deadline height from the input + // set. + DeadlineHeight: b.deadlineHeight, + }, + } + + b.addInput(pi) + + // Return if we've reached the minimum output amount. + if !b.NeedWalletInput() { + return nil + } + } + + // The wallet doesn't have enough utxos to cover the budget. Revert the + // input set to its original state. + b.inputs = originalInputs + + return ErrNotEnoughInputs +} + +// Budget returns the total budget of the set. +// +// NOTE: part of the InputSet interface. +func (b *BudgetInputSet) Budget() btcutil.Amount { + budget := btcutil.Amount(0) + for _, input := range b.inputs { + budget += input.params.Budget + } + + return budget +} + +// DeadlineHeight returns the deadline height of the set. +// +// NOTE: part of the InputSet interface. +func (b *BudgetInputSet) DeadlineHeight() fn.Option[int32] { + return b.deadlineHeight +} + +// Inputs returns the inputs that should be used to create a tx. +// +// NOTE: part of the InputSet interface. +func (b *BudgetInputSet) Inputs() []input.Input { + inputs := make([]input.Input, 0, len(b.inputs)) + for _, inp := range b.inputs { + inputs = append(inputs, inp.Input) + } + + return inputs +} + +// FeeRate returns the fee rate that should be used for the tx. +// +// NOTE: part of the InputSet interface. +// +// TODO(yy): will be removed once fee bumper is implemented. +func (b *BudgetInputSet) FeeRate() chainfee.SatPerKWeight { + return 0 +} diff --git a/sweep/tx_input_set_test.go b/sweep/tx_input_set_test.go index 51afff7b7..32a08fba4 100644 --- a/sweep/tx_input_set_test.go +++ b/sweep/tx_input_set_test.go @@ -1,10 +1,14 @@ package sweep import ( + "errors" + "math" "testing" "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/stretchr/testify/require" @@ -237,3 +241,432 @@ func TestTxInputSetRequiredOutput(t *testing.T) { } require.True(t, set.enoughInput()) } + +// TestNewBudgetInputSet checks `NewBudgetInputSet` correctly validates the +// supplied inputs and returns the error. +func TestNewBudgetInputSet(t *testing.T) { + t.Parallel() + + rt := require.New(t) + + // Pass an empty slice and expect an error. + set, err := NewBudgetInputSet([]pendingInput{}) + rt.ErrorContains(err, "inputs slice is empty") + rt.Nil(set) + + // Create two inputs with different deadline heights. + inp0 := createP2WKHInput(1000) + inp1 := createP2WKHInput(1000) + inp2 := createP2WKHInput(1000) + input0 := pendingInput{ + Input: inp0, + params: Params{ + Budget: 100, + DeadlineHeight: fn.None[int32](), + }, + } + input1 := pendingInput{ + Input: inp1, + params: Params{ + Budget: 100, + DeadlineHeight: fn.Some(int32(1)), + }, + } + input2 := pendingInput{ + Input: inp2, + params: Params{ + Budget: 100, + DeadlineHeight: fn.Some(int32(2)), + }, + } + + // Pass a slice of inputs with different deadline heights. + set, err = NewBudgetInputSet([]pendingInput{input1, input2}) + rt.ErrorContains(err, "inputs have different deadline heights") + rt.Nil(set) + + // Pass a slice of inputs that only one input has the deadline height. + set, err = NewBudgetInputSet([]pendingInput{input0, input2}) + rt.NoError(err) + rt.NotNil(set) + + // Pass a slice of inputs that are duplicates. + set, err = NewBudgetInputSet([]pendingInput{input1, input1}) + rt.ErrorContains(err, "duplicate inputs") + rt.Nil(set) +} + +// TestBudgetInputSetAddInput checks that `addInput` correctly updates the +// budget of the input set. +func TestBudgetInputSetAddInput(t *testing.T) { + t.Parallel() + + // Create a testing input with a budget of 100 satoshis. + input := createP2WKHInput(1000) + pi := &pendingInput{ + Input: input, + params: Params{ + Budget: 100, + }, + } + + // Initialize an input set, which adds the above input. + set, err := NewBudgetInputSet([]pendingInput{*pi}) + require.NoError(t, err) + + // Add the input to the set again. + set.addInput(*pi) + + // The set should now have two inputs. + require.Len(t, set.inputs, 2) + require.Equal(t, pi, set.inputs[0]) + require.Equal(t, pi, set.inputs[1]) + + // The set should have a budget of 200 satoshis. + require.Equal(t, btcutil.Amount(200), set.Budget()) +} + +// TestNeedWalletInput checks that NeedWalletInput correctly determines if a +// wallet input is needed. +func TestNeedWalletInput(t *testing.T) { + t.Parallel() + + // Create a mock input that doesn't have required outputs. + mockInput := &input.MockInput{} + mockInput.On("RequiredTxOut").Return(nil) + defer mockInput.AssertExpectations(t) + + // Create a mock input that has required outputs. + mockInputRequireOutput := &input.MockInput{} + mockInputRequireOutput.On("RequiredTxOut").Return(&wire.TxOut{}) + defer mockInputRequireOutput.AssertExpectations(t) + + // We now create two pending inputs each has a budget of 100 satoshis. + const budget = 100 + + // Create the pending input that doesn't have a required output. + piBudget := &pendingInput{ + Input: mockInput, + params: Params{Budget: budget}, + } + + // Create the pending input that has a required output. + piRequireOutput := &pendingInput{ + Input: mockInputRequireOutput, + params: Params{Budget: budget}, + } + + testCases := []struct { + name string + setupInputs func() []*pendingInput + need bool + }{ + { + // When there are no pending inputs, we won't need a + // wallet input. Technically this should be an invalid + // state. + name: "no inputs", + setupInputs: func() []*pendingInput { + return nil + }, + need: false, + }, + { + // When there's no required output, we don't need a + // wallet input. + name: "no required outputs", + setupInputs: func() []*pendingInput { + // Create a sign descriptor to be used in the + // pending input when calculating budgets can + // be borrowed. + sd := &input.SignDescriptor{ + Output: &wire.TxOut{ + Value: budget, + }, + } + mockInput.On("SignDesc").Return(sd).Once() + + return []*pendingInput{piBudget} + }, + need: false, + }, + { + // When the output value cannot cover the budget, we + // need a wallet input. + name: "output value cannot cover budget", + setupInputs: func() []*pendingInput { + // Create a sign descriptor to be used in the + // pending input when calculating budgets can + // be borrowed. + sd := &input.SignDescriptor{ + Output: &wire.TxOut{ + Value: budget - 1, + }, + } + mockInput.On("SignDesc").Return(sd).Once() + + // These two methods are only invoked when the + // unit test is running with a logger. + mockInput.On("OutPoint").Return( + &wire.OutPoint{Hash: chainhash.Hash{1}}, + ).Maybe() + mockInput.On("WitnessType").Return( + input.CommitmentAnchor, + ).Maybe() + + return []*pendingInput{piBudget} + }, + need: true, + }, + { + // When there's only inputs that require outputs, we + // need wallet inputs. + name: "only required outputs", + setupInputs: func() []*pendingInput { + return []*pendingInput{piRequireOutput} + }, + need: true, + }, + { + // When there's a mix of inputs, but the borrowable + // budget cannot cover the required, we need a wallet + // input. + name: "not enough budget to be borrowed", + setupInputs: func() []*pendingInput { + // Create a sign descriptor to be used in the + // pending input when calculating budgets can + // be borrowed. + // + // NOTE: the value is exactly the same as the + // budget so we can't borrow any more. + sd := &input.SignDescriptor{ + Output: &wire.TxOut{ + Value: budget, + }, + } + mockInput.On("SignDesc").Return(sd).Once() + + return []*pendingInput{ + piBudget, piRequireOutput, + } + }, + need: true, + }, + { + // When there's a mix of inputs, and the budget can be + // borrowed covers the required, we don't need wallet + // inputs. + name: "enough budget to be borrowed", + setupInputs: func() []*pendingInput { + // Create a sign descriptor to be used in the + // pending input when calculating budgets can + // be borrowed. + // + // NOTE: the value is exactly the same as the + // budget so we can't borrow any more. + sd := &input.SignDescriptor{ + Output: &wire.TxOut{ + Value: budget * 2, + }, + } + mockInput.On("SignDesc").Return(sd).Once() + piBudget.Input = mockInput + + return []*pendingInput{ + piBudget, piRequireOutput, + } + }, + need: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Setup testing inputs. + inputs := tc.setupInputs() + + // Initialize an input set, which adds the testing + // inputs. + set := &BudgetInputSet{inputs: inputs} + + result := set.NeedWalletInput() + require.Equal(t, tc.need, result) + }) + } +} + +// TestAddWalletInputReturnErr tests the three possible errors returned from +// AddWalletInputs: +// - error from ListUnspentWitnessFromDefaultAccount. +// - error from createWalletTxInput. +// - error when wallet doesn't have utxos. +func TestAddWalletInputReturnErr(t *testing.T) { + t.Parallel() + + wallet := &MockWallet{} + defer wallet.AssertExpectations(t) + + // Initialize an empty input set. + set := &BudgetInputSet{} + + // Specify the min and max confs used in + // ListUnspentWitnessFromDefaultAccount. + min, max := int32(1), int32(math.MaxInt32) + + // Mock the wallet to return an error. + dummyErr := errors.New("dummy error") + wallet.On("ListUnspentWitnessFromDefaultAccount", + min, max).Return(nil, dummyErr).Once() + + // Check that the error is returned from + // ListUnspentWitnessFromDefaultAccount. + err := set.AddWalletInputs(wallet) + require.ErrorIs(t, err, dummyErr) + + // Create an utxo with unknown address type to trigger an error. + utxo := &lnwallet.Utxo{ + AddressType: lnwallet.UnknownAddressType, + } + + // Mock the wallet to return the above utxo. + wallet.On("ListUnspentWitnessFromDefaultAccount", + min, max).Return([]*lnwallet.Utxo{utxo}, nil).Once() + + // Check that the error is returned from createWalletTxInput. + err = set.AddWalletInputs(wallet) + require.Error(t, err) + + // Mock the wallet to return empty utxos. + wallet.On("ListUnspentWitnessFromDefaultAccount", + min, max).Return([]*lnwallet.Utxo{}, nil).Once() + + // Check that the error is returned from not having wallet inputs. + err = set.AddWalletInputs(wallet) + require.ErrorIs(t, err, ErrNotEnoughInputs) +} + +// TestAddWalletInputNotEnoughInputs checks that when there are not enough +// wallet utxos, an error is returned and the budget set is reset to its +// initial state. +func TestAddWalletInputNotEnoughInputs(t *testing.T) { + t.Parallel() + + wallet := &MockWallet{} + defer wallet.AssertExpectations(t) + + // Specify the min and max confs used in + // ListUnspentWitnessFromDefaultAccount. + min, max := int32(1), int32(math.MaxInt32) + + // Assume the desired budget is 10k satoshis. + const budget = 10_000 + + // Create a mock input that has required outputs. + mockInput := &input.MockInput{} + mockInput.On("RequiredTxOut").Return(&wire.TxOut{}) + defer mockInput.AssertExpectations(t) + + // Create a pending input that requires 10k satoshis. + pi := &pendingInput{ + Input: mockInput, + params: Params{Budget: budget}, + } + + // Create a wallet utxo that cannot cover the budget. + utxo := &lnwallet.Utxo{ + AddressType: lnwallet.WitnessPubKey, + Value: budget - 1, + } + + // Mock the wallet to return the above utxo. + wallet.On("ListUnspentWitnessFromDefaultAccount", + min, max).Return([]*lnwallet.Utxo{utxo}, nil).Once() + + // Initialize an input set with the pending input. + set := BudgetInputSet{inputs: []*pendingInput{pi}} + + // Add wallet inputs to the input set, which should give us an error as + // the wallet cannot cover the budget. + err := set.AddWalletInputs(wallet) + require.ErrorIs(t, err, ErrNotEnoughInputs) + + // Check that the budget set is reverted to its initial state. + require.Len(t, set.inputs, 1) + require.Equal(t, pi, set.inputs[0]) +} + +// TestAddWalletInputSuccess checks that when there are enough wallet utxos, +// they are added to the input set. +func TestAddWalletInputSuccess(t *testing.T) { + t.Parallel() + + wallet := &MockWallet{} + defer wallet.AssertExpectations(t) + + // Specify the min and max confs used in + // ListUnspentWitnessFromDefaultAccount. + min, max := int32(1), int32(math.MaxInt32) + + // Assume the desired budget is 10k satoshis. + const budget = 10_000 + + // Create a mock input that has required outputs. + mockInput := &input.MockInput{} + mockInput.On("RequiredTxOut").Return(&wire.TxOut{}) + defer mockInput.AssertExpectations(t) + + // Create a pending input that requires 10k satoshis. + deadline := int32(1000) + pi := &pendingInput{ + Input: mockInput, + params: Params{ + Budget: budget, + DeadlineHeight: fn.Some(deadline), + }, + } + + // Mock methods used in loggings. + // + // NOTE: these methods are not functional as they are only used for + // loggings in debug or trace mode so we use arbitrary values. + mockInput.On("OutPoint").Return(&wire.OutPoint{Hash: chainhash.Hash{1}}) + mockInput.On("WitnessType").Return(input.CommitmentAnchor) + + // Create a wallet utxo that cannot cover the budget. + utxo := &lnwallet.Utxo{ + AddressType: lnwallet.WitnessPubKey, + Value: budget - 1, + } + + // Mock the wallet to return the two utxos which can cover the budget. + wallet.On("ListUnspentWitnessFromDefaultAccount", + min, max).Return([]*lnwallet.Utxo{utxo, utxo}, nil).Once() + + // Initialize an input set with the pending input. + set, err := NewBudgetInputSet([]pendingInput{*pi}) + require.NoError(t, err) + + // Add wallet inputs to the input set, which should give us an error as + // the wallet cannot cover the budget. + err = set.AddWalletInputs(wallet) + require.NoError(t, err) + + // Check that the budget set is updated. + require.Len(t, set.inputs, 3) + + // The first input is the pending input. + require.Equal(t, pi, set.inputs[0]) + + // The second and third inputs are wallet inputs that have + // DeadlineHeight set. + input2Deadline := set.inputs[1].params.DeadlineHeight + require.Equal(t, deadline, input2Deadline.UnsafeFromSome()) + input3Deadline := set.inputs[2].params.DeadlineHeight + require.Equal(t, deadline, input3Deadline.UnsafeFromSome()) + + // Finally, check the interface methods. + require.EqualValues(t, budget, set.Budget()) + require.Equal(t, deadline, set.DeadlineHeight().UnsafeFromSome()) + // Weak check, a strong check is to open the slice and check each item. + require.Len(t, set.inputs, 3) +}