• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

lightningnetwork / lnd / 15155511119

21 May 2025 06:52AM UTC coverage: 57.389% (-11.6%) from 68.996%
15155511119

Pull #9844

github

web-flow
Merge 8658c8597 into c52a6ddeb
Pull Request #9844: Refactor Payment PR 3

346 of 493 new or added lines in 4 files covered. (70.18%)

30172 existing lines in 456 files now uncovered.

95441 of 166305 relevant lines covered (57.39%)

0.61 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

84.42
/sweep/aggregator.go
1
package sweep
2

3
import (
4
        "sort"
5

6
        "github.com/btcsuite/btcd/btcutil"
7
        "github.com/btcsuite/btcd/wire"
8
        "github.com/lightningnetwork/lnd/fn/v2"
9
        "github.com/lightningnetwork/lnd/input"
10
        "github.com/lightningnetwork/lnd/lntypes"
11
        "github.com/lightningnetwork/lnd/lnwallet"
12
        "github.com/lightningnetwork/lnd/lnwallet/chainfee"
13
)
14

15
// UtxoAggregator defines an interface that takes a list of inputs and
16
// aggregate them into groups. Each group is used as the inputs to create a
17
// sweeping transaction.
18
type UtxoAggregator interface {
19
        // ClusterInputs takes a list of inputs and groups them into input
20
        // sets. Each input set will be used to create a sweeping transaction.
21
        ClusterInputs(inputs InputsMap) []InputSet
22
}
23

24
// BudgetAggregator is a budget-based aggregator that creates clusters based on
25
// deadlines and budgets of inputs.
26
type BudgetAggregator struct {
27
        // estimator is used when crafting sweep transactions to estimate the
28
        // necessary fee relative to the expected size of the sweep
29
        // transaction.
30
        estimator chainfee.Estimator
31

32
        // maxInputs specifies the maximum number of inputs allowed in a single
33
        // sweep tx.
34
        maxInputs uint32
35

36
        // auxSweeper is an optional interface that can be used to modify the
37
        // way sweep transaction are generated.
38
        auxSweeper fn.Option[AuxSweeper]
39
}
40

41
// Compile-time constraint to ensure BudgetAggregator implements UtxoAggregator.
42
var _ UtxoAggregator = (*BudgetAggregator)(nil)
43

44
// NewBudgetAggregator creates a new instance of a BudgetAggregator.
45
func NewBudgetAggregator(estimator chainfee.Estimator,
46
        maxInputs uint32, auxSweeper fn.Option[AuxSweeper]) *BudgetAggregator {
1✔
47

1✔
48
        return &BudgetAggregator{
1✔
49
                estimator:  estimator,
1✔
50
                maxInputs:  maxInputs,
1✔
51
                auxSweeper: auxSweeper,
1✔
52
        }
1✔
53
}
1✔
54

55
// clusterGroup defines an alias for a set of inputs that are to be grouped.
56
type clusterGroup map[int32][]SweeperInput
57

58
// ClusterInputs creates a list of input sets from pending inputs.
59
// 1. filter out inputs whose budget cannot cover min relay fee.
60
// 2. filter a list of exclusive inputs.
61
// 3. group the inputs into clusters based on their deadline height.
62
// 4. sort the inputs in each cluster by their budget.
63
// 5. optionally split a cluster if it exceeds the max input limit.
64
// 6. create input sets from each of the clusters.
65
// 7. create input sets for each of the exclusive inputs.
66
func (b *BudgetAggregator) ClusterInputs(inputs InputsMap) []InputSet {
1✔
67
        // Filter out inputs that have a budget below min relay fee.
1✔
68
        filteredInputs := b.filterInputs(inputs)
1✔
69

1✔
70
        // Create clusters to group inputs based on their deadline height.
1✔
71
        clusters := make(clusterGroup, len(filteredInputs))
1✔
72

1✔
73
        // exclusiveInputs is a set of inputs that are not to be included in
1✔
74
        // any cluster. These inputs can only be swept independently as there's
1✔
75
        // no guarantee which input will be confirmed first, which means
1✔
76
        // grouping exclusive inputs may jeopardize non-exclusive inputs.
1✔
77
        exclusiveInputs := make(map[wire.OutPoint]clusterGroup)
1✔
78

1✔
79
        // Iterate all the inputs and group them based on their specified
1✔
80
        // deadline heights.
1✔
81
        for _, input := range filteredInputs {
2✔
82
                // Get deadline height, and use the specified default deadline
1✔
83
                // height if it's not set.
1✔
84
                height := input.DeadlineHeight
1✔
85

1✔
86
                // Put exclusive inputs in their own set.
1✔
87
                if input.params.ExclusiveGroup != nil {
2✔
88
                        log.Tracef("Input %v is exclusive", input.OutPoint())
1✔
89
                        exclusiveInputs[input.OutPoint()] = clusterGroup{
1✔
90
                                height: []SweeperInput{*input},
1✔
91
                        }
1✔
92

1✔
93
                        continue
1✔
94
                }
95

96
                cluster, ok := clusters[height]
1✔
97
                if !ok {
2✔
98
                        cluster = make([]SweeperInput, 0)
1✔
99
                }
1✔
100

101
                cluster = append(cluster, *input)
1✔
102
                clusters[height] = cluster
1✔
103
        }
104

105
        // Now that we have the clusters, we can create the input sets.
106
        //
107
        // NOTE: cannot pre-allocate the slice since we don't know the number
108
        // of input sets in advance.
109
        inputSets := make([]InputSet, 0)
1✔
110
        for height, cluster := range clusters {
2✔
111
                // Sort the inputs by their economical value.
1✔
112
                sortedInputs := b.sortInputs(cluster)
1✔
113

1✔
114
                // Split on locktimes if they are different.
1✔
115
                splitClusters := splitOnLocktime(sortedInputs)
1✔
116

1✔
117
                // Create input sets from the cluster.
1✔
118
                for _, cluster := range splitClusters {
2✔
119
                        sets := b.createInputSets(cluster, height)
1✔
120
                        inputSets = append(inputSets, sets...)
1✔
121
                }
1✔
122
        }
123

124
        // Create input sets from the exclusive inputs.
125
        for _, cluster := range exclusiveInputs {
2✔
126
                for height, input := range cluster {
2✔
127
                        sets := b.createInputSets(input, height)
1✔
128
                        inputSets = append(inputSets, sets...)
1✔
129
                }
1✔
130
        }
131

132
        return inputSets
1✔
133
}
134

135
// createInputSet takes a set of inputs which share the same deadline height
136
// and turns them into a list of `InputSet`, each set is then used to create a
137
// sweep transaction.
138
//
139
// TODO(yy): by the time we call this method, all the invalid/uneconomical
140
// inputs have been filtered out, all the inputs have been sorted based on
141
// their budgets, and we are about to create input sets. The only thing missing
142
// here is, we need to group the inputs here even further based on whether
143
// their budgets can cover the starting fee rate used for this input set.
144
func (b *BudgetAggregator) createInputSets(inputs []SweeperInput,
145
        deadlineHeight int32) []InputSet {
1✔
146

1✔
147
        // sets holds the InputSets that we will return.
1✔
148
        sets := make([]InputSet, 0)
1✔
149

1✔
150
        // Copy the inputs to a new slice so we can modify it.
1✔
151
        remainingInputs := make([]SweeperInput, len(inputs))
1✔
152
        copy(remainingInputs, inputs)
1✔
153

1✔
154
        // If the number of inputs is greater than the max inputs allowed, we
1✔
155
        // will split them into smaller clusters.
1✔
156
        for uint32(len(remainingInputs)) > b.maxInputs {
1✔
UNCOV
157
                log.Tracef("Cluster has %v inputs, max is %v, dividing...",
×
UNCOV
158
                        len(inputs), b.maxInputs)
×
UNCOV
159

×
UNCOV
160
                // Copy the inputs to be put into the new set, and update the
×
UNCOV
161
                // remaining inputs by removing currentInputs.
×
UNCOV
162
                currentInputs := make([]SweeperInput, b.maxInputs)
×
UNCOV
163
                copy(currentInputs, remainingInputs[:b.maxInputs])
×
UNCOV
164
                remainingInputs = remainingInputs[b.maxInputs:]
×
UNCOV
165

×
UNCOV
166
                // Create an InputSet using the max allowed number of inputs.
×
UNCOV
167
                set, err := NewBudgetInputSet(
×
UNCOV
168
                        currentInputs, deadlineHeight, b.auxSweeper,
×
UNCOV
169
                )
×
UNCOV
170
                if err != nil {
×
UNCOV
171
                        log.Errorf("unable to create input set: %v", err)
×
UNCOV
172

×
UNCOV
173
                        continue
×
174
                }
175

UNCOV
176
                sets = append(sets, set)
×
177
        }
178

179
        // Create an InputSet from the remaining inputs.
180
        if len(remainingInputs) > 0 {
2✔
181
                set, err := NewBudgetInputSet(
1✔
182
                        remainingInputs, deadlineHeight, b.auxSweeper,
1✔
183
                )
1✔
184
                if err != nil {
1✔
185
                        log.Errorf("unable to create input set: %v", err)
×
186
                        return nil
×
187
                }
×
188

189
                sets = append(sets, set)
1✔
190
        }
191

192
        return sets
1✔
193
}
194

195
// filterInputs filters out inputs that have,
196
// - a budget below the min relay fee.
197
// - a budget below its requested starting fee.
198
// - a required output that's below the dust.
199
func (b *BudgetAggregator) filterInputs(inputs InputsMap) InputsMap {
1✔
200
        // Get the current min relay fee for this round.
1✔
201
        minFeeRate := b.estimator.RelayFeePerKW()
1✔
202

1✔
203
        // filterInputs stores a map of inputs that has a budget that at least
1✔
204
        // can pay the minimal fee.
1✔
205
        filteredInputs := make(InputsMap, len(inputs))
1✔
206

1✔
207
        // Iterate all the inputs and filter out the ones whose budget cannot
1✔
208
        // cover the min fee.
1✔
209
        for _, pi := range inputs {
2✔
210
                op := pi.OutPoint()
1✔
211

1✔
212
                // Get the size of the witness and skip if there's an error.
1✔
213
                witnessSize, _, err := pi.WitnessType().SizeUpperBound()
1✔
214
                if err != nil {
1✔
UNCOV
215
                        log.Warnf("Skipped input=%v: cannot get its size: %v",
×
UNCOV
216
                                op, err)
×
UNCOV
217

×
UNCOV
218
                        continue
×
219
                }
220

221
                //nolint:ll
222
                // Calculate the size if the input is included in the tx.
223
                //
224
                // NOTE: When including this input, we need to account the
225
                // non-witness data which is expressed in vb.
226
                //
227
                // TODO(yy): This is not accurate for tapscript input. We need
228
                // to unify calculations used in the `TxWeightEstimator` inside
229
                // `input/size.go` and `weightEstimator` in
230
                // `weight_estimator.go`. And calculate the expected weights
231
                // similar to BOLT-3:
232
                // https://github.com/lightning/bolts/blob/master/03-transactions.md#appendix-a-expected-weights
233
                wu := lntypes.VByte(input.InputSize).ToWU() + witnessSize
1✔
234

1✔
235
                // Skip inputs that has too little budget.
1✔
236
                minFee := minFeeRate.FeeForWeight(wu)
1✔
237
                if pi.params.Budget < minFee {
2✔
238
                        log.Warnf("Skipped input=%v: has budget=%v, but the "+
1✔
239
                                "min fee requires %v (feerate=%v), size=%v", op,
1✔
240
                                pi.params.Budget, minFee,
1✔
241
                                minFeeRate.FeePerVByte(), wu.ToVB())
1✔
242

1✔
243
                        continue
1✔
244
                }
245

246
                // Skip inputs that has cannot cover its starting fees.
247
                startingFeeRate := pi.params.StartingFeeRate.UnwrapOr(
1✔
248
                        chainfee.SatPerKWeight(0),
1✔
249
                )
1✔
250
                startingFee := startingFeeRate.FeeForWeight(wu)
1✔
251
                if pi.params.Budget < startingFee {
1✔
252
                        log.Errorf("Skipped input=%v: has budget=%v, but the "+
×
253
                                "starting fee requires %v (feerate=%v), "+
×
254
                                "size=%v", op, pi.params.Budget, startingFee,
×
255
                                startingFeeRate.FeePerVByte(), wu.ToVB())
×
256

×
257
                        continue
×
258
                }
259

260
                // If the input comes with a required tx out that is below
261
                // dust, we won't add it.
262
                //
263
                // NOTE: only HtlcSecondLevelAnchorInput returns non-nil
264
                // RequiredTxOut.
265
                reqOut := pi.RequiredTxOut()
1✔
266
                if reqOut != nil {
2✔
267
                        if isDustOutput(reqOut) {
1✔
UNCOV
268
                                log.Errorf("Rejected input=%v due to dust "+
×
UNCOV
269
                                        "required output=%v", op, reqOut.Value)
×
UNCOV
270

×
UNCOV
271
                                continue
×
272
                        }
273
                }
274

275
                filteredInputs[op] = pi
1✔
276
        }
277

278
        return filteredInputs
1✔
279
}
280

281
// sortInputs sorts the inputs based on their economical value.
282
//
283
// NOTE: besides the forced inputs, the sorting won't make any difference
284
// because all the inputs are added to the same set. The exception is when the
285
// number of inputs exceeds the maxInputs limit, it requires us to split them
286
// into smaller clusters. In that case, the sorting will make a difference as
287
// the budgets of the clusters will be different.
288
func (b *BudgetAggregator) sortInputs(inputs []SweeperInput) []SweeperInput {
1✔
289
        // sortedInputs is the final list of inputs sorted by their economical
1✔
290
        // value.
1✔
291
        sortedInputs := make([]SweeperInput, 0, len(inputs))
1✔
292

1✔
293
        // Copy the inputs.
1✔
294
        sortedInputs = append(sortedInputs, inputs...)
1✔
295

1✔
296
        // Sort the inputs based on their budgets.
1✔
297
        //
1✔
298
        // NOTE: We can implement more sophisticated algorithm as the budget
1✔
299
        // left is a function f(minFeeRate, size) = b1 - s1 * r > b2 - s2 * r,
1✔
300
        // where b1 and b2 are budgets, s1 and s2 are sizes of the inputs.
1✔
301
        sort.Slice(sortedInputs, func(i, j int) bool {
2✔
302
                left := sortedInputs[i].params.Budget
1✔
303
                right := sortedInputs[j].params.Budget
1✔
304

1✔
305
                // Make sure forced inputs are always put in the front.
1✔
306
                leftForce := sortedInputs[i].params.Immediate
1✔
307
                rightForce := sortedInputs[j].params.Immediate
1✔
308

1✔
309
                // If both are forced inputs, we return the one with the higher
1✔
310
                // budget. If neither are forced inputs, we also return the one
1✔
311
                // with the higher budget.
1✔
312
                if leftForce == rightForce {
2✔
313
                        return left > right
1✔
314
                }
1✔
315

316
                // Otherwise, it's either the left or the right is forced. We
317
                // can simply return `leftForce` here as, if it's true, the
318
                // left is forced and should be put in the front. Otherwise,
319
                // the right is forced and should be put in the front.
UNCOV
320
                return leftForce
×
321
        })
322

323
        return sortedInputs
1✔
324
}
325

326
// splitOnLocktime splits the list of inputs based on their locktime.
327
//
328
// TODO(yy): this is a temporary hack as the blocks are not synced among the
329
// contractcourt and the sweeper.
330
func splitOnLocktime(inputs []SweeperInput) map[uint32][]SweeperInput {
1✔
331
        result := make(map[uint32][]SweeperInput)
1✔
332
        noLocktimeInputs := make([]SweeperInput, 0, len(inputs))
1✔
333

1✔
334
        // mergeLocktime is the locktime that we use to merge all the
1✔
335
        // nolocktime inputs into.
1✔
336
        var mergeLocktime uint32
1✔
337

1✔
338
        // Iterate all inputs and split them based on their locktimes.
1✔
339
        for _, inp := range inputs {
2✔
340
                locktime, required := inp.RequiredLockTime()
1✔
341
                if !required {
2✔
342
                        log.Tracef("No locktime required for input=%v",
1✔
343
                                inp.OutPoint())
1✔
344

1✔
345
                        noLocktimeInputs = append(noLocktimeInputs, inp)
1✔
346

1✔
347
                        continue
1✔
348
                }
349

350
                log.Tracef("Split input=%v on locktime=%v", inp.OutPoint(),
1✔
351
                        locktime)
1✔
352

1✔
353
                // Get the slice - the slice will be initialized if not found.
1✔
354
                inputList := result[locktime]
1✔
355

1✔
356
                // Add the input to the list.
1✔
357
                inputList = append(inputList, inp)
1✔
358

1✔
359
                // Update the map.
1✔
360
                result[locktime] = inputList
1✔
361

1✔
362
                // Update the merge locktime.
1✔
363
                mergeLocktime = locktime
1✔
364
        }
365

366
        // If there are locktime inputs, we will merge the no locktime inputs
367
        // to the last locktime group found.
368
        if len(result) > 0 {
2✔
369
                log.Tracef("No locktime inputs has been merged to locktime=%v",
1✔
370
                        mergeLocktime)
1✔
371
                result[mergeLocktime] = append(
1✔
372
                        result[mergeLocktime], noLocktimeInputs...,
1✔
373
                )
1✔
374
        } else {
2✔
375
                // Otherwise just return the no locktime inputs.
1✔
376
                result[mergeLocktime] = noLocktimeInputs
1✔
377
        }
1✔
378

379
        return result
1✔
380
}
381

382
// isDustOutput checks if the given output is considered as dust.
383
func isDustOutput(output *wire.TxOut) bool {
1✔
384
        // Fetch the dust limit for this output.
1✔
385
        dustLimit := lnwallet.DustLimitForSize(len(output.PkScript))
1✔
386

1✔
387
        // If the output is below the dust limit, we consider it dust.
1✔
388
        return btcutil.Amount(output.Value) < dustLimit
1✔
389
}
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc