Skip to content

Commit

Permalink
sweep: add wallet inputs to reach dust limit
Browse files Browse the repository at this point in the history
This commit allows sweeper to sweep inputs that on its own are not able
to form a sweep transaction that meets the dust limit.

This functionality is useful for sweeping small outputs. In the future,
this will be particularly important to sweep anchors. Anchors will
typically be spent with a relatively large fee to pay for the parent tx.
It will then be necessary to attach an additional wallet utxo.
  • Loading branch information
joostjager committed Dec 17, 2019
1 parent 8353b6f commit e01600f
Show file tree
Hide file tree
Showing 7 changed files with 299 additions and 34 deletions.
12 changes: 12 additions & 0 deletions sweep/backend_mock_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ type mockBackend struct {
unconfirmedSpendInputs map[wire.OutPoint]struct{}

publishChan chan wire.MsgTx

walletUtxos []*lnwallet.Utxo
}

func newMockBackend(t *testing.T, notifier *MockNotifier) *mockBackend {
Expand Down Expand Up @@ -84,6 +86,16 @@ func (b *mockBackend) PublishTransaction(tx *wire.MsgTx) error {
return err
}

func (b *mockBackend) ListUnspentWitness(minconfirms, maxconfirms int32) (
[]*lnwallet.Utxo, error) {

return b.walletUtxos, nil
}

func (b *mockBackend) WithCoinSelectLock(f func() error) error {
return f()
}

func (b *mockBackend) deleteUnconfirmed(txHash chainhash.Hash) {
b.lock.Lock()
defer b.lock.Unlock()
Expand Down
15 changes: 15 additions & 0 deletions sweep/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,26 @@ package sweep

import (
"github.com/btcsuite/btcd/wire"
"github.com/lightningnetwork/lnd/lnwallet"
)

// Wallet contains all wallet related functionality required by sweeper.
type Wallet interface {
// PublishTransaction performs cursory validation (dust checks, etc) and
// broadcasts the passed transaction to the Bitcoin network.
PublishTransaction(tx *wire.MsgTx) error

// ListUnspentWitness returns all unspent outputs which are version 0
// witness programs. The 'minconfirms' and 'maxconfirms' parameters
// indicate the minimum and maximum number of confirmations an output
// needs in order to be returned by this method.
ListUnspentWitness(minconfirms, maxconfirms int32) ([]*lnwallet.Utxo,
error)

// WithCoinSelectLock will execute the passed function closure in a
// synchronized manner preventing any coin selection operations from
// proceeding while the closure if executing. This can be seen as the
// ability to execute a function closure under an exclusive coin
// selection lock.
WithCoinSelectLock(f func() error) error
}
41 changes: 27 additions & 14 deletions sweep/sweeper.go
Original file line number Diff line number Diff line change
Expand Up @@ -632,21 +632,27 @@ func (s *UtxoSweeper) collector(blockEpochs <-chan *chainntnfs.BlockEpoch) {
func (s *UtxoSweeper) sweepCluster(cluster inputCluster,
currentHeight int32) error {

// Examine pending inputs and try to construct lists of inputs.
inputLists, err := s.getInputLists(cluster, currentHeight)
if err != nil {
return fmt.Errorf("unable to examine pending inputs: %v", err)
}

// Sweep selected inputs.
for _, inputs := range inputLists {
err := s.sweep(inputs, cluster.sweepFeeRate, currentHeight)
// Execute the sweep within a coin select lock. Otherwise the coins that
// we are going to spend may be selected for other transactions like
// funding of a channel.
return s.cfg.Wallet.WithCoinSelectLock(func() error {
// Examine pending inputs and try to construct
// lists of inputs.
inputLists, err := s.getInputLists(cluster, currentHeight)
if err != nil {
return fmt.Errorf("unable to sweep inputs: %v", err)
return fmt.Errorf("unable to examine pending inputs: %v", err)
}
}

return nil
// Sweep selected inputs.
for _, inputs := range inputLists {
err := s.sweep(inputs, cluster.sweepFeeRate, currentHeight)
if err != nil {
return fmt.Errorf("unable to sweep inputs: %v", err)
}
}

return nil
})
}

// bucketForFeeReate determines the proper bucket for a fee rate. This is done
Expand Down Expand Up @@ -718,6 +724,10 @@ func (s *UtxoSweeper) scheduleSweep(currentHeight int32) error {
startTimer := false
for _, cluster := range s.clusterBySweepFeeRate() {
// Examine pending inputs and try to construct lists of inputs.
// We don't need to obtain the coin selection lock, because we
// just need an indication as to whether we can sweep. More
// inputs may be added until we publish the transaction and
// coins that we select now may be used in other transactions.
inputLists, err := s.getInputLists(cluster, currentHeight)
if err != nil {
return fmt.Errorf("get input lists: %v", err)
Expand Down Expand Up @@ -823,6 +833,7 @@ func (s *UtxoSweeper) getInputLists(cluster inputCluster,
allSets, err = generateInputPartitionings(
append(retryInputs, newInputs...), s.relayFeeRate,
cluster.sweepFeeRate, s.cfg.MaxInputsPerTx,
s.cfg.Wallet,
)
if err != nil {
return nil, fmt.Errorf("input partitionings: %v", err)
Expand All @@ -832,7 +843,7 @@ func (s *UtxoSweeper) getInputLists(cluster inputCluster,
// Create sets for just the new inputs.
newSets, err := generateInputPartitionings(
newInputs, s.relayFeeRate, cluster.sweepFeeRate,
s.cfg.MaxInputsPerTx,
s.cfg.MaxInputsPerTx, s.cfg.Wallet,
)
if err != nil {
return nil, fmt.Errorf("input partitionings: %v", err)
Expand Down Expand Up @@ -908,7 +919,9 @@ func (s *UtxoSweeper) sweep(inputs inputSet, feeRate chainfee.SatPerKWeight,
if !ok {
// It can be that the input has been removed because it
// exceed the maximum number of attempts in a previous
// input set.
// input set. It could also be that this input is an
// additional wallet input that was attached. In that
// case there also isn't a pending input to update.
continue
}

Expand Down
55 changes: 54 additions & 1 deletion sweep/sweeper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,12 @@ func createSweeperTestContext(t *testing.T) *sweeperTestContext {
store := NewMockSweeperStore()

backend := newMockBackend(t, notifier)
backend.walletUtxos = []*lnwallet.Utxo{
{
Value: btcutil.Amount(10000),
AddressType: lnwallet.WitnessPubKey,
},
}

estimator := newMockFeeEstimator(10000, chainfee.FeePerKwFloor)

Expand Down Expand Up @@ -407,7 +413,10 @@ func TestDust(t *testing.T) {
}

// No sweep transaction is expected now. The sweeper should recognize
// that the sweep output will not be relayed and not generate the tx.
// that the sweep output will not be relayed and not generate the tx. It
// isn't possible to attach a wallet utxo either, because the added
// weight would create a negatively yielding transaction at this fee
// rate.

// Sweep another input that brings the tx output above the dust limit.
largeInput := createTestInput(100000, input.CommitmentTimeLock)
Expand All @@ -433,6 +442,50 @@ func TestDust(t *testing.T) {
ctx.finish(1)
}

// TestWalletUtxo asserts that inputs that are not big enough to raise above the
// dust limit are accompanied by a wallet utxo to make them sweepable.
func TestWalletUtxo(t *testing.T) {
ctx := createSweeperTestContext(t)

// Sweeping a single output produces a tx of 439 weight units. At the
// fee floor, the sweep tx will pay 439*253/1000 = 111 sat in fees.
//
// Create an input so that the output after paying fees is still
// positive (183 sat), but less than the dust limit (537 sat) for the
// sweep tx output script (P2WPKH).
//
// What we now expect is that the sweeper will attach a utxo from the
// wallet. This increases the tx weight to 712 units with a fee of 180
// sats. The tx yield becomes then 294-180 = 114 sats.
dustInput := createTestInput(294, input.WitnessKeyHash)

_, err := ctx.sweeper.SweepInput(
&dustInput,
Params{Fee: FeePreference{FeeRate: chainfee.FeePerKwFloor}},
)
if err != nil {
t.Fatal(err)
}

ctx.tick()

sweepTx := ctx.receiveTx()
if len(sweepTx.TxIn) != 2 {
t.Fatalf("Expected tx to sweep 2 inputs, but contains %v "+
"inputs instead", len(sweepTx.TxIn))
}

// Calculate expected output value based on wallet utxo of 10000 sats.
expectedOutputValue := int64(294 + 10000 - 180)
if sweepTx.TxOut[0].Value != expectedOutputValue {
t.Fatalf("Expected output value of %v, but got %v",
expectedOutputValue, sweepTx.TxOut[0].Value)
}

ctx.backend.mine()
ctx.finish(1)
}

// TestNegativeInput asserts that no inputs with a negative yield are swept.
// Negative yield means that the value minus the added fee is negative.
func TestNegativeInput(t *testing.T) {
Expand Down
128 changes: 122 additions & 6 deletions sweep/tx_input_set.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
package sweep

import (
"fmt"
"math"

"github.com/btcsuite/btcd/txscript"
"github.com/btcsuite/btcd/wire"
"github.com/btcsuite/btcutil"
"github.com/btcsuite/btcwallet/wallet/txrules"
"github.com/lightningnetwork/lnd/input"
"github.com/lightningnetwork/lnd/lnwallet"
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
)

Expand Down Expand Up @@ -32,11 +38,18 @@ type txInputSet struct {
// maxInputs is the maximum number of inputs that will be accepted in
// the set.
maxInputs int

// walletInputTotal is the total value of inputs coming from the wallet.
walletInputTotal btcutil.Amount

// wallet contains wallet functionality required by the input set to
// retrieve utxos.
wallet Wallet
}

// newTxInputSet constructs a new, empty input set.
func newTxInputSet(feePerKW, relayFee chainfee.SatPerKWeight,
maxInputs int) *txInputSet {
func newTxInputSet(wallet Wallet, feePerKW,
relayFee chainfee.SatPerKWeight, maxInputs int) *txInputSet {

dustLimit := txrules.GetDustThreshold(
input.P2WPKHSize,
Expand All @@ -47,6 +60,7 @@ func newTxInputSet(feePerKW, relayFee chainfee.SatPerKWeight,
feePerKW: feePerKW,
dustLimit: dustLimit,
maxInputs: maxInputs,
wallet: wallet,
}

// Add the sweep tx output to the weight estimate.
Expand All @@ -64,9 +78,10 @@ func (t *txInputSet) dustLimitReached() bool {
// add adds a new input to the set. It returns a bool indicating whether the
// input was added to the set. An input is rejected if it decreases the tx
// output value after paying fees.
func (t *txInputSet) add(input input.Input) bool {
// Stop if max inputs is reached.
if len(t.inputs) == t.maxInputs {
func (t *txInputSet) add(input input.Input, fromWallet bool) bool {
// Stop if max inputs is reached. Do not count additional wallet inputs,
// because we don't know in advance how many we may need.
if !fromWallet && len(t.inputs) >= t.maxInputs {
return false
}

Expand Down Expand Up @@ -100,6 +115,39 @@ func (t *txInputSet) add(input input.Input) bool {
return false
}

// If this input comes from the wallet, verify that we still gain
// something with this transaction.
if fromWallet {
// Calculate the total value that we spend in this tx from the
// wallet if we'd add this wallet input.
newWalletTotal := t.walletInputTotal + value

// In any case, we don't want to lose money by sweeping. If we
// don't get more out of the tx then we put in ourselves, do not
// add this wallet input.
//
// We should only add wallet inputs to get the tx output value
// above the dust limit, otherwise we'd only burn into fees.
// This is guarded by tryAddWalletInputsIfNeeded.
//
// TODO(joostjager): Possibly require a max ratio between the
// value of the wallet input and what we get out of this
// transaction. To prevent attaching and locking a big utxo for
// very little benefit.
if newWalletTotal >= newOutputValue {
log.Debugf("Rejecting wallet input of %v, because it "+
"would make a negative yielding transaction "+
"(%v)",
value, newOutputValue-newWalletTotal)

return false
}

// We've decided to add the wallet input. Increment the total
// wallet funds that go into this tx.
t.walletInputTotal = newWalletTotal
}

// Update running values.
t.inputTotal = newInputTotal
t.outputValue = newOutputValue
Expand All @@ -123,10 +171,78 @@ func (t *txInputSet) addPositiveYieldInputs(sweepableInputs []txInput) {
// succeed because it wouldn't increase the output value,
// return. Assuming inputs are sorted by yield, any further
// inputs wouldn't increase the output value either.
if !t.add(input) {
if !t.add(input, false) {
return
}
}

// We managed to add all inputs to the set.
}

// tryAddWalletInputsIfNeeded retrieves utxos from the wallet and tries adding as
// many as required to bring the tx output value above the given minimum.
func (t *txInputSet) tryAddWalletInputsIfNeeded() error {
// If we've already reached the dust limit, no action is needed.
if t.dustLimitReached() {
return nil
}

// Retrieve wallet utxos. Only consider confirmed utxos to prevent
// problems around RBF rules for unconfirmed inputs.
utxos, err := t.wallet.ListUnspentWitness(1, math.MaxInt32)
if err != nil {
return err
}

for _, utxo := range utxos {
input, err := createWalletTxInput(utxo)
if err != nil {
return err
}

// If the wallet input isn't positively-yielding at this fee
// rate, skip it.
if !t.add(input, true) {
continue
}

// Return if we've reached the minimum output amount.
if t.dustLimitReached() {
return nil
}
}

// We were not able to reach the minimum output amount.
return nil
}

// createWalletTxInput converts a wallet utxo into an object that can be added
// to the other inputs to sweep.
func createWalletTxInput(utxo *lnwallet.Utxo) (input.Input, error) {
var witnessType input.WitnessType
switch utxo.AddressType {
case lnwallet.WitnessPubKey:
witnessType = input.WitnessKeyHash
case lnwallet.NestedWitnessPubKey:
witnessType = input.NestedWitnessKeyHash
default:
return nil, fmt.Errorf("unknown address type %v",
utxo.AddressType)
}

signDesc := &input.SignDescriptor{
Output: &wire.TxOut{
PkScript: utxo.PkScript,
Value: int64(utxo.Value),
},
HashType: txscript.SigHashAll,
}

// A height hint doesn't need to be set, because we don't monitor these
// inputs for spend.
heightHint := uint32(0)

return input.NewBaseInput(
&utxo.OutPoint, witnessType, signDesc, heightHint,
), nil
}
Loading

0 comments on commit e01600f

Please sign in to comment.