Skip to content

Commit 44d9147

Browse files
committed
sweepbatcher: add greedy batch selection algorithm
If custom fee rates are used, the algorithm is tried. It selects a batch for the sweep using the greedy algorithm, which minimizes costs, and adds the sweep to the batch. If it fails, old algorithm is used, trying to add to any batch, or starting a new batch.
1 parent 019d3c6 commit 44d9147

File tree

4 files changed

+1079
-3
lines changed

4 files changed

+1079
-3
lines changed
Lines changed: 347 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,347 @@
1+
package sweepbatcher
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"sort"
8+
9+
"github.com/btcsuite/btcd/btcutil"
10+
"github.com/btcsuite/btcd/txscript"
11+
sweeppkg "github.com/lightninglabs/loop/sweep"
12+
"github.com/lightningnetwork/lnd/input"
13+
"github.com/lightningnetwork/lnd/lntypes"
14+
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
15+
)
16+
17+
// greedyAddSweep selects a batch for the sweep using the greedy algorithm,
18+
// which minimizes costs, and adds the sweep to the batch. To accomplish this,
19+
// it first collects fee details about the sweep being added, about a potential
20+
// new batch composed of this sweep only, and about all existing batches. Then
21+
// it passes the data to selectBatches() function, which emulates adding the
22+
// sweep to each batch and creating new batch for the sweep, and calculates the
23+
// costs of each alternative. Based on the estimates of selectBatches(), this
24+
// method adds the sweep to the batch that results in the least overall fee
25+
// increase, or creates new batch for it. If the sweep is not accepted by an
26+
// existing batch (may happen because of too distant timeouts), next batch is
27+
// tried in the list returned by selectBatches(). If adding fails or new batch
28+
// creation fails, this method returns an error. If this method fails for any
29+
// reason, the caller falls back to the simple algorithm (method handleSweep).
30+
func (b *Batcher) greedyAddSweep(ctx context.Context, sweep *sweep) error {
31+
if b.customFeeRate == nil {
32+
return errors.New("greedy batch selection algorithm requires " +
33+
"setting custom fee rate provider")
34+
}
35+
36+
// Collect weight and fee rate info about the sweep and new batch.
37+
sweepFeeDetails, newBatchFeeDetails, err := estimateSweepFeeIncrement(
38+
sweep,
39+
)
40+
if err != nil {
41+
return fmt.Errorf("failed to estimate tx weight for "+
42+
"sweep %x: %w", sweep.swapHash[:6], err)
43+
}
44+
45+
// Collect weight and fee rate info about existing batches.
46+
batches := make([]feeDetails, 0, len(b.batches))
47+
for _, existingBatch := range b.batches {
48+
batchFeeDetails, err := estimateBatchWeight(existingBatch)
49+
if err != nil {
50+
return fmt.Errorf("failed to estimate tx weight for "+
51+
"batch %d: %w", existingBatch.id, err)
52+
}
53+
batches = append(batches, batchFeeDetails)
54+
}
55+
56+
// Run the algorithm. Get batchId of possible batches, sorted from best
57+
// to worst.
58+
batchesIds, err := selectBatches(
59+
batches, sweepFeeDetails, newBatchFeeDetails,
60+
)
61+
if err != nil {
62+
return fmt.Errorf("batch selection algorithm failed for sweep "+
63+
"%x: %w", sweep.swapHash[:6], err)
64+
}
65+
66+
// Try batches, starting with the best.
67+
for _, batchId := range batchesIds {
68+
// If the best option is to start new batch, do it.
69+
if batchId == newBatchSignal {
70+
return b.spinUpNewBatch(ctx, sweep)
71+
}
72+
73+
// Locate the batch to add the sweep to.
74+
bestBatch, has := b.batches[batchId]
75+
if !has {
76+
return fmt.Errorf("batch selection algorithm returned "+
77+
"batch id %d which doesn't exist, for sweep %x",
78+
batchId, sweep.swapHash[:6])
79+
}
80+
81+
// Add the sweep to the batch.
82+
accepted, err := bestBatch.addSweep(ctx, sweep)
83+
if err != nil {
84+
return fmt.Errorf("batch selection algorithm returned "+
85+
"batch id %d for sweep %x, but adding failed: "+
86+
"%w", batchId, sweep.swapHash[:6], err)
87+
}
88+
if accepted {
89+
return nil
90+
}
91+
92+
log.Debugf("Batch selection algorithm returned batch id %d for"+
93+
" sweep %x, but acceptance failed.", batchId,
94+
sweep.swapHash[:6])
95+
}
96+
97+
return fmt.Errorf("no batch accepted sweep %x", sweep.swapHash[:6])
98+
}
99+
100+
// estimateSweepFeeIncrement returns fee details for adding the sweep to a batch
101+
// and for creating new batch with this sweep only.
102+
func estimateSweepFeeIncrement(s *sweep) (feeDetails, feeDetails, error) {
103+
// Create a fake batch with this sweep.
104+
batch := &batch{
105+
rbfCache: rbfCache{
106+
FeeRate: s.minFeeRate,
107+
},
108+
sweeps: map[lntypes.Hash]sweep{
109+
s.swapHash: *s,
110+
},
111+
}
112+
113+
// Estimate new batch.
114+
fd1, err := estimateBatchWeight(batch)
115+
if err != nil {
116+
return feeDetails{}, feeDetails{}, err
117+
}
118+
119+
// Add the same sweep again to measure weight increments.
120+
swapHash2 := s.swapHash
121+
swapHash2[0]++
122+
batch.sweeps[swapHash2] = *s
123+
124+
// Estimate weight of a batch with two sweeps.
125+
fd2, err := estimateBatchWeight(batch)
126+
if err != nil {
127+
return feeDetails{}, feeDetails{}, err
128+
}
129+
130+
// Create feeDetails for sweep.
131+
sweepFeeDetails := feeDetails{
132+
FeeRate: s.minFeeRate,
133+
NonCoopHint: s.nonCoopHint,
134+
IsExternalAddr: s.isExternalAddr,
135+
136+
// Calculate sweep weight as a difference.
137+
CoopWeight: fd2.CoopWeight - fd1.CoopWeight,
138+
NonCoopWeight: fd2.NonCoopWeight - fd1.NonCoopWeight,
139+
}
140+
141+
return sweepFeeDetails, fd1, nil
142+
}
143+
144+
// estimateBatchWeight estimates batch weight and returns its fee details.
145+
func estimateBatchWeight(batch *batch) (feeDetails, error) {
146+
// Make sure the batch is not empty.
147+
if len(batch.sweeps) == 0 {
148+
return feeDetails{}, errors.New("empty batch")
149+
}
150+
151+
// Make sure fee rate is valid.
152+
if batch.rbfCache.FeeRate < chainfee.AbsoluteFeePerKwFloor {
153+
return feeDetails{}, fmt.Errorf("feeRate is too low: %v",
154+
batch.rbfCache.FeeRate)
155+
}
156+
157+
// Find if the batch has at least one non-cooperative sweep.
158+
hasNonCoop := false
159+
for _, sweep := range batch.sweeps {
160+
if sweep.nonCoopHint {
161+
hasNonCoop = true
162+
}
163+
}
164+
165+
// Find some sweep of the batch. It is used if there is just one sweep.
166+
var theSweep sweep
167+
for _, sweep := range batch.sweeps {
168+
theSweep = sweep
169+
break
170+
}
171+
172+
// Find sweep destination address (type) for weight estimations.
173+
var destAddr btcutil.Address
174+
if theSweep.isExternalAddr {
175+
if theSweep.destAddr == nil {
176+
return feeDetails{}, errors.New("isExternalAddr=true," +
177+
" but destAddr is nil")
178+
}
179+
destAddr = theSweep.destAddr
180+
} else {
181+
// Assume it is taproot by default.
182+
destAddr = (*btcutil.AddressTaproot)(nil)
183+
}
184+
185+
// Make two estimators: for coop and non-coop cases.
186+
var coopWeight, nonCoopWeight input.TxWeightEstimator
187+
188+
// Add output weight to the estimator.
189+
err := sweeppkg.AddOutputEstimate(&coopWeight, destAddr)
190+
if err != nil {
191+
return feeDetails{}, fmt.Errorf("sweep.AddOutputEstimate: %w",
192+
err)
193+
}
194+
err = sweeppkg.AddOutputEstimate(&nonCoopWeight, destAddr)
195+
if err != nil {
196+
return feeDetails{}, fmt.Errorf("sweep.AddOutputEstimate: %w",
197+
err)
198+
}
199+
200+
// Add inputs.
201+
for _, sweep := range batch.sweeps {
202+
// TODO: it should be txscript.SigHashDefault.
203+
coopWeight.AddTaprootKeySpendInput(txscript.SigHashAll)
204+
205+
err = sweep.htlcSuccessEstimator(&nonCoopWeight)
206+
if err != nil {
207+
return feeDetails{}, fmt.Errorf("htlcSuccessEstimator "+
208+
"failed: %w", err)
209+
}
210+
}
211+
212+
return feeDetails{
213+
BatchId: batch.id,
214+
FeeRate: batch.rbfCache.FeeRate,
215+
CoopWeight: coopWeight.Weight(),
216+
NonCoopWeight: nonCoopWeight.Weight(),
217+
NonCoopHint: hasNonCoop,
218+
IsExternalAddr: theSweep.isExternalAddr,
219+
}, nil
220+
}
221+
222+
// newBatchSignal is the value that indicates a new batch. It is returned by
223+
// selectBatches to encode new batch creation.
224+
const newBatchSignal = -1
225+
226+
// feeDetails is either a batch or a sweep and it holds data important for
227+
// selection of a batch to add the sweep to (or new batch creation).
228+
type feeDetails struct {
229+
BatchId int32
230+
FeeRate chainfee.SatPerKWeight
231+
CoopWeight lntypes.WeightUnit
232+
NonCoopWeight lntypes.WeightUnit
233+
NonCoopHint bool
234+
IsExternalAddr bool
235+
}
236+
237+
// fee returns fee of onchain transaction representing this instance.
238+
func (e feeDetails) fee() btcutil.Amount {
239+
var weight lntypes.WeightUnit
240+
if e.NonCoopHint {
241+
weight = e.NonCoopWeight
242+
} else {
243+
weight = e.CoopWeight
244+
}
245+
246+
return e.FeeRate.FeeForWeight(weight)
247+
}
248+
249+
// combine returns new feeDetails, combining properties.
250+
func (e1 feeDetails) combine(e2 feeDetails) feeDetails {
251+
// The fee rate is max of two fee rates.
252+
feeRate := e1.FeeRate
253+
if feeRate < e2.FeeRate {
254+
feeRate = e2.FeeRate
255+
}
256+
257+
return feeDetails{
258+
FeeRate: feeRate,
259+
CoopWeight: e1.CoopWeight + e2.CoopWeight,
260+
NonCoopWeight: e1.NonCoopWeight + e2.NonCoopWeight,
261+
NonCoopHint: e1.NonCoopHint || e2.NonCoopHint,
262+
IsExternalAddr: e1.IsExternalAddr || e2.IsExternalAddr,
263+
}
264+
}
265+
266+
// selectBatches returns the list of id of batches sorted from best to worst.
267+
// Creation a new batch is encoded as newBatchSignal. For each batch its fee
268+
// rate and two weights are provided: weight in case of cooperative spending and
269+
// weight in case non-cooperative spending (using preimages instead of taproot
270+
// key spend). Also, a hint is provided to signal if the batch has to use
271+
// non-cooperative spending path. The same data is also provided to the sweep
272+
// for which we are selecting a batch to add. In case of the sweep weights are
273+
// weight deltas resulted from adding the sweep. Finally, the same data is
274+
// provided for new batch having this sweep only. The algorithm compares costs
275+
// of adding the sweep to each existing batch, and costs of new batch creation
276+
// for this sweep and returns BatchId of the winning batch. If the best option
277+
// is to create a new batch, return newBatchSignal. Each fee details has also
278+
// IsExternalAddr flag. There is a rule that sweeps having flag IsExternalAddr
279+
// must go in individual batches. Cooperative spending is only available if all
280+
// the sweeps support cooperative spending path.
281+
func selectBatches(batches []feeDetails, sweep, oneSweepBatch feeDetails) (
282+
[]int32, error) {
283+
284+
// If the sweep has IsExternalAddr flag, the sweep can't be added to
285+
// a batch, so create new batch for it.
286+
if sweep.IsExternalAddr {
287+
return []int32{newBatchSignal}, nil
288+
}
289+
290+
// alternative holds batch ID and its cost.
291+
type alternative struct {
292+
batchId int32
293+
cost btcutil.Amount
294+
}
295+
296+
// Create the list of possible actions and their costs.
297+
alternatives := make([]alternative, 0, len(batches)+1)
298+
299+
// Track the best batch to add a sweep to. The default case is new batch
300+
// creation with this sweep only in it. The cost is its full fee.
301+
alternatives = append(alternatives, alternative{
302+
batchId: newBatchSignal,
303+
cost: oneSweepBatch.fee(),
304+
})
305+
306+
// Try to add the sweep to every batch, calculate the costs and
307+
// find the batch adding to which results in minimum costs.
308+
for _, batch := range batches {
309+
// If the batch has IsExternalAddr flag, the sweep can't be
310+
// added to it, so skip the batch.
311+
if batch.IsExternalAddr {
312+
continue
313+
}
314+
315+
// Add the sweep to the batch virtually.
316+
combinedBatch := batch.combine(sweep)
317+
318+
// The cost is the fee increase.
319+
cost := combinedBatch.fee() - batch.fee()
320+
321+
// The cost must be positive, because we added a sweep.
322+
if cost <= 0 {
323+
return nil, fmt.Errorf("got non-positive cost of "+
324+
"adding sweep to batch %d: %d", batch.BatchId,
325+
cost)
326+
}
327+
328+
// Track the best batch, according to the costs.
329+
alternatives = append(alternatives, alternative{
330+
batchId: batch.BatchId,
331+
cost: cost,
332+
})
333+
}
334+
335+
// Sort the alternatives by cost. The lower the cost, the better.
336+
sort.Slice(alternatives, func(i, j int) bool {
337+
return alternatives[i].cost < alternatives[j].cost
338+
})
339+
340+
// Collect batches IDs.
341+
batchesIds := make([]int32, len(alternatives))
342+
for i, alternative := range alternatives {
343+
batchesIds[i] = alternative.batchId
344+
}
345+
346+
return batchesIds, nil
347+
}

0 commit comments

Comments
 (0)