|
| 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