Merge pull request #787 from starius/sweepbatcher-greedy-batch-selection

sweepbatcher: add greedy batch selection algorithm
This commit is contained in:
Boris Nagaev 2024-07-18 14:19:03 -03:00 committed by GitHub
commit 4c490398e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 1269 additions and 129 deletions

View file

@ -4,6 +4,7 @@ import (
"context"
"errors"
"math"
"os"
"testing"
"time"
@ -11,6 +12,7 @@ import (
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/wire"
"github.com/btcsuite/btclog"
"github.com/lightninglabs/lndclient"
"github.com/lightninglabs/loop/loopdb"
"github.com/lightninglabs/loop/sweep"
@ -255,6 +257,11 @@ func TestCustomSweepConfTarget(t *testing.T) {
func testCustomSweepConfTarget(t *testing.T) {
defer test.Guard(t)()
// Setup logger for sweepbatcher.
logger := btclog.NewBackend(os.Stdout).Logger("SWEEP")
logger.SetLevel(btclog.LevelTrace)
sweepbatcher.UseLogger(logger)
lnd := test.NewMockLnd()
ctx := test.NewContext(t, lnd)
server := newServerMock(lnd)

View file

@ -191,8 +191,8 @@ func (s *Sweeper) GetSweepFee(ctx context.Context,
return fee, err
}
// GetSweepFee calculates the required tx fee to spend to P2WKH. It takes a
// function that is expected to add the weight of the input to the weight
// GetSweepFeeDetails calculates the required tx fee to spend to P2WKH. It takes
// a function that is expected to add the weight of the input to the weight
// estimator. It returns also the fee rate and transaction weight.
func (s *Sweeper) GetSweepFeeDetails(ctx context.Context,
addInputEstimate func(*input.TxWeightEstimator) error,
@ -207,6 +207,30 @@ func (s *Sweeper) GetSweepFeeDetails(ctx context.Context,
// Calculate weight for this tx.
var weightEstimate input.TxWeightEstimator
// Add output.
if err := AddOutputEstimate(&weightEstimate, destAddr); err != nil {
return 0, 0, 0, fmt.Errorf("failed to add output weight "+
"estimate: %w", err)
}
// Add input.
err = addInputEstimate(&weightEstimate)
if err != nil {
return 0, 0, 0, fmt.Errorf("failed to add input weight "+
"estimate: %w", err)
}
// Find weight.
weight := weightEstimate.Weight()
return feeRate.FeeForWeight(weight), feeRate, weight, nil
}
// AddOutputEstimate adds output to weight estimator.
func AddOutputEstimate(weightEstimate *input.TxWeightEstimator,
destAddr btcutil.Address) error {
switch destAddr.(type) {
case *btcutil.AddressWitnessScriptHash:
weightEstimate.AddP2WSHOutput()
@ -224,16 +248,8 @@ func (s *Sweeper) GetSweepFeeDetails(ctx context.Context,
weightEstimate.AddP2TROutput()
default:
return 0, 0, 0, fmt.Errorf("estimate fee: unknown address "+
"type %T", destAddr)
return fmt.Errorf("unknown address type %T", destAddr)
}
err = addInputEstimate(&weightEstimate)
if err != nil {
return 0, 0, 0, err
}
weight := weightEstimate.Weight()
return feeRate.FeeForWeight(weight), feeRate, weight, nil
return nil
}

View file

@ -0,0 +1,341 @@
package sweepbatcher
import (
"context"
"errors"
"fmt"
"sort"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/txscript"
sweeppkg "github.com/lightninglabs/loop/sweep"
"github.com/lightningnetwork/lnd/input"
"github.com/lightningnetwork/lnd/lntypes"
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
)
// greedyAddSweep selects a batch for the sweep using the greedy algorithm,
// which minimizes costs, and adds the sweep to the batch. To accomplish this,
// it first collects fee details about the sweep being added, about a potential
// new batch composed of this sweep only, and about all existing batches. Then
// it passes the data to selectBatches() function, which emulates adding the
// sweep to each batch and creating new batch for the sweep, and calculates the
// costs of each alternative. Based on the estimates of selectBatches(), this
// method adds the sweep to the batch that results in the least overall fee
// increase, or creates new batch for it. If the sweep is not accepted by an
// existing batch (may happen because of too distant timeouts), next batch is
// tried in the list returned by selectBatches(). If adding fails or new batch
// creation fails, this method returns an error. If this method fails for any
// reason, the caller falls back to the simple algorithm (method handleSweep).
func (b *Batcher) greedyAddSweep(ctx context.Context, sweep *sweep) error {
// Collect weight and fee rate info about the sweep and new batch.
sweepFeeDetails, newBatchFeeDetails, err := estimateSweepFeeIncrement(
sweep,
)
if err != nil {
return fmt.Errorf("failed to estimate tx weight for "+
"sweep %x: %w", sweep.swapHash[:6], err)
}
// Collect weight and fee rate info about existing batches.
batches := make([]feeDetails, 0, len(b.batches))
for _, existingBatch := range b.batches {
batchFeeDetails, err := estimateBatchWeight(existingBatch)
if err != nil {
return fmt.Errorf("failed to estimate tx weight for "+
"batch %d: %w", existingBatch.id, err)
}
batches = append(batches, batchFeeDetails)
}
// Run the algorithm. Get batchId of possible batches, sorted from best
// to worst.
batchesIds, err := selectBatches(
batches, sweepFeeDetails, newBatchFeeDetails,
)
if err != nil {
return fmt.Errorf("batch selection algorithm failed for sweep "+
"%x: %w", sweep.swapHash[:6], err)
}
// Try batches, starting with the best.
for _, batchId := range batchesIds {
// If the best option is to start new batch, do it.
if batchId == newBatchSignal {
return b.spinUpNewBatch(ctx, sweep)
}
// Locate the batch to add the sweep to.
bestBatch, has := b.batches[batchId]
if !has {
return fmt.Errorf("batch selection algorithm returned "+
"batch id %d which doesn't exist, for sweep %x",
batchId, sweep.swapHash[:6])
}
// Add the sweep to the batch.
accepted, err := bestBatch.addSweep(ctx, sweep)
if err != nil {
return fmt.Errorf("batch selection algorithm returned "+
"batch id %d for sweep %x, but adding failed: "+
"%w", batchId, sweep.swapHash[:6], err)
}
if accepted {
return nil
}
log.Debugf("Batch selection algorithm returned batch id %d for"+
" sweep %x, but acceptance failed.", batchId,
sweep.swapHash[:6])
}
return fmt.Errorf("no batch accepted sweep %x", sweep.swapHash[:6])
}
// estimateSweepFeeIncrement returns fee details for adding the sweep to a batch
// and for creating new batch with this sweep only.
func estimateSweepFeeIncrement(s *sweep) (feeDetails, feeDetails, error) {
// Create a fake batch with this sweep.
batch := &batch{
rbfCache: rbfCache{
FeeRate: s.minFeeRate,
},
sweeps: map[lntypes.Hash]sweep{
s.swapHash: *s,
},
}
// Estimate new batch.
fd1, err := estimateBatchWeight(batch)
if err != nil {
return feeDetails{}, feeDetails{}, err
}
// Add the same sweep again to measure weight increments.
swapHash2 := s.swapHash
swapHash2[0]++
batch.sweeps[swapHash2] = *s
// Estimate weight of a batch with two sweeps.
fd2, err := estimateBatchWeight(batch)
if err != nil {
return feeDetails{}, feeDetails{}, err
}
// Create feeDetails for sweep.
sweepFeeDetails := feeDetails{
FeeRate: s.minFeeRate,
NonCoopHint: s.nonCoopHint,
IsExternalAddr: s.isExternalAddr,
// Calculate sweep weight as a difference.
CoopWeight: fd2.CoopWeight - fd1.CoopWeight,
NonCoopWeight: fd2.NonCoopWeight - fd1.NonCoopWeight,
}
return sweepFeeDetails, fd1, nil
}
// estimateBatchWeight estimates batch weight and returns its fee details.
func estimateBatchWeight(batch *batch) (feeDetails, error) {
// Make sure the batch is not empty.
if len(batch.sweeps) == 0 {
return feeDetails{}, errors.New("empty batch")
}
// Make sure fee rate is valid.
if batch.rbfCache.FeeRate < chainfee.AbsoluteFeePerKwFloor {
return feeDetails{}, fmt.Errorf("feeRate is too low: %v",
batch.rbfCache.FeeRate)
}
// Find if the batch has at least one non-cooperative sweep.
hasNonCoop := false
for _, sweep := range batch.sweeps {
if sweep.nonCoopHint {
hasNonCoop = true
}
}
// Find some sweep of the batch. It is used if there is just one sweep.
var theSweep sweep
for _, sweep := range batch.sweeps {
theSweep = sweep
break
}
// Find sweep destination address (type) for weight estimations.
var destAddr btcutil.Address
if theSweep.isExternalAddr {
if theSweep.destAddr == nil {
return feeDetails{}, errors.New("isExternalAddr=true," +
" but destAddr is nil")
}
destAddr = theSweep.destAddr
} else {
// Assume it is taproot by default.
destAddr = (*btcutil.AddressTaproot)(nil)
}
// Make two estimators: for coop and non-coop cases.
var coopWeight, nonCoopWeight input.TxWeightEstimator
// Add output weight to the estimator.
err := sweeppkg.AddOutputEstimate(&coopWeight, destAddr)
if err != nil {
return feeDetails{}, fmt.Errorf("sweep.AddOutputEstimate: %w",
err)
}
err = sweeppkg.AddOutputEstimate(&nonCoopWeight, destAddr)
if err != nil {
return feeDetails{}, fmt.Errorf("sweep.AddOutputEstimate: %w",
err)
}
// Add inputs.
for _, sweep := range batch.sweeps {
coopWeight.AddTaprootKeySpendInput(txscript.SigHashDefault)
err = sweep.htlcSuccessEstimator(&nonCoopWeight)
if err != nil {
return feeDetails{}, fmt.Errorf("htlcSuccessEstimator "+
"failed: %w", err)
}
}
return feeDetails{
BatchId: batch.id,
FeeRate: batch.rbfCache.FeeRate,
CoopWeight: coopWeight.Weight(),
NonCoopWeight: nonCoopWeight.Weight(),
NonCoopHint: hasNonCoop,
IsExternalAddr: theSweep.isExternalAddr,
}, nil
}
// newBatchSignal is the value that indicates a new batch. It is returned by
// selectBatches to encode new batch creation.
const newBatchSignal = -1
// feeDetails is either a batch or a sweep and it holds data important for
// selection of a batch to add the sweep to (or new batch creation).
type feeDetails struct {
BatchId int32
FeeRate chainfee.SatPerKWeight
CoopWeight lntypes.WeightUnit
NonCoopWeight lntypes.WeightUnit
NonCoopHint bool
IsExternalAddr bool
}
// fee returns fee of onchain transaction representing this instance.
func (e feeDetails) fee() btcutil.Amount {
var weight lntypes.WeightUnit
if e.NonCoopHint {
weight = e.NonCoopWeight
} else {
weight = e.CoopWeight
}
return e.FeeRate.FeeForWeight(weight)
}
// combine returns new feeDetails, combining properties.
func (e1 feeDetails) combine(e2 feeDetails) feeDetails {
// The fee rate is max of two fee rates.
feeRate := e1.FeeRate
if feeRate < e2.FeeRate {
feeRate = e2.FeeRate
}
return feeDetails{
FeeRate: feeRate,
CoopWeight: e1.CoopWeight + e2.CoopWeight,
NonCoopWeight: e1.NonCoopWeight + e2.NonCoopWeight,
NonCoopHint: e1.NonCoopHint || e2.NonCoopHint,
IsExternalAddr: e1.IsExternalAddr || e2.IsExternalAddr,
}
}
// selectBatches returns the list of id of batches sorted from best to worst.
// Creation a new batch is encoded as newBatchSignal. For each batch its fee
// rate and two weights are provided: weight in case of cooperative spending and
// weight in case non-cooperative spending (using preimages instead of taproot
// key spend). Also, a hint is provided to signal if the batch has to use
// non-cooperative spending path. The same data is also provided to the sweep
// for which we are selecting a batch to add. In case of the sweep weights are
// weight deltas resulted from adding the sweep. Finally, the same data is
// provided for new batch having this sweep only. The algorithm compares costs
// of adding the sweep to each existing batch, and costs of new batch creation
// for this sweep and returns BatchId of the winning batch. If the best option
// is to create a new batch, return newBatchSignal. Each fee details has also
// IsExternalAddr flag. There is a rule that sweeps having flag IsExternalAddr
// must go in individual batches. Cooperative spending is only available if all
// the sweeps support cooperative spending path.
func selectBatches(batches []feeDetails, sweep, oneSweepBatch feeDetails) (
[]int32, error) {
// If the sweep has IsExternalAddr flag, the sweep can't be added to
// a batch, so create new batch for it.
if sweep.IsExternalAddr {
return []int32{newBatchSignal}, nil
}
// alternative holds batch ID and its cost.
type alternative struct {
batchId int32
cost btcutil.Amount
}
// Create the list of possible actions and their costs.
alternatives := make([]alternative, 0, len(batches)+1)
// Track the best batch to add a sweep to. The default case is new batch
// creation with this sweep only in it. The cost is its full fee.
alternatives = append(alternatives, alternative{
batchId: newBatchSignal,
cost: oneSweepBatch.fee(),
})
// Try to add the sweep to every batch, calculate the costs and
// find the batch adding to which results in minimum costs.
for _, batch := range batches {
// If the batch has IsExternalAddr flag, the sweep can't be
// added to it, so skip the batch.
if batch.IsExternalAddr {
continue
}
// Add the sweep to the batch virtually.
combinedBatch := batch.combine(sweep)
// The cost is the fee increase.
cost := combinedBatch.fee() - batch.fee()
// The cost must be positive, because we added a sweep.
if cost <= 0 {
return nil, fmt.Errorf("got non-positive cost of "+
"adding sweep to batch %d: %d", batch.BatchId,
cost)
}
// Track the best batch, according to the costs.
alternatives = append(alternatives, alternative{
batchId: batch.BatchId,
cost: cost,
})
}
// Sort the alternatives by cost. The lower the cost, the better.
sort.Slice(alternatives, func(i, j int) bool {
return alternatives[i].cost < alternatives[j].cost
})
// Collect batches IDs.
batchesIds := make([]int32, len(alternatives))
for i, alternative := range alternatives {
batchesIds[i] = alternative.batchId
}
return batchesIds, nil
}

View file

@ -0,0 +1,698 @@
package sweepbatcher
import (
"testing"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg"
"github.com/lightninglabs/loop/swap"
"github.com/lightningnetwork/lnd/input"
"github.com/lightningnetwork/lnd/lntypes"
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
"github.com/stretchr/testify/require"
)
// Useful constants for tests.
const (
lowFeeRate = chainfee.FeePerKwFloor
highFeeRate = chainfee.SatPerKWeight(30000)
coopInputWeight = lntypes.WeightUnit(230)
nonCoopInputWeight = lntypes.WeightUnit(521)
nonCoopPenalty = nonCoopInputWeight - coopInputWeight
coopNewBatchWeight = lntypes.WeightUnit(444)
nonCoopNewBatchWeight = coopNewBatchWeight + nonCoopPenalty
// p2pkhDiscount is weight discount P2PKH output has over P2TR output.
p2pkhDiscount = lntypes.WeightUnit(
input.P2TROutputSize-input.P2PKHOutputSize,
) * 4
coopTwoSweepBatchWeight = coopNewBatchWeight + coopInputWeight
nonCoopTwoSweepBatchWeight = coopTwoSweepBatchWeight +
2*nonCoopPenalty
v2v3BatchWeight = nonCoopTwoSweepBatchWeight - 153
)
// testHtlcV2SuccessEstimator adds weight of non-cooperative input to estimator
// using HTLC v2.
func testHtlcV2SuccessEstimator(estimator *input.TxWeightEstimator) error {
swapHash := lntypes.Hash{1, 1, 1}
htlc, err := swap.NewHtlcV2(
111, htlcKeys.SenderScriptKey, htlcKeys.ReceiverScriptKey,
swapHash, &chaincfg.RegressionNetParams,
)
if err != nil {
return err
}
return htlc.AddSuccessToEstimator(estimator)
}
// testHtlcV3SuccessEstimator adds weight of non-cooperative input to estimator
// using HTLC v3.
func testHtlcV3SuccessEstimator(estimator *input.TxWeightEstimator) error {
swapHash := lntypes.Hash{1, 1, 1}
htlc, err := swap.NewHtlcV3(
input.MuSig2Version100RC2, 111,
htlcKeys.SenderInternalPubKey, htlcKeys.ReceiverInternalPubKey,
htlcKeys.SenderScriptKey, htlcKeys.ReceiverScriptKey, swapHash,
&chaincfg.RegressionNetParams,
)
if err != nil {
return err
}
return htlc.AddSuccessToEstimator(estimator)
}
// TestEstimateSweepFeeIncrement tests that weight and fee estimations work
// correctly for a sweep and one sweep batch.
func TestEstimateSweepFeeIncrement(t *testing.T) {
// Useful variables reused in test cases.
se3 := testHtlcV3SuccessEstimator
trAddr := (*btcutil.AddressTaproot)(nil)
p2pkhAddr := (*btcutil.AddressPubKeyHash)(nil)
cases := []struct {
name string
sweep *sweep
wantSweepFeeDetails feeDetails
wantNewBatchFeeDetails feeDetails
}{
{
name: "regular",
sweep: &sweep{
minFeeRate: lowFeeRate,
htlcSuccessEstimator: se3,
},
wantSweepFeeDetails: feeDetails{
FeeRate: lowFeeRate,
CoopWeight: coopInputWeight,
NonCoopWeight: nonCoopInputWeight,
},
wantNewBatchFeeDetails: feeDetails{
FeeRate: lowFeeRate,
CoopWeight: coopNewBatchWeight,
NonCoopWeight: nonCoopNewBatchWeight,
},
},
{
name: "high fee rate",
sweep: &sweep{
minFeeRate: highFeeRate,
htlcSuccessEstimator: se3,
},
wantSweepFeeDetails: feeDetails{
FeeRate: highFeeRate,
CoopWeight: coopInputWeight,
NonCoopWeight: nonCoopInputWeight,
},
wantNewBatchFeeDetails: feeDetails{
FeeRate: highFeeRate,
CoopWeight: coopNewBatchWeight,
NonCoopWeight: nonCoopNewBatchWeight,
},
},
{
name: "isExternalAddr taproot",
sweep: &sweep{
minFeeRate: lowFeeRate,
htlcSuccessEstimator: se3,
isExternalAddr: true,
destAddr: trAddr,
},
wantSweepFeeDetails: feeDetails{
FeeRate: lowFeeRate,
CoopWeight: coopInputWeight,
NonCoopWeight: nonCoopInputWeight,
IsExternalAddr: true,
},
wantNewBatchFeeDetails: feeDetails{
FeeRate: lowFeeRate,
CoopWeight: coopNewBatchWeight,
NonCoopWeight: nonCoopNewBatchWeight,
IsExternalAddr: true,
},
},
{
name: "isExternalAddr P2PKH",
sweep: &sweep{
minFeeRate: lowFeeRate,
htlcSuccessEstimator: se3,
isExternalAddr: true,
destAddr: p2pkhAddr,
},
wantSweepFeeDetails: feeDetails{
FeeRate: lowFeeRate,
CoopWeight: coopInputWeight,
NonCoopWeight: nonCoopInputWeight,
IsExternalAddr: true,
},
wantNewBatchFeeDetails: feeDetails{
FeeRate: lowFeeRate,
CoopWeight: coopNewBatchWeight -
p2pkhDiscount,
NonCoopWeight: nonCoopNewBatchWeight -
p2pkhDiscount,
IsExternalAddr: true,
},
},
{
name: "non-coop",
sweep: &sweep{
minFeeRate: lowFeeRate,
htlcSuccessEstimator: se3,
nonCoopHint: true,
},
wantSweepFeeDetails: feeDetails{
FeeRate: lowFeeRate,
CoopWeight: coopInputWeight,
NonCoopWeight: nonCoopInputWeight,
NonCoopHint: true,
},
wantNewBatchFeeDetails: feeDetails{
FeeRate: lowFeeRate,
CoopWeight: coopNewBatchWeight,
NonCoopWeight: nonCoopNewBatchWeight,
NonCoopHint: true,
},
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
gotSweepFeeDetails, gotNewBatchFeeDetails, err :=
estimateSweepFeeIncrement(tc.sweep)
require.NoError(t, err)
require.Equal(
t, tc.wantSweepFeeDetails, gotSweepFeeDetails,
)
require.Equal(
t, tc.wantNewBatchFeeDetails,
gotNewBatchFeeDetails,
)
})
}
}
// TestEstimateBatchWeight tests that weight and fee estimations work correctly
// for batches.
func TestEstimateBatchWeight(t *testing.T) {
// Useful variables reused in test cases.
swapHash1 := lntypes.Hash{1, 1, 1}
swapHash2 := lntypes.Hash{2, 2, 2}
se2 := testHtlcV2SuccessEstimator
se3 := testHtlcV3SuccessEstimator
trAddr := (*btcutil.AddressTaproot)(nil)
cases := []struct {
name string
batch *batch
wantBatchFeeDetails feeDetails
}{
{
name: "one sweep regular batch",
batch: &batch{
id: 1,
rbfCache: rbfCache{
FeeRate: lowFeeRate,
},
sweeps: map[lntypes.Hash]sweep{
swapHash1: {
htlcSuccessEstimator: se3,
},
},
},
wantBatchFeeDetails: feeDetails{
BatchId: 1,
FeeRate: lowFeeRate,
CoopWeight: coopNewBatchWeight,
NonCoopWeight: nonCoopNewBatchWeight,
},
},
{
name: "two sweeps regular batch",
batch: &batch{
id: 1,
rbfCache: rbfCache{
FeeRate: lowFeeRate,
},
sweeps: map[lntypes.Hash]sweep{
swapHash1: {
htlcSuccessEstimator: se3,
},
swapHash2: {
htlcSuccessEstimator: se3,
},
},
},
wantBatchFeeDetails: feeDetails{
BatchId: 1,
FeeRate: lowFeeRate,
CoopWeight: coopTwoSweepBatchWeight,
NonCoopWeight: nonCoopTwoSweepBatchWeight,
},
},
{
name: "v2 and v3 sweeps",
batch: &batch{
id: 1,
rbfCache: rbfCache{
FeeRate: lowFeeRate,
},
sweeps: map[lntypes.Hash]sweep{
swapHash1: {
htlcSuccessEstimator: se2,
},
swapHash2: {
htlcSuccessEstimator: se3,
},
},
},
wantBatchFeeDetails: feeDetails{
BatchId: 1,
FeeRate: lowFeeRate,
CoopWeight: coopTwoSweepBatchWeight,
NonCoopWeight: v2v3BatchWeight,
},
},
{
name: "high fee rate",
batch: &batch{
id: 1,
rbfCache: rbfCache{
FeeRate: highFeeRate,
},
sweeps: map[lntypes.Hash]sweep{
swapHash1: {
htlcSuccessEstimator: se3,
},
},
},
wantBatchFeeDetails: feeDetails{
BatchId: 1,
FeeRate: highFeeRate,
CoopWeight: coopNewBatchWeight,
NonCoopWeight: nonCoopNewBatchWeight,
},
},
{
name: "non-coop",
batch: &batch{
id: 1,
rbfCache: rbfCache{
FeeRate: lowFeeRate,
},
sweeps: map[lntypes.Hash]sweep{
swapHash1: {
htlcSuccessEstimator: se3,
},
swapHash2: {
htlcSuccessEstimator: se3,
nonCoopHint: true,
},
},
},
wantBatchFeeDetails: feeDetails{
BatchId: 1,
FeeRate: lowFeeRate,
CoopWeight: coopTwoSweepBatchWeight,
NonCoopWeight: nonCoopTwoSweepBatchWeight,
NonCoopHint: true,
},
},
{
name: "isExternalAddr",
batch: &batch{
id: 1,
rbfCache: rbfCache{
FeeRate: lowFeeRate,
},
sweeps: map[lntypes.Hash]sweep{
swapHash1: {
htlcSuccessEstimator: se3,
isExternalAddr: true,
destAddr: trAddr,
},
},
},
wantBatchFeeDetails: feeDetails{
BatchId: 1,
FeeRate: lowFeeRate,
CoopWeight: coopNewBatchWeight,
NonCoopWeight: nonCoopNewBatchWeight,
IsExternalAddr: true,
},
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
gotBatchFeeDetails, err := estimateBatchWeight(tc.batch)
require.NoError(t, err)
require.Equal(
t, tc.wantBatchFeeDetails, gotBatchFeeDetails,
)
})
}
}
// TestSelectBatches tests greedy batch selection algorithm.
func TestSelectBatches(t *testing.T) {
cases := []struct {
name string
batches []feeDetails
sweep, oneSweepBatch feeDetails
wantBestBatchesIds []int32
}{
{
name: "no existing batches",
batches: []feeDetails{},
sweep: feeDetails{
FeeRate: lowFeeRate,
CoopWeight: coopInputWeight,
NonCoopWeight: nonCoopInputWeight,
},
oneSweepBatch: feeDetails{
FeeRate: lowFeeRate,
CoopWeight: coopNewBatchWeight,
NonCoopWeight: nonCoopNewBatchWeight,
},
wantBestBatchesIds: []int32{newBatchSignal},
},
{
name: "low fee sweep, low fee existing batch",
batches: []feeDetails{
{
BatchId: 1,
FeeRate: lowFeeRate,
CoopWeight: coopNewBatchWeight,
NonCoopWeight: nonCoopNewBatchWeight,
},
},
sweep: feeDetails{
FeeRate: lowFeeRate,
CoopWeight: coopInputWeight,
NonCoopWeight: nonCoopInputWeight,
},
oneSweepBatch: feeDetails{
FeeRate: lowFeeRate,
CoopWeight: coopNewBatchWeight,
NonCoopWeight: nonCoopNewBatchWeight,
},
wantBestBatchesIds: []int32{1, newBatchSignal},
},
{
name: "low fee sweep, high fee existing batch",
batches: []feeDetails{
{
BatchId: 1,
FeeRate: highFeeRate,
CoopWeight: coopNewBatchWeight,
NonCoopWeight: nonCoopNewBatchWeight,
},
},
sweep: feeDetails{
FeeRate: lowFeeRate,
CoopWeight: coopInputWeight,
NonCoopWeight: nonCoopInputWeight,
},
oneSweepBatch: feeDetails{
FeeRate: lowFeeRate,
CoopWeight: coopNewBatchWeight,
NonCoopWeight: nonCoopNewBatchWeight,
},
wantBestBatchesIds: []int32{newBatchSignal, 1},
},
{
name: "low fee sweep, low + high fee existing batches",
batches: []feeDetails{
{
BatchId: 1,
FeeRate: lowFeeRate,
CoopWeight: coopNewBatchWeight,
NonCoopWeight: nonCoopNewBatchWeight,
},
{
BatchId: 2,
FeeRate: highFeeRate,
CoopWeight: coopNewBatchWeight,
NonCoopWeight: nonCoopNewBatchWeight,
},
},
sweep: feeDetails{
FeeRate: lowFeeRate,
CoopWeight: coopInputWeight,
NonCoopWeight: nonCoopInputWeight,
},
oneSweepBatch: feeDetails{
FeeRate: lowFeeRate,
CoopWeight: coopNewBatchWeight,
NonCoopWeight: nonCoopNewBatchWeight,
},
wantBestBatchesIds: []int32{1, newBatchSignal, 2},
},
{
name: "high fee sweep, low + high fee existing batches",
batches: []feeDetails{
{
BatchId: 1,
FeeRate: lowFeeRate,
CoopWeight: coopNewBatchWeight,
NonCoopWeight: nonCoopNewBatchWeight,
},
{
BatchId: 2,
FeeRate: highFeeRate,
CoopWeight: coopNewBatchWeight,
NonCoopWeight: nonCoopNewBatchWeight,
},
},
sweep: feeDetails{
FeeRate: highFeeRate,
CoopWeight: coopInputWeight,
NonCoopWeight: nonCoopInputWeight,
},
oneSweepBatch: feeDetails{
FeeRate: highFeeRate,
CoopWeight: coopNewBatchWeight,
NonCoopWeight: nonCoopNewBatchWeight,
},
wantBestBatchesIds: []int32{2, newBatchSignal, 1},
},
{
name: "high fee noncoop sweep",
batches: []feeDetails{
{
BatchId: 1,
FeeRate: lowFeeRate,
CoopWeight: coopNewBatchWeight,
NonCoopWeight: nonCoopNewBatchWeight,
},
{
BatchId: 2,
FeeRate: highFeeRate,
CoopWeight: coopNewBatchWeight,
NonCoopWeight: nonCoopNewBatchWeight,
},
},
sweep: feeDetails{
FeeRate: highFeeRate,
CoopWeight: coopInputWeight,
NonCoopWeight: nonCoopInputWeight,
NonCoopHint: true,
},
oneSweepBatch: feeDetails{
FeeRate: highFeeRate,
CoopWeight: coopNewBatchWeight,
NonCoopWeight: nonCoopNewBatchWeight,
NonCoopHint: true,
},
wantBestBatchesIds: []int32{newBatchSignal, 2, 1},
},
{
name: "high fee noncoop sweep, high batch noncoop",
batches: []feeDetails{
{
BatchId: 1,
FeeRate: lowFeeRate,
CoopWeight: coopNewBatchWeight,
NonCoopWeight: nonCoopNewBatchWeight,
},
{
BatchId: 2,
FeeRate: highFeeRate,
CoopWeight: coopNewBatchWeight,
NonCoopWeight: nonCoopNewBatchWeight,
NonCoopHint: true,
},
},
sweep: feeDetails{
FeeRate: highFeeRate,
CoopWeight: coopInputWeight,
NonCoopWeight: nonCoopInputWeight,
NonCoopHint: true,
},
oneSweepBatch: feeDetails{
FeeRate: highFeeRate,
CoopWeight: coopNewBatchWeight,
NonCoopWeight: nonCoopNewBatchWeight,
NonCoopHint: true,
},
wantBestBatchesIds: []int32{2, newBatchSignal, 1},
},
{
name: "low fee noncoop sweep",
batches: []feeDetails{
{
BatchId: 1,
FeeRate: lowFeeRate,
CoopWeight: coopNewBatchWeight,
NonCoopWeight: nonCoopNewBatchWeight,
},
{
BatchId: 2,
FeeRate: highFeeRate,
CoopWeight: coopNewBatchWeight,
NonCoopWeight: nonCoopNewBatchWeight,
},
},
sweep: feeDetails{
FeeRate: lowFeeRate,
CoopWeight: coopInputWeight,
NonCoopWeight: nonCoopInputWeight,
NonCoopHint: true,
},
oneSweepBatch: feeDetails{
FeeRate: lowFeeRate,
CoopWeight: coopNewBatchWeight,
NonCoopWeight: nonCoopNewBatchWeight,
NonCoopHint: true,
},
wantBestBatchesIds: []int32{newBatchSignal, 1, 2},
},
{
name: "low fee noncoop sweep, low batch noncoop",
batches: []feeDetails{
{
BatchId: 1,
FeeRate: lowFeeRate,
CoopWeight: coopNewBatchWeight,
NonCoopWeight: nonCoopNewBatchWeight,
NonCoopHint: true,
},
{
BatchId: 2,
FeeRate: highFeeRate,
CoopWeight: coopNewBatchWeight,
NonCoopWeight: nonCoopNewBatchWeight,
},
},
sweep: feeDetails{
FeeRate: lowFeeRate,
CoopWeight: coopInputWeight,
NonCoopWeight: nonCoopInputWeight,
NonCoopHint: true,
},
oneSweepBatch: feeDetails{
FeeRate: lowFeeRate,
CoopWeight: coopNewBatchWeight,
NonCoopWeight: nonCoopNewBatchWeight,
NonCoopHint: true,
},
wantBestBatchesIds: []int32{1, newBatchSignal, 2},
},
{
name: "external address sweep",
batches: []feeDetails{
{
BatchId: 1,
FeeRate: highFeeRate,
CoopWeight: coopNewBatchWeight,
NonCoopWeight: nonCoopNewBatchWeight,
},
{
BatchId: 2,
FeeRate: highFeeRate,
CoopWeight: coopNewBatchWeight,
NonCoopWeight: nonCoopNewBatchWeight,
},
},
sweep: feeDetails{
FeeRate: highFeeRate,
CoopWeight: coopInputWeight,
NonCoopWeight: nonCoopInputWeight,
IsExternalAddr: true,
},
oneSweepBatch: feeDetails{
FeeRate: highFeeRate,
CoopWeight: coopNewBatchWeight,
NonCoopWeight: nonCoopNewBatchWeight,
IsExternalAddr: true,
},
wantBestBatchesIds: []int32{newBatchSignal},
},
{
name: "external address batch",
batches: []feeDetails{
{
BatchId: 1,
FeeRate: highFeeRate - 1,
CoopWeight: coopNewBatchWeight,
NonCoopWeight: nonCoopNewBatchWeight,
},
{
BatchId: 2,
FeeRate: highFeeRate,
CoopWeight: coopNewBatchWeight,
NonCoopWeight: nonCoopNewBatchWeight,
IsExternalAddr: true,
},
},
sweep: feeDetails{
FeeRate: highFeeRate,
CoopWeight: coopInputWeight,
NonCoopWeight: nonCoopInputWeight,
},
oneSweepBatch: feeDetails{
FeeRate: highFeeRate,
CoopWeight: coopNewBatchWeight,
NonCoopWeight: nonCoopNewBatchWeight,
},
wantBestBatchesIds: []int32{1, newBatchSignal},
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
gotBestBatchesIds, err := selectBatches(
tc.batches, tc.sweep, tc.oneSweepBatch,
)
require.NoError(t, err)
require.Equal(
t, tc.wantBestBatchesIds, gotBestBatchesIds,
)
})
}
}

View file

@ -22,6 +22,7 @@ import (
"github.com/lightninglabs/loop/labels"
"github.com/lightninglabs/loop/loopdb"
"github.com/lightninglabs/loop/swap"
sweeppkg "github.com/lightninglabs/loop/sweep"
"github.com/lightningnetwork/lnd/chainntnfs"
"github.com/lightningnetwork/lnd/input"
"github.com/lightningnetwork/lnd/keychain"
@ -101,6 +102,11 @@ type sweep struct {
// minFeeRate is minimum fee rate that must be used by a batch of
// the sweep. If it is specified, confTarget is ignored.
minFeeRate chainfee.SatPerKWeight
// nonCoopHint is set, if the sweep can not be spent cooperatively and
// has to be spent using preimage. This is only used in fee estimations
// when selecting a batch for the sweep to minimize fees.
nonCoopHint bool
}
// batchState is the state of the batch.
@ -133,9 +139,7 @@ type batchConfig struct {
batchPublishDelay time.Duration
// noBumping instructs sweepbatcher not to fee bump itself and rely on
// external source of fee rates (MinFeeRate). To change the fee rate,
// the caller has to update it in the source of SweepInfo (interface
// SweepFetcher) and re-add the sweep by calling AddSweep.
// external source of fee rates (FeeRateProvider).
noBumping bool
// customMuSig2Signer is a custom signer. If it is set, it is used to
@ -152,6 +156,10 @@ type rbfCache struct {
// FeeRate is the last used fee rate we used to publish a batch tx.
FeeRate chainfee.SatPerKWeight
// SkipNextBump instructs updateRbfRate to skip one fee bumping.
// It is set upon updating FeeRate externally.
SkipNextBump bool
}
// batch is a collection of sweeps that are published together.
@ -413,6 +421,7 @@ func (b *batch) addSweep(ctx context.Context, sweep *sweep) (bool, error) {
if b.primarySweepID == sweep.swapHash {
b.cfg.batchConfTarget = sweep.confTarget
b.rbfCache.FeeRate = sweep.minFeeRate
b.rbfCache.SkipNextBump = true
}
return true, nil
@ -455,6 +464,7 @@ func (b *batch) addSweep(ctx context.Context, sweep *sweep) (bool, error) {
b.primarySweepID = sweep.swapHash
b.cfg.batchConfTarget = sweep.confTarget
b.rbfCache.FeeRate = sweep.minFeeRate
b.rbfCache.SkipNextBump = true
// We also need to start the spend monitor for this new primary
// sweep.
@ -472,6 +482,7 @@ func (b *batch) addSweep(ctx context.Context, sweep *sweep) (bool, error) {
// the batch is the basis for fee bumps.
if b.rbfCache.FeeRate < sweep.minFeeRate {
b.rbfCache.FeeRate = sweep.minFeeRate
b.rbfCache.SkipNextBump = true
}
return true, b.persistSweep(ctx, *sweep, false)
@ -734,7 +745,10 @@ func (b *batch) publishBatch(ctx context.Context) (btcutil.Amount, error) {
return fee, err
}
weightEstimate.AddP2TROutput()
err = sweeppkg.AddOutputEstimate(&weightEstimate, address)
if err != nil {
return fee, err
}
weight := weightEstimate.Weight()
feeForWeight := b.rbfCache.FeeRate.FeeForWeight(weight)
@ -840,7 +854,7 @@ func (b *batch) publishBatchCoop(ctx context.Context) (btcutil.Amount,
PreviousOutPoint: sweep.outpoint,
})
weightEstimate.AddTaprootKeySpendInput(txscript.SigHashAll)
weightEstimate.AddTaprootKeySpendInput(txscript.SigHashDefault)
}
var address btcutil.Address
@ -866,7 +880,10 @@ func (b *batch) publishBatchCoop(ctx context.Context) (btcutil.Amount,
return fee, err, false
}
weightEstimate.AddP2TROutput()
err = sweeppkg.AddOutputEstimate(&weightEstimate, address)
if err != nil {
return fee, err, false
}
weight := weightEstimate.Weight()
feeForWeight := b.rbfCache.FeeRate.FeeForWeight(weight)
@ -1131,10 +1148,15 @@ func (b *batch) updateRbfRate(ctx context.Context) error {
// If the feeRate is unset then we never published before, so we
// retrieve the fee estimate from our wallet.
if b.rbfCache.FeeRate == 0 {
// We set minFeeRate in each sweep, so fee rate is expected to
// be initiated here.
b.log.Warnf("rbfCache.FeeRate is 0, which must not happen.")
if b.cfg.batchConfTarget == 0 {
b.log.Warnf("updateRbfRate called with zero " +
"batchConfTarget")
}
b.log.Infof("initializing rbf fee rate for conf target=%v",
b.cfg.batchConfTarget)
rate, err := b.wallet.EstimateFeeRate(
@ -1147,8 +1169,13 @@ func (b *batch) updateRbfRate(ctx context.Context) error {
// Set the initial value for our fee rate.
b.rbfCache.FeeRate = rate
} else if !b.cfg.noBumping {
// Bump the fee rate by the configured step.
b.rbfCache.FeeRate += defaultFeeRateStep
if b.rbfCache.SkipNextBump {
// Skip fee bumping, unset the flag, to bump next time.
b.rbfCache.SkipNextBump = false
} else {
// Bump the fee rate by the configured step.
b.rbfCache.FeeRate += defaultFeeRateStep
}
}
b.rbfCache.LastHeight = b.currentHeight

View file

@ -122,9 +122,10 @@ type SweepInfo struct {
// DestAddr is the destination address of the sweep.
DestAddr btcutil.Address
// MinFeeRate is minimum fee rate that must be used by a batch of
// the sweep. If it is specified, confTarget is ignored.
MinFeeRate chainfee.SatPerKWeight
// NonCoopHint is set, if the sweep can not be spent cooperatively and
// has to be spent using preimage. This is only used in fee estimations
// when selecting a batch for the sweep to minimize fees.
NonCoopHint bool
}
// SweepFetcher is used to get details of a sweep.
@ -151,6 +152,11 @@ type SignMuSig2 func(ctx context.Context, muSig2Version input.MuSig2Version,
// signature.
type VerifySchnorrSig func(pubKey *btcec.PublicKey, hash, sig []byte) error
// FeeRateProvider is a function that returns min fee rate of a batch sweeping
// the UTXO of the swap.
type FeeRateProvider func(ctx context.Context,
swapHash lntypes.Hash) (chainfee.SatPerKWeight, error)
// SweepRequest is a request to sweep a specific outpoint.
type SweepRequest struct {
// SwapHash is the hash of the swap that is being swept.
@ -247,11 +253,10 @@ type Batcher struct {
// exit.
wg sync.WaitGroup
// noBumping instructs sweepbatcher not to fee bump itself and rely on
// external source of fee rates (MinFeeRate). To change the fee rate,
// the caller has to update it in the source of SweepInfo (interface
// SweepFetcher) and re-add the sweep by calling AddSweep.
noBumping bool
// customFeeRate provides custom min fee rate per swap. The batch uses
// max of the fee rates of its swaps. In this mode confTarget is
// ignored and fee bumping by sweepbatcher is disabled.
customFeeRate FeeRateProvider
// customMuSig2Signer is a custom signer. If it is set, it is used to
// create musig2 signatures instead of musig2SignSweep and signerClient.
@ -262,11 +267,10 @@ type Batcher struct {
// BatcherConfig holds batcher configuration.
type BatcherConfig struct {
// noBumping instructs sweepbatcher not to fee bump itself and rely on
// external source of fee rates (MinFeeRate). To change the fee rate,
// the caller has to update it in the source of SweepInfo (interface
// SweepFetcher) and re-add the sweep by calling AddSweep.
noBumping bool
// customFeeRate provides custom min fee rate per swap. The batch uses
// max of the fee rates of its swaps. In this mode confTarget is
// ignored and fee bumping by sweepbatcher is disabled.
customFeeRate FeeRateProvider
// customMuSig2Signer is a custom signer. If it is set, it is used to
// create musig2 signatures instead of musig2SignSweep and signerClient.
@ -278,13 +282,12 @@ type BatcherConfig struct {
// BatcherOption configures batcher behaviour.
type BatcherOption func(*BatcherConfig)
// WithNoBumping instructs sweepbatcher not to fee bump itself and
// rely on external source of fee rates (MinFeeRate). To change the
// fee rate, the caller has to update it in the source of SweepInfo
// (interface SweepFetcher) and re-add the sweep by calling AddSweep.
func WithNoBumping() BatcherOption {
// WithCustomFeeRate instructs sweepbatcher not to fee bump itself and rely on
// external source of fee rates (FeeRateProvider). To apply a fee rate change,
// the caller should re-add the sweep by calling AddSweep.
func WithCustomFeeRate(customFeeRate FeeRateProvider) BatcherOption {
return func(cfg *BatcherConfig) {
cfg.noBumping = true
cfg.customFeeRate = customFeeRate
}
}
@ -331,7 +334,7 @@ func NewBatcher(wallet lndclient.WalletKitClient,
chainParams: chainparams,
store: store,
sweepStore: sweepStore,
noBumping: cfg.noBumping,
customFeeRate: cfg.customFeeRate,
customMuSig2Signer: cfg.customMuSig2Signer,
}
}
@ -372,18 +375,22 @@ func (b *Batcher) Run(ctx context.Context) error {
case sweepReq := <-b.sweepReqs:
sweep, err := b.fetchSweep(runCtx, sweepReq)
if err != nil {
log.Warnf("fetchSweep failed: %v.", err)
return err
}
err = b.handleSweep(runCtx, sweep, sweepReq.Notifier)
if err != nil {
log.Warnf("handleSweep failed: %v.", err)
return err
}
case err := <-b.errChan:
log.Warnf("Batcher received an error: %v.", err)
return err
case <-runCtx.Done():
log.Infof("Stopping Batcher: run context cancelled.")
return runCtx.Err()
}
}
@ -467,6 +474,16 @@ func (b *Batcher) handleSweep(ctx context.Context, sweep *sweep,
}
}
// Try to run the greedy algorithm of batch selection to minimize costs.
err = b.greedyAddSweep(ctx, sweep)
if err == nil {
// The greedy algorithm succeeded.
return nil
}
log.Warnf("Greedy batch selection algorithm failed for sweep %x: %v. "+
"Falling back to old approach.", sweep.swapHash[:6], err)
// If one of the batches accepts the sweep, we provide it to that batch.
for _, batch := range b.batches {
accepted, err := batch.addSweep(ctx, sweep)
@ -483,13 +500,19 @@ func (b *Batcher) handleSweep(ctx context.Context, sweep *sweep,
// If no batch is capable of accepting the sweep, we spin up a fresh
// batch and hand the sweep over to it.
batch, err := b.spinUpBatch(ctx)
return b.spinUpNewBatch(ctx, sweep)
}
// spinUpNewBatch creates new batch, starts it and adds the sweep to it.
func (b *Batcher) spinUpNewBatch(ctx context.Context, sweep *sweep) error {
// Spin up a fresh batch.
newBatch, err := b.spinUpBatch(ctx)
if err != nil {
return err
}
// Add the sweep to the fresh batch.
accepted, err := batch.addSweep(ctx, sweep)
accepted, err := newBatch.addSweep(ctx, sweep)
if err != nil {
return err
}
@ -498,7 +521,7 @@ func (b *Batcher) handleSweep(ctx context.Context, sweep *sweep,
// we should return the error.
if !accepted {
return fmt.Errorf("sweep %x was not accepted by new batch %d",
sweep.swapHash[:6], batch.id)
sweep.swapHash[:6], newBatch.id)
}
return nil
@ -506,11 +529,7 @@ func (b *Batcher) handleSweep(ctx context.Context, sweep *sweep,
// spinUpBatch spins up a new batch and returns it.
func (b *Batcher) spinUpBatch(ctx context.Context) (*batch, error) {
cfg := batchConfig{
maxTimeoutDistance: defaultMaxTimeoutDistance,
noBumping: b.noBumping,
customMuSig2Signer: b.customMuSig2Signer,
}
cfg := b.newBatchConfig(defaultMaxTimeoutDistance)
switch b.chainParams {
case &chaincfg.MainNetParams:
@ -520,17 +539,7 @@ func (b *Batcher) spinUpBatch(ctx context.Context) (*batch, error) {
cfg.batchPublishDelay = defaultPublishDelay
}
batchKit := batchKit{
returnChan: b.sweepReqs,
wallet: b.wallet,
chainNotifier: b.chainNotifier,
signerClient: b.signerClient,
musig2SignSweep: b.musig2ServerSign,
verifySchnorrSig: b.VerifySchnorrSig,
purger: b.AddSweep,
store: b.store,
quit: b.quit,
}
batchKit := b.newBatchKit()
batch := NewBatch(cfg, batchKit)
@ -605,31 +614,17 @@ func (b *Batcher) spinUpBatchFromDB(ctx context.Context, batch *batch) error {
logger := batchPrefixLogger(fmt.Sprintf("%d", batch.id))
batchKit := batchKit{
id: batch.id,
batchTxid: batch.batchTxid,
batchPkScript: batch.batchPkScript,
state: batch.state,
primaryID: primarySweep.SwapHash,
sweeps: sweeps,
rbfCache: rbfCache,
returnChan: b.sweepReqs,
wallet: b.wallet,
chainNotifier: b.chainNotifier,
signerClient: b.signerClient,
musig2SignSweep: b.musig2ServerSign,
verifySchnorrSig: b.VerifySchnorrSig,
purger: b.AddSweep,
store: b.store,
log: logger,
quit: b.quit,
}
batchKit := b.newBatchKit()
batchKit.id = batch.id
batchKit.batchTxid = batch.batchTxid
batchKit.batchPkScript = batch.batchPkScript
batchKit.state = batch.state
batchKit.primaryID = primarySweep.SwapHash
batchKit.sweeps = sweeps
batchKit.rbfCache = rbfCache
batchKit.log = logger
cfg := batchConfig{
maxTimeoutDistance: batch.cfg.maxTimeoutDistance,
noBumping: b.noBumping,
customMuSig2Signer: b.customMuSig2Signer,
}
cfg := b.newBatchConfig(batch.cfg.maxTimeoutDistance)
newBatch, err := NewBatchFromDB(cfg, batchKit)
if err != nil {
@ -689,11 +684,7 @@ func (b *Batcher) FetchUnconfirmedBatches(ctx context.Context) ([]*batch,
}
batch.rbfCache = rbfCache
bchCfg := batchConfig{
maxTimeoutDistance: bch.MaxTimeoutDistance,
noBumping: b.noBumping,
customMuSig2Signer: b.customMuSig2Signer,
}
bchCfg := b.newBatchConfig(bch.MaxTimeoutDistance)
batch.cfg = &bchCfg
batches = append(batches, &batch)
@ -799,29 +790,8 @@ func (b *Batcher) writeToErrChan(ctx context.Context, err error) error {
func (b *Batcher) convertSweep(ctx context.Context, dbSweep *dbSweep) (
*sweep, error) {
s, err := b.sweepStore.FetchSweep(ctx, dbSweep.SwapHash)
if err != nil {
return nil, fmt.Errorf("failed to fetch sweep data for %x: %w",
dbSweep.SwapHash[:6], err)
}
return &sweep{
swapHash: dbSweep.SwapHash,
outpoint: dbSweep.Outpoint,
value: dbSweep.Amount,
confTarget: s.ConfTarget,
timeout: s.Timeout,
initiationHeight: s.InitiationHeight,
htlc: s.HTLC,
preimage: s.Preimage,
swapInvoicePaymentAddr: s.SwapInvoicePaymentAddr,
htlcKeys: s.HTLCKeys,
htlcSuccessEstimator: s.HTLCSuccessEstimator,
protocolVersion: s.ProtocolVersion,
isExternalAddr: s.IsExternalAddr,
destAddr: s.DestAddr,
minFeeRate: s.MinFeeRate,
}, nil
return b.loadSweep(ctx, dbSweep.SwapHash, dbSweep.Outpoint,
dbSweep.Amount)
}
// LoopOutFetcher is used to load LoopOut swaps from the database.
@ -897,16 +867,51 @@ func NewSweepFetcherFromSwapStore(swapStore LoopOutFetcher,
func (b *Batcher) fetchSweep(ctx context.Context,
sweepReq SweepRequest) (*sweep, error) {
s, err := b.sweepStore.FetchSweep(ctx, sweepReq.SwapHash)
return b.loadSweep(ctx, sweepReq.SwapHash, sweepReq.Outpoint,
sweepReq.Value)
}
// loadSweep loads inputs of sweep from the database and from FeeRateProvider
// if needed and returns an assembled sweep object.
func (b *Batcher) loadSweep(ctx context.Context, swapHash lntypes.Hash,
outpoint wire.OutPoint, value btcutil.Amount) (*sweep, error) {
s, err := b.sweepStore.FetchSweep(ctx, swapHash)
if err != nil {
return nil, fmt.Errorf("failed to fetch sweep data for %x: %w",
sweepReq.SwapHash[:6], err)
swapHash[:6], err)
}
// Find minimum fee rate for the sweep. Use customFeeRate if it is
// provided, otherwise use wallet's EstimateFeeRate.
var minFeeRate chainfee.SatPerKWeight
if b.customFeeRate != nil {
minFeeRate, err = b.customFeeRate(ctx, swapHash)
if err != nil {
return nil, fmt.Errorf("failed to fetch min fee rate "+
"for %x: %w", swapHash[:6], err)
}
if minFeeRate < chainfee.AbsoluteFeePerKwFloor {
return nil, fmt.Errorf("min fee rate too low (%v) for "+
"%x", minFeeRate, swapHash[:6])
}
} else {
if s.ConfTarget == 0 {
log.Warnf("Fee estimation was requested for zero "+
"confTarget for sweep %x.", swapHash[:6])
}
minFeeRate, err = b.wallet.EstimateFeeRate(ctx, s.ConfTarget)
if err != nil {
return nil, fmt.Errorf("failed to estimate fee rate "+
"for %x, confTarget=%d: %w", swapHash[:6],
s.ConfTarget, err)
}
}
return &sweep{
swapHash: sweepReq.SwapHash,
outpoint: sweepReq.Outpoint,
value: sweepReq.Value,
swapHash: swapHash,
outpoint: outpoint,
value: value,
confTarget: s.ConfTarget,
timeout: s.Timeout,
initiationHeight: s.InitiationHeight,
@ -918,6 +923,31 @@ func (b *Batcher) fetchSweep(ctx context.Context,
protocolVersion: s.ProtocolVersion,
isExternalAddr: s.IsExternalAddr,
destAddr: s.DestAddr,
minFeeRate: s.MinFeeRate,
minFeeRate: minFeeRate,
nonCoopHint: s.NonCoopHint,
}, nil
}
// newBatchConfig creates new batch config.
func (b *Batcher) newBatchConfig(maxTimeoutDistance int32) batchConfig {
return batchConfig{
maxTimeoutDistance: maxTimeoutDistance,
noBumping: b.customFeeRate != nil,
customMuSig2Signer: b.customMuSig2Signer,
}
}
// newBatchKit creates new batch kit.
func (b *Batcher) newBatchKit() batchKit {
return batchKit{
returnChan: b.sweepReqs,
wallet: b.wallet,
chainNotifier: b.chainNotifier,
signerClient: b.signerClient,
musig2SignSweep: b.musig2ServerSign,
verifySchnorrSig: b.VerifySchnorrSig,
purger: b.AddSweep,
store: b.store,
quit: b.quit,
}
}

View file

@ -9,6 +9,7 @@ import (
"testing"
"time"
"github.com/btcsuite/btcd/blockchain"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg/chainhash"
@ -308,7 +309,7 @@ func testSweepBatcherBatchCreation(t *testing.T, store testStore,
}
// testFeeBumping tests that sweep is RBFed with slightly higher fee rate after
// each block unless WithNoBumping is passed.
// each block unless WithCustomFeeRate is passed.
func testFeeBumping(t *testing.T, store testStore,
batcherStore testBatcherStore, noFeeBumping bool) {
@ -324,7 +325,14 @@ func testFeeBumping(t *testing.T, store testStore,
// Disable fee bumping, if requested.
var opts []BatcherOption
if noFeeBumping {
opts = append(opts, WithNoBumping())
customFeeRate := func(ctx context.Context,
swapHash lntypes.Hash) (chainfee.SatPerKWeight, error) {
// Always provide the same value, no bumping.
return test.DefaultMockFee, nil
}
opts = append(opts, WithCustomFeeRate(customFeeRate))
}
batcher := NewBatcher(lnd.WalletKit, lnd.ChainNotifier, lnd.Signer,
@ -1930,9 +1938,8 @@ func testSweepFetcher(t *testing.T, store testStore,
// Provide min fee rate for the sweep.
feeRate := chainfee.SatPerKWeight(30000)
amt := btcutil.Amount(1_000_000)
weight := lntypes.WeightUnit(445) // Weight for 1-to-1 tx.
bumpedFee := feeRate + 100
expectedFee := bumpedFee.FeeForWeight(weight)
weight := lntypes.WeightUnit(396) // Weight for 1-to-1 tx.
expectedFee := feeRate.FeeForWeight(weight)
swap := &loopdb.LoopOutContract{
SwapContract: loopdb.SwapContract{
@ -1955,7 +1962,6 @@ func testSweepFetcher(t *testing.T, store testStore,
ConfTarget: 123,
Timeout: 111,
SwapInvoicePaymentAddr: *swapPaymentAddr,
MinFeeRate: feeRate,
ProtocolVersion: loopdb.ProtocolVersionMuSig2,
HTLCKeys: htlcKeys,
HTLC: *htlc,
@ -1987,9 +1993,17 @@ func testSweepFetcher(t *testing.T, store testStore,
require.NoError(t, err)
store.AssertLoopOutStored()
customFeeRate := func(ctx context.Context,
swapHash lntypes.Hash) (chainfee.SatPerKWeight, error) {
// Always provide the same value, no bumping.
return feeRate, nil
}
batcher := NewBatcher(lnd.WalletKit, lnd.ChainNotifier, lnd.Signer,
testMuSig2SignSweep, testVerifySchnorrSig, lnd.ChainParams,
batcherStore, sweepFetcher)
nil, testVerifySchnorrSig, lnd.ChainParams,
batcherStore, sweepFetcher, WithCustomFeeRate(customFeeRate),
WithCustomSignMuSig2(testSignMuSig2func))
var wg sync.WaitGroup
wg.Add(1)
@ -2040,6 +2054,12 @@ func testSweepFetcher(t *testing.T, store testStore,
out := btcutil.Amount(tx.TxOut[0].Value)
gotFee := amt - out
require.Equal(t, expectedFee, gotFee, "fees don't match")
gotWeight := lntypes.WeightUnit(
blockchain.GetTransactionWeight(btcutil.NewTx(tx)),
)
require.Equal(t, weight, gotWeight, "weights don't match")
gotFeeRate := chainfee.NewSatPerKWeight(gotFee, gotWeight)
require.Equal(t, feeRate, gotFeeRate, "fee rates don't match")
// Make sure we have stored the batch.
batches, err := batcherStore.FetchUnconfirmedSweepBatches(ctx)
@ -2090,8 +2110,9 @@ func testSweepBatcherCloseDuringAdding(t *testing.T, store testStore,
Preimage: lntypes.Preimage{i},
},
DestAddr: destAddr,
SwapInvoice: swapInvoice,
DestAddr: destAddr,
SwapInvoice: swapInvoice,
SweepConfTarget: 111,
}
err = store.CreateLoopOut(ctx, swapHash, swap)
@ -2238,7 +2259,7 @@ func TestSweepBatcherBatchCreation(t *testing.T) {
}
// TestFeeBumping tests that sweep is RBFed with slightly higher fee rate after
// each block unless WithNoBumping is passed.
// each block unless WithCustomFeeRate is passed.
func TestFeeBumping(t *testing.T) {
t.Run("regular", func(t *testing.T) {
runTests(t, func(t *testing.T, store testStore,
@ -2248,7 +2269,7 @@ func TestFeeBumping(t *testing.T) {
})
})
t.Run("WithNoBumping", func(t *testing.T) {
t.Run("fixed fee rate", func(t *testing.T) {
runTests(t, func(t *testing.T, store testStore,
batcherStore testBatcherStore) {