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.
This commit is contained in:
yyforyongyu 2024-02-27 17:52:47 +08:00
parent bd5eec8e1f
commit e7400f6a94
No known key found for this signature in database
GPG key ID: 9BCD95C4FF296868
3 changed files with 723 additions and 0 deletions

View file

@ -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.

View file

@ -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
}

View file

@ -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)
}