lnd/sweep/aggregator.go
Olaoluwa Osuntokun eaea11e48f
sweep: update sweeper to use AuxSweeper to add extra change addr
In this commit, we start to use the AuxSweeper (if present) to obtain a
new extra change addr we should add to the sweeping transaction. With
this, we'll take the set of inputs and our change addr, and then maybe
gain a new change addr to add to the sweep transaction.

The extra change addr will be treated as an extra required tx out,
shared across all the relevant inputs. This'll also be used in
NeedWalletInput to make sure that we add an extra input if needed to be
able to pay for the change addr.
2024-10-02 18:09:58 -07:00

390 lines
12 KiB
Go

package sweep
import (
"sort"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/wire"
"github.com/lightningnetwork/lnd/fn"
"github.com/lightningnetwork/lnd/input"
"github.com/lightningnetwork/lnd/lntypes"
"github.com/lightningnetwork/lnd/lnwallet"
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
)
// UtxoAggregator defines an interface that takes a list of inputs and
// aggregate them into groups. Each group is used as the inputs to create a
// sweeping transaction.
type UtxoAggregator interface {
// ClusterInputs takes a list of inputs and groups them into input
// sets. Each input set will be used to create a sweeping transaction.
ClusterInputs(inputs InputsMap) []InputSet
}
// BudgetAggregator is a budget-based aggregator that creates clusters based on
// deadlines and budgets of inputs.
type BudgetAggregator struct {
// estimator is used when crafting sweep transactions to estimate the
// necessary fee relative to the expected size of the sweep
// transaction.
estimator chainfee.Estimator
// maxInputs specifies the maximum number of inputs allowed in a single
// sweep tx.
maxInputs uint32
// auxSweeper is an optional interface that can be used to modify the
// way sweep transaction are generated.
auxSweeper fn.Option[AuxSweeper]
}
// Compile-time constraint to ensure BudgetAggregator implements UtxoAggregator.
var _ UtxoAggregator = (*BudgetAggregator)(nil)
// NewBudgetAggregator creates a new instance of a BudgetAggregator.
func NewBudgetAggregator(estimator chainfee.Estimator,
maxInputs uint32, auxSweeper fn.Option[AuxSweeper]) *BudgetAggregator {
return &BudgetAggregator{
estimator: estimator,
maxInputs: maxInputs,
auxSweeper: auxSweeper,
}
}
// clusterGroup defines an alias for a set of inputs that are to be grouped.
type clusterGroup map[int32][]SweeperInput
// ClusterInputs creates a list of input sets from pending inputs.
// 1. filter out inputs whose budget cannot cover min relay fee.
// 2. filter a list of exclusive inputs.
// 3. group the inputs into clusters based on their deadline height.
// 4. sort the inputs in each cluster by their budget.
// 5. optionally split a cluster if it exceeds the max input limit.
// 6. create input sets from each of the clusters.
// 7. create input sets for each of the exclusive inputs.
func (b *BudgetAggregator) ClusterInputs(inputs InputsMap) []InputSet {
// Filter out inputs that have a budget below min relay fee.
filteredInputs := b.filterInputs(inputs)
// Create clusters to group inputs based on their deadline height.
clusters := make(clusterGroup, len(filteredInputs))
// exclusiveInputs is a set of inputs that are not to be included in
// any cluster. These inputs can only be swept independently as there's
// no guarantee which input will be confirmed first, which means
// grouping exclusive inputs may jeopardize non-exclusive inputs.
exclusiveInputs := make(map[wire.OutPoint]clusterGroup)
// Iterate all the inputs and group them based on their specified
// deadline heights.
for _, input := range filteredInputs {
// Get deadline height, and use the specified default deadline
// height if it's not set.
height := input.DeadlineHeight
// Put exclusive inputs in their own set.
if input.params.ExclusiveGroup != nil {
log.Tracef("Input %v is exclusive", input.OutPoint())
exclusiveInputs[input.OutPoint()] = clusterGroup{
height: []SweeperInput{*input},
}
continue
}
cluster, ok := clusters[height]
if !ok {
cluster = make([]SweeperInput, 0)
}
cluster = append(cluster, *input)
clusters[height] = cluster
}
// Now that we have the clusters, we can create the input sets.
//
// NOTE: cannot pre-allocate the slice since we don't know the number
// of input sets in advance.
inputSets := make([]InputSet, 0)
for height, cluster := range clusters {
// Sort the inputs by their economical value.
sortedInputs := b.sortInputs(cluster)
// Split on locktimes if they are different.
splitClusters := splitOnLocktime(sortedInputs)
// Create input sets from the cluster.
for _, cluster := range splitClusters {
sets := b.createInputSets(cluster, height)
inputSets = append(inputSets, sets...)
}
}
// Create input sets from the exclusive inputs.
for _, cluster := range exclusiveInputs {
for height, input := range cluster {
sets := b.createInputSets(input, height)
inputSets = append(inputSets, sets...)
}
}
return inputSets
}
// createInputSet takes a set of inputs which share the same deadline height
// and turns them into a list of `InputSet`, each set is then used to create a
// sweep transaction.
//
// TODO(yy): by the time we call this method, all the invalid/uneconomical
// inputs have been filtered out, all the inputs have been sorted based on
// their budgets, and we are about to create input sets. The only thing missing
// here is, we need to group the inputs here even further based on whether
// their budgets can cover the starting fee rate used for this input set.
func (b *BudgetAggregator) createInputSets(inputs []SweeperInput,
deadlineHeight int32) []InputSet {
// sets holds the InputSets that we will return.
sets := make([]InputSet, 0)
// Copy the inputs to a new slice so we can modify it.
remainingInputs := make([]SweeperInput, len(inputs))
copy(remainingInputs, inputs)
// If the number of inputs is greater than the max inputs allowed, we
// will split them into smaller clusters.
for uint32(len(remainingInputs)) > b.maxInputs {
log.Tracef("Cluster has %v inputs, max is %v, dividing...",
len(inputs), b.maxInputs)
// Copy the inputs to be put into the new set, and update the
// remaining inputs by removing currentInputs.
currentInputs := make([]SweeperInput, b.maxInputs)
copy(currentInputs, remainingInputs[:b.maxInputs])
remainingInputs = remainingInputs[b.maxInputs:]
// Create an InputSet using the max allowed number of inputs.
set, err := NewBudgetInputSet(
currentInputs, deadlineHeight, b.auxSweeper,
)
if err != nil {
log.Errorf("unable to create input set: %v", err)
continue
}
sets = append(sets, set)
}
// Create an InputSet from the remaining inputs.
if len(remainingInputs) > 0 {
set, err := NewBudgetInputSet(
remainingInputs, deadlineHeight, b.auxSweeper,
)
if err != nil {
log.Errorf("unable to create input set: %v", err)
return nil
}
sets = append(sets, set)
}
return sets
}
// filterInputs filters out inputs that have,
// - a budget below the min relay fee.
// - a budget below its requested starting fee.
// - a required output that's below the dust.
func (b *BudgetAggregator) filterInputs(inputs InputsMap) InputsMap {
// Get the current min relay fee for this round.
minFeeRate := b.estimator.RelayFeePerKW()
// filterInputs stores a map of inputs that has a budget that at least
// can pay the minimal fee.
filteredInputs := make(InputsMap, len(inputs))
// Iterate all the inputs and filter out the ones whose budget cannot
// cover the min fee.
for _, pi := range inputs {
op := pi.OutPoint()
// Get the size of the witness and skip if there's an error.
witnessSize, _, err := pi.WitnessType().SizeUpperBound()
if err != nil {
log.Warnf("Skipped input=%v: cannot get its size: %v",
op, err)
continue
}
//nolint:lll
// Calculate the size if the input is included in the tx.
//
// NOTE: When including this input, we need to account the
// non-witness data which is expressed in vb.
//
// TODO(yy): This is not accurate for tapscript input. We need
// to unify calculations used in the `TxWeightEstimator` inside
// `input/size.go` and `weightEstimator` in
// `weight_estimator.go`. And calculate the expected weights
// similar to BOLT-3:
// https://github.com/lightning/bolts/blob/master/03-transactions.md#appendix-a-expected-weights
wu := lntypes.VByte(input.InputSize).ToWU() + witnessSize
// Skip inputs that has too little budget.
minFee := minFeeRate.FeeForWeight(wu)
if pi.params.Budget < minFee {
log.Warnf("Skipped input=%v: has budget=%v, but the "+
"min fee requires %v (feerate=%v), size=%v", op,
pi.params.Budget, minFee,
minFeeRate.FeePerVByte(), wu.ToVB())
continue
}
// Skip inputs that has cannot cover its starting fees.
startingFeeRate := pi.params.StartingFeeRate.UnwrapOr(
chainfee.SatPerKWeight(0),
)
startingFee := startingFeeRate.FeeForWeight(wu)
if pi.params.Budget < startingFee {
log.Errorf("Skipped input=%v: has budget=%v, but the "+
"starting fee requires %v (feerate=%v), "+
"size=%v", op, pi.params.Budget, startingFee,
startingFeeRate.FeePerVByte(), wu.ToVB())
continue
}
// If the input comes with a required tx out that is below
// dust, we won't add it.
//
// NOTE: only HtlcSecondLevelAnchorInput returns non-nil
// RequiredTxOut.
reqOut := pi.RequiredTxOut()
if reqOut != nil {
if isDustOutput(reqOut) {
log.Errorf("Rejected input=%v due to dust "+
"required output=%v", op, reqOut.Value)
continue
}
}
filteredInputs[op] = pi
}
return filteredInputs
}
// sortInputs sorts the inputs based on their economical value.
//
// NOTE: besides the forced inputs, the sorting won't make any difference
// because all the inputs are added to the same set. The exception is when the
// number of inputs exceeds the maxInputs limit, it requires us to split them
// into smaller clusters. In that case, the sorting will make a difference as
// the budgets of the clusters will be different.
func (b *BudgetAggregator) sortInputs(inputs []SweeperInput) []SweeperInput {
// sortedInputs is the final list of inputs sorted by their economical
// value.
sortedInputs := make([]SweeperInput, 0, len(inputs))
// Copy the inputs.
sortedInputs = append(sortedInputs, inputs...)
// Sort the inputs based on their budgets.
//
// NOTE: We can implement more sophisticated algorithm as the budget
// left is a function f(minFeeRate, size) = b1 - s1 * r > b2 - s2 * r,
// where b1 and b2 are budgets, s1 and s2 are sizes of the inputs.
sort.Slice(sortedInputs, func(i, j int) bool {
left := sortedInputs[i].params.Budget
right := sortedInputs[j].params.Budget
// Make sure forced inputs are always put in the front.
leftForce := sortedInputs[i].params.Immediate
rightForce := sortedInputs[j].params.Immediate
// If both are forced inputs, we return the one with the higher
// budget. If neither are forced inputs, we also return the one
// with the higher budget.
if leftForce == rightForce {
return left > right
}
// Otherwise, it's either the left or the right is forced. We
// can simply return `leftForce` here as, if it's true, the
// left is forced and should be put in the front. Otherwise,
// the right is forced and should be put in the front.
return leftForce
})
return sortedInputs
}
// splitOnLocktime splits the list of inputs based on their locktime.
//
// TODO(yy): this is a temporary hack as the blocks are not synced among the
// contractcourt and the sweeper.
func splitOnLocktime(inputs []SweeperInput) map[uint32][]SweeperInput {
result := make(map[uint32][]SweeperInput)
noLocktimeInputs := make([]SweeperInput, 0, len(inputs))
// mergeLocktime is the locktime that we use to merge all the
// nolocktime inputs into.
var mergeLocktime uint32
// Iterate all inputs and split them based on their locktimes.
for _, inp := range inputs {
locktime, required := inp.RequiredLockTime()
if !required {
log.Tracef("No locktime required for input=%v",
inp.OutPoint())
noLocktimeInputs = append(noLocktimeInputs, inp)
continue
}
log.Tracef("Split input=%v on locktime=%v", inp.OutPoint(),
locktime)
// Get the slice - the slice will be initialized if not found.
inputList := result[locktime]
// Add the input to the list.
inputList = append(inputList, inp)
// Update the map.
result[locktime] = inputList
// Update the merge locktime.
mergeLocktime = locktime
}
// If there are locktime inputs, we will merge the no locktime inputs
// to the last locktime group found.
if len(result) > 0 {
log.Tracef("No locktime inputs has been merged to locktime=%v",
mergeLocktime)
result[mergeLocktime] = append(
result[mergeLocktime], noLocktimeInputs...,
)
} else {
// Otherwise just return the no locktime inputs.
result[mergeLocktime] = noLocktimeInputs
}
return result
}
// isDustOutput checks if the given output is considered as dust.
func isDustOutput(output *wire.TxOut) bool {
// Fetch the dust limit for this output.
dustLimit := lnwallet.DustLimitForSize(len(output.PkScript))
// If the output is below the dust limit, we consider it dust.
return btcutil.Amount(output.Value) < dustLimit
}