sweep: make sweeper aware of unconfirmed parent transactions.

Extend the fee estimator to take into account parent transactions with
their weights and fees.

Do not try to cpfp parent transactions that have a higher fee rate than
the sweep tx fee rate.
This commit is contained in:
Joost Jager 2020-09-04 11:28:17 +02:00
parent 3e3d8487fb
commit 681496b474
No known key found for this signature in database
GPG Key ID: A61B9D4C393C59C7
13 changed files with 334 additions and 30 deletions

View File

@ -914,6 +914,11 @@ func (bo *breachedOutput) HeightHint() uint32 {
return bo.confHeight return bo.confHeight
} }
// UnconfParent returns information about a possibly unconfirmed parent tx.
func (bo *breachedOutput) UnconfParent() *input.TxInfo {
return nil
}
// Add compile-time constraint ensuring breachedOutput implements the Input // Add compile-time constraint ensuring breachedOutput implements the Input
// interface. // interface.
var _ input.Input = (*breachedOutput)(nil) var _ input.Input = (*breachedOutput)(nil)

View File

@ -99,6 +99,7 @@ func (c *anchorResolver) Resolve() (ContractResolver, error) {
input.CommitmentAnchor, input.CommitmentAnchor,
&c.anchorSignDescriptor, &c.anchorSignDescriptor,
c.broadcastHeight, c.broadcastHeight,
nil,
) )
resultChan, err := c.Sweeper.SweepInput( resultChan, err := c.Sweeper.SweepInput(

View File

@ -1073,6 +1073,7 @@ func (c *ChannelArbitrator) sweepAnchors(anchors []*lnwallet.AnchorResolution,
input.CommitmentAnchor, input.CommitmentAnchor,
&anchor.AnchorSignDescriptor, &anchor.AnchorSignDescriptor,
heightHint, heightHint,
nil,
) )
// Sweep anchor output with the minimum fee rate. This usually // Sweep anchor output with the minimum fee rate. This usually

View File

@ -3,6 +3,7 @@ package input
import ( import (
"github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/txscript"
"github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcd/wire"
"github.com/btcsuite/btcutil"
) )
// Input represents an abstract UTXO which is to be spent using a sweeping // Input represents an abstract UTXO which is to be spent using a sweeping
@ -41,6 +42,19 @@ type Input interface {
// HeightHint returns the minimum height at which a confirmed spending // HeightHint returns the minimum height at which a confirmed spending
// tx can occur. // tx can occur.
HeightHint() uint32 HeightHint() uint32
// UnconfParent returns information about a possibly unconfirmed parent
// tx.
UnconfParent() *TxInfo
}
// TxInfo describes properties of a parent tx that are relevant for CPFP.
type TxInfo struct {
// Fee is the fee of the tx.
Fee btcutil.Amount
// Weight is the weight of the tx.
Weight int64
} }
type inputKit struct { type inputKit struct {
@ -49,6 +63,10 @@ type inputKit struct {
signDesc SignDescriptor signDesc SignDescriptor
heightHint uint32 heightHint uint32
blockToMaturity uint32 blockToMaturity uint32
// unconfParent contains information about a potential unconfirmed
// parent transaction.
unconfParent *TxInfo
} }
// OutPoint returns the breached output's identifier that is to be included as // OutPoint returns the breached output's identifier that is to be included as
@ -82,6 +100,11 @@ func (i *inputKit) BlocksToMaturity() uint32 {
return i.blockToMaturity return i.blockToMaturity
} }
// Cpfp returns information about a possibly unconfirmed parent tx.
func (i *inputKit) UnconfParent() *TxInfo {
return i.unconfParent
}
// BaseInput contains all the information needed to sweep a basic output // BaseInput contains all the information needed to sweep a basic output
// (CSV/CLTV/no time lock) // (CSV/CLTV/no time lock)
type BaseInput struct { type BaseInput struct {
@ -91,14 +114,16 @@ type BaseInput struct {
// MakeBaseInput assembles a new BaseInput that can be used to construct a // MakeBaseInput assembles a new BaseInput that can be used to construct a
// sweep transaction. // sweep transaction.
func MakeBaseInput(outpoint *wire.OutPoint, witnessType WitnessType, func MakeBaseInput(outpoint *wire.OutPoint, witnessType WitnessType,
signDescriptor *SignDescriptor, heightHint uint32) BaseInput { signDescriptor *SignDescriptor, heightHint uint32,
unconfParent *TxInfo) BaseInput {
return BaseInput{ return BaseInput{
inputKit{ inputKit{
outpoint: *outpoint, outpoint: *outpoint,
witnessType: witnessType, witnessType: witnessType,
signDesc: *signDescriptor, signDesc: *signDescriptor,
heightHint: heightHint, heightHint: heightHint,
unconfParent: unconfParent,
}, },
} }
} }
@ -109,7 +134,7 @@ func NewBaseInput(outpoint *wire.OutPoint, witnessType WitnessType,
signDescriptor *SignDescriptor, heightHint uint32) *BaseInput { signDescriptor *SignDescriptor, heightHint uint32) *BaseInput {
input := MakeBaseInput( input := MakeBaseInput(
outpoint, witnessType, signDescriptor, heightHint, outpoint, witnessType, signDescriptor, heightHint, nil,
) )
return &input return &input

View File

@ -505,8 +505,11 @@ func (s *UtxoSweeper) collector(blockEpochs <-chan *chainntnfs.BlockEpoch) {
log.Debugf("Already pending input %v received", log.Debugf("Already pending input %v received",
outpoint) outpoint)
// Update sweep parameters. // Update input details and sweep parameters.
// The re-offered input details may contain a
// change to the unconfirmed parent tx info.
pendInput.params = input.params pendInput.params = input.params
pendInput.Input = input.input
// Add additional result channel to signal // Add additional result channel to signal
// spend of this input. // spend of this input.
@ -763,6 +766,26 @@ func (s *UtxoSweeper) clusterBySweepFeeRate() []inputCluster {
log.Warnf("Skipping input %v: %v", op, err) log.Warnf("Skipping input %v: %v", op, err)
continue continue
} }
// Only try to sweep inputs with an unconfirmed parent if the
// current sweep fee rate exceeds the parent tx fee rate. This
// assumes that such inputs are offered to the sweeper solely
// for the purpose of anchoring down the parent tx using cpfp.
parentTx := input.UnconfParent()
if parentTx != nil {
parentFeeRate :=
chainfee.SatPerKWeight(parentTx.Fee*1000) /
chainfee.SatPerKWeight(parentTx.Weight)
if parentFeeRate >= feeRate {
log.Debugf("Skipping cpfp input %v: fee_rate=%v, "+
"parent_fee_rate=%v", op, feeRate,
parentFeeRate)
continue
}
}
feeGroup := s.bucketForFeeRate(feeRate) feeGroup := s.bucketForFeeRate(feeRate)
// Create a bucket list for this fee rate if there isn't one // Create a bucket list for this fee rate if there isn't one

View File

@ -17,6 +17,7 @@ import (
"github.com/lightningnetwork/lnd/lntest/mock" "github.com/lightningnetwork/lnd/lntest/mock"
"github.com/lightningnetwork/lnd/lnwallet" "github.com/lightningnetwork/lnd/lnwallet"
"github.com/lightningnetwork/lnd/lnwallet/chainfee" "github.com/lightningnetwork/lnd/lnwallet/chainfee"
"github.com/stretchr/testify/require"
) )
var ( var (
@ -77,6 +78,7 @@ func createTestInput(value int64, witnessType input.WitnessType) input.BaseInput
}, },
}, },
0, 0,
nil,
) )
testInputCount++ testInputCount++
@ -176,6 +178,18 @@ func (ctx *sweeperTestContext) tick() {
} }
} }
// assertNoTick asserts that the sweeper does not wait for a tick.
func (ctx *sweeperTestContext) assertNoTick() {
ctx.t.Helper()
select {
case <-ctx.timeoutChan:
ctx.t.Fatal("unexpected tick")
case <-time.After(processingDelay):
}
}
func (ctx *sweeperTestContext) assertNoNewTimer() { func (ctx *sweeperTestContext) assertNoNewTimer() {
select { select {
case <-ctx.timeoutChan: case <-ctx.timeoutChan:
@ -337,9 +351,10 @@ func assertTxFeeRate(t *testing.T, tx *wire.MsgTx,
outputAmt := tx.TxOut[0].Value outputAmt := tx.TxOut[0].Value
fee := btcutil.Amount(inputAmt - outputAmt) fee := btcutil.Amount(inputAmt - outputAmt)
_, txWeight := getWeightEstimate(inputs) _, estimator := getWeightEstimate(inputs, 0)
txWeight := estimator.weight()
expectedFee := expectedFeeRate.FeeForWeight(txWeight) expectedFee := expectedFeeRate.FeeForWeight(int64(txWeight))
if fee != expectedFee { if fee != expectedFee {
t.Fatalf("expected fee rate %v results in %v fee, got %v fee", t.Fatalf("expected fee rate %v results in %v fee, got %v fee",
expectedFeeRate, expectedFee, fee) expectedFeeRate, expectedFee, fee)
@ -1293,3 +1308,71 @@ func TestExclusiveGroup(t *testing.T) {
t.Fatal("expected third input to be canceled") t.Fatal("expected third input to be canceled")
} }
} }
// TestCpfp tests that the sweeper spends cpfp inputs at a fee rate that exceeds
// the parent tx fee rate.
func TestCpfp(t *testing.T) {
ctx := createSweeperTestContext(t)
ctx.estimator.updateFees(1000, chainfee.FeePerKwFloor)
// Offer an input with an unconfirmed parent tx to the sweeper. The
// parent tx pays 3000 sat/kw.
hash := chainhash.Hash{1}
input := input.MakeBaseInput(
&wire.OutPoint{Hash: hash},
input.CommitmentTimeLock,
&input.SignDescriptor{
Output: &wire.TxOut{
Value: 330,
},
KeyDesc: keychain.KeyDescriptor{
PubKey: testPubKey,
},
},
0,
&input.TxInfo{
Weight: 300,
Fee: 900,
},
)
feePref := FeePreference{ConfTarget: 6}
result, err := ctx.sweeper.SweepInput(
&input, Params{Fee: feePref, Force: true},
)
require.NoError(t, err)
// Because we sweep at 1000 sat/kw, the parent cannot be paid for. We
// expect the sweeper to remain idle.
ctx.assertNoTick()
// Increase the fee estimate to above the parent tx fee rate.
ctx.estimator.updateFees(5000, chainfee.FeePerKwFloor)
// Signal a new block. This is a trigger for the sweeper to refresh fee
// estimates.
ctx.notifier.NotifyEpoch(1000)
// Now we do expect a sweep transaction to be published with our input
// and an attached wallet utxo.
ctx.tick()
tx := ctx.receiveTx()
require.Len(t, tx.TxIn, 2)
require.Len(t, tx.TxOut, 1)
// As inputs we have 10000 sats from the wallet and 330 sats from the
// cpfp input. The sweep tx is weight expected to be 759 units. There is
// an additional 300 weight units from the parent to include in the
// package, making a total of 1059. At 5000 sat/kw, the required fee for
// the package is 5295 sats. The parent already paid 900 sats, so there
// is 4395 sat remaining to be paid. The expected output value is
// therefore: 10000 + 330 - 4395 = 5935.
require.Equal(t, int64(5935), tx.TxOut[0].Value)
// Mine the tx and assert that the result is passed back.
ctx.backend.mine()
ctx.expectResult(result, nil)
ctx.finish(1)
}

View File

@ -13,6 +13,7 @@ import (
var ( var (
defaultTestTimeout = 5 * time.Second defaultTestTimeout = 5 * time.Second
processingDelay = 1 * time.Second
mockChainHash, _ = chainhash.NewHashFromStr("00aabbccddeeff") mockChainHash, _ = chainhash.NewHashFromStr("00aabbccddeeff")
mockChainHeight = int32(100) mockChainHeight = int32(100)
) )

View File

@ -71,9 +71,6 @@ func (t *txInputSetState) clone() txInputSetState {
type txInputSet struct { type txInputSet struct {
txInputSetState txInputSetState
// feePerKW is the fee rate used to calculate the tx fee.
feePerKW chainfee.SatPerKWeight
// dustLimit is the minimum output value of the tx. // dustLimit is the minimum output value of the tx.
dustLimit btcutil.Amount dustLimit btcutil.Amount
@ -96,11 +93,10 @@ func newTxInputSet(wallet Wallet, feePerKW,
) )
state := txInputSetState{ state := txInputSetState{
weightEstimate: newWeightEstimator(), weightEstimate: newWeightEstimator(feePerKW),
} }
b := txInputSet{ b := txInputSet{
feePerKW: feePerKW,
dustLimit: dustLimit, dustLimit: dustLimit,
maxInputs: maxInputs, maxInputs: maxInputs,
wallet: wallet, wallet: wallet,
@ -146,8 +142,7 @@ func (t *txInputSet) addToState(inp input.Input, constraints addConstraints) *tx
s.inputTotal += value s.inputTotal += value
// Recalculate the tx fee. // Recalculate the tx fee.
weight := s.weightEstimate.weight() fee := s.weightEstimate.fee()
fee := t.feePerKW.FeeForWeight(int64(weight))
// Calculate the new output value. // Calculate the new output value.
s.outputValue = s.inputTotal - fee s.outputValue = s.inputTotal - fee

View File

@ -134,9 +134,9 @@ func createSweepTx(inputs []input.Input, outputPkScript []byte,
currentBlockHeight uint32, feePerKw chainfee.SatPerKWeight, currentBlockHeight uint32, feePerKw chainfee.SatPerKWeight,
signer input.Signer) (*wire.MsgTx, error) { signer input.Signer) (*wire.MsgTx, error) {
inputs, txWeight := getWeightEstimate(inputs) inputs, estimator := getWeightEstimate(inputs, feePerKw)
txFee := feePerKw.FeeForWeight(txWeight) txFee := estimator.fee()
// Sum up the total value contained in the inputs. // Sum up the total value contained in the inputs.
var totalSum btcutil.Amount var totalSum btcutil.Amount
@ -208,21 +208,29 @@ func createSweepTx(inputs []input.Input, outputPkScript []byte,
} }
log.Infof("Creating sweep transaction %v for %v inputs (%s) "+ log.Infof("Creating sweep transaction %v for %v inputs (%s) "+
"using %v sat/kw, tx_fee=%v", sweepTx.TxHash(), len(inputs), "using %v sat/kw, tx_weight=%v, tx_fee=%v, parents_count=%v, "+
inputTypeSummary(inputs), int64(feePerKw), txFee) "parents_fee=%v, parents_weight=%v",
sweepTx.TxHash(), len(inputs),
inputTypeSummary(inputs), int64(feePerKw),
estimator.weight(), txFee,
len(estimator.parents), estimator.parentsFee,
estimator.parentsWeight,
)
return sweepTx, nil return sweepTx, nil
} }
// getWeightEstimate returns a weight estimate for the given inputs. // getWeightEstimate returns a weight estimate for the given inputs.
// Additionally, it returns counts for the number of csv and cltv inputs. // Additionally, it returns counts for the number of csv and cltv inputs.
func getWeightEstimate(inputs []input.Input) ([]input.Input, int64) { func getWeightEstimate(inputs []input.Input, feeRate chainfee.SatPerKWeight) (
[]input.Input, *weightEstimator) {
// We initialize a weight estimator so we can accurately asses the // We initialize a weight estimator so we can accurately asses the
// amount of fees we need to pay for this sweep transaction. // amount of fees we need to pay for this sweep transaction.
// //
// TODO(roasbeef): can be more intelligent about buffering outputs to // TODO(roasbeef): can be more intelligent about buffering outputs to
// be more efficient on-chain. // be more efficient on-chain.
weightEstimate := newWeightEstimator() weightEstimate := newWeightEstimator(feeRate)
// Our sweep transaction will pay to a single segwit p2wkh address, // Our sweep transaction will pay to a single segwit p2wkh address,
// ensure it contributes to our weight estimate. // ensure it contributes to our weight estimate.
@ -247,7 +255,7 @@ func getWeightEstimate(inputs []input.Input) ([]input.Input, int64) {
sweepInputs = append(sweepInputs, inp) sweepInputs = append(sweepInputs, inp)
} }
return sweepInputs, int64(weightEstimate.weight()) return sweepInputs, weightEstimate
} }
// inputSummary returns a string containing a human readable summary about the // inputSummary returns a string containing a human readable summary about the

View File

@ -39,7 +39,8 @@ func TestWeightEstimate(t *testing.T) {
)) ))
} }
_, weight := getWeightEstimate(inputs) _, estimator := getWeightEstimate(inputs, 0)
weight := int64(estimator.weight())
if weight != expectedWeight { if weight != expectedWeight {
t.Fatalf("unexpected weight. expected %d but got %d.", t.Fatalf("unexpected weight. expected %d but got %d.",
expectedWeight, weight) expectedWeight, weight)

View File

@ -255,7 +255,9 @@ func CraftSweepAllTx(feeRate chainfee.SatPerKWeight, blockHeight uint32,
// Now that we've constructed the items required, we'll make an // Now that we've constructed the items required, we'll make an
// input which can be passed to the sweeper for ultimate // input which can be passed to the sweeper for ultimate
// sweeping. // sweeping.
input := input.MakeBaseInput(&output.OutPoint, witnessType, signDesc, 0) input := input.MakeBaseInput(
&output.OutPoint, witnessType, signDesc, 0, nil,
)
inputsToSweep = append(inputsToSweep, &input) inputsToSweep = append(inputsToSweep, &input)
} }

View File

@ -1,33 +1,91 @@
package sweep package sweep
import ( import (
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcutil"
"github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/input"
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
) )
// weightEstimator wraps a standard weight estimator instance. // weightEstimator wraps a standard weight estimator instance and adds to that
// support for child-pays-for-parent.
type weightEstimator struct { type weightEstimator struct {
estimator input.TxWeightEstimator estimator input.TxWeightEstimator
feeRate chainfee.SatPerKWeight
parents map[chainhash.Hash]struct{}
parentsFee btcutil.Amount
parentsWeight int64
} }
// newWeightEstimator instantiates a new sweeper weight estimator. // newWeightEstimator instantiates a new sweeper weight estimator.
func newWeightEstimator() *weightEstimator { func newWeightEstimator(feeRate chainfee.SatPerKWeight) *weightEstimator {
return &weightEstimator{} return &weightEstimator{
feeRate: feeRate,
parents: make(map[chainhash.Hash]struct{}),
}
} }
// clone returns a copy of this weight estimator. // clone returns a copy of this weight estimator.
func (w *weightEstimator) clone() *weightEstimator { func (w *weightEstimator) clone() *weightEstimator {
parents := make(map[chainhash.Hash]struct{}, len(w.parents))
for hash := range w.parents {
parents[hash] = struct{}{}
}
return &weightEstimator{ return &weightEstimator{
estimator: w.estimator, estimator: w.estimator,
feeRate: w.feeRate,
parents: parents,
parentsFee: w.parentsFee,
parentsWeight: w.parentsWeight,
} }
} }
// add adds the weight of the given input to the weight estimate. // add adds the weight of the given input to the weight estimate.
func (w *weightEstimator) add(inp input.Input) error { func (w *weightEstimator) add(inp input.Input) error {
// If there is a parent tx, add the parent's fee and weight.
w.tryAddParent(inp)
wt := inp.WitnessType() wt := inp.WitnessType()
return wt.AddWeightEstimation(&w.estimator) return wt.AddWeightEstimation(&w.estimator)
} }
// tryAddParent examines the input and updates parent tx totals if required for
// cpfp.
func (w *weightEstimator) tryAddParent(inp input.Input) {
// Get unconfirmed parent info from the input.
unconfParent := inp.UnconfParent()
// If there is no parent, there is nothing to add.
if unconfParent == nil {
return
}
// If we've already accounted for the parent tx, don't do it
// again. This can happens when two outputs of the parent tx are
// included in the same sweep tx.
parentHash := inp.OutPoint().Hash
if _, ok := w.parents[parentHash]; ok {
return
}
// Calculate parent fee rate.
parentFeeRate := chainfee.SatPerKWeight(unconfParent.Fee) * 1000 /
chainfee.SatPerKWeight(unconfParent.Weight)
// Ignore parents that pay at least the fee rate of this transaction.
// Parent pays for child is not happening.
if parentFeeRate >= w.feeRate {
return
}
// Include parent.
w.parents[parentHash] = struct{}{}
w.parentsFee += unconfParent.Fee
w.parentsWeight += unconfParent.Weight
}
// addP2WKHOutput updates the weight estimate to account for an additional // addP2WKHOutput updates the weight estimate to account for an additional
// native P2WKH output. // native P2WKH output.
func (w *weightEstimator) addP2WKHOutput() { func (w *weightEstimator) addP2WKHOutput() {
@ -38,3 +96,25 @@ func (w *weightEstimator) addP2WKHOutput() {
func (w *weightEstimator) weight() int { func (w *weightEstimator) weight() int {
return w.estimator.Weight() return w.estimator.Weight()
} }
// fee returns the tx fee to use for the aggregated inputs and outputs, taking
// into account unconfirmed parent transactions (cpfp).
func (w *weightEstimator) fee() btcutil.Amount {
// Calculate fee and weight for just this tx.
childWeight := int64(w.estimator.Weight())
// Add combined weight of unconfirmed parent txes.
totalWeight := childWeight + w.parentsWeight
// Subtract fee already paid by parents.
fee := w.feeRate.FeeForWeight(totalWeight) - w.parentsFee
// Clamp the fee to what would be required if no parent txes were paid
// for. This is to make sure no rounding errors can get us into trouble.
childFee := w.feeRate.FeeForWeight(childWeight)
if childFee > fee {
fee = childFee
}
return fee
}

View File

@ -0,0 +1,79 @@
package sweep
import (
"testing"
"github.com/btcsuite/btcd/wire"
"github.com/lightningnetwork/lnd/input"
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
"github.com/stretchr/testify/require"
)
// TestWeightEstimator tests weight estimation for inputs with and without
// unconfirmed parents.
func TestWeightEstimator(t *testing.T) {
testFeeRate := chainfee.SatPerKWeight(20000)
w := newWeightEstimator(testFeeRate)
// Add an input without unconfirmed parent tx.
input1 := input.MakeBaseInput(
&wire.OutPoint{}, input.CommitmentAnchor,
&input.SignDescriptor{}, 0, nil,
)
require.NoError(t, w.add(&input1))
// The expectations is that this input is added.
const expectedWeight1 = 322
require.Equal(t, expectedWeight1, w.weight())
require.Equal(t, testFeeRate.FeeForWeight(expectedWeight1), w.fee())
// Define a parent transaction that pays a fee of 30000 sat/kw.
parentTxHighFee := &input.TxInfo{
Weight: 100,
Fee: 3000,
}
// Add an output of the parent tx above.
input2 := input.MakeBaseInput(
&wire.OutPoint{}, input.CommitmentAnchor,
&input.SignDescriptor{}, 0,
parentTxHighFee,
)
require.NoError(t, w.add(&input2))
// Pay for parent isn't possible because the parent pays a higher fee
// rate than the child. We expect no additional fee on the child.
const expectedWeight2 = expectedWeight1 + 280
require.Equal(t, expectedWeight2, w.weight())
require.Equal(t, testFeeRate.FeeForWeight(expectedWeight2), w.fee())
// Define a parent transaction that pays a fee of 10000 sat/kw.
parentTxLowFee := &input.TxInfo{
Weight: 100,
Fee: 1000,
}
// Add an output of the low-fee parent tx above.
input3 := input.MakeBaseInput(
&wire.OutPoint{}, input.CommitmentAnchor,
&input.SignDescriptor{}, 0,
parentTxLowFee,
)
require.NoError(t, w.add(&input3))
// Expect the weight to increase because of the third input.
const expectedWeight3 = expectedWeight2 + 280
require.Equal(t, expectedWeight3, w.weight())
// Expect the fee to cover the child and the parent transaction at 20
// sat/kw after subtraction of the fee that was already paid by the
// parent.
expectedFee := testFeeRate.FeeForWeight(
expectedWeight3+parentTxLowFee.Weight,
) - parentTxLowFee.Fee
require.Equal(t, expectedFee, w.fee())
}