From d4457617c5c191b449a16b1c6b766cb2c0a1174c Mon Sep 17 00:00:00 2001 From: Jared Tobin Date: Wed, 10 Jun 2026 12:16:20 -0230 Subject: [PATCH 1/2] sweep: account for aux extra budget when filtering inputs The BudgetAggregator filters out inputs whose budget cannot cover the min relay fee or their requested starting fee rate. For inputs that carry a resolution blob (custom channel outputs), the aux sweeper contributes a sizable extra budget to any input set they join, but the filter only considered the input's own budget, which for asset outputs is tiny (their value is carried off-chain). The filter is mostly harmless with default parameters, but the starting fee rate of an input is ratcheted whenever a sweep attempt fails, including failures that have nothing to do with fees: e.g. when a concurrent sweep transaction spends the wallet UTXO that was backing this input's set (the sweeper currently doesn't lease selected wallet UTXOs, so concurrent input sets can pick the same one). One such collision is enough to push the required starting fee above a small asset input's own budget, after which the input is filtered out of every future input set and the sweep is silently stranded forever. Account for the aux extra budget in the filter, mirroring how the budget input set itself accounts for it when deciding whether wallet inputs are needed. Inputs without a resolution blob (the only kind that exists without an aux sweeper) are unaffected. --- sweep/aggregator.go | 58 ++++++++++++++++-- sweep/aggregator_test.go | 129 +++++++++++++++++++++++++++++++++++++++ sweep/interface.go | 7 +++ 3 files changed, 190 insertions(+), 4 deletions(-) diff --git a/sweep/aggregator.go b/sweep/aggregator.go index 9bc6ca315ef..e65c2bb6079 100644 --- a/sweep/aggregator.go +++ b/sweep/aggregator.go @@ -1,6 +1,7 @@ package sweep import ( + "math" "sort" "github.com/btcsuite/btcd/btcutil/v2" @@ -232,12 +233,61 @@ func (b *BudgetAggregator) filterInputs(inputs InputsMap) InputsMap { // https://github.com/lightning/bolts/blob/master/03-transactions.md#appendix-a-expected-weights wu := lntypes.VByte(input.InputSize).ToWU() + witnessSize + // If an aux sweeper is set, it may contribute an extra budget + // to any input set this input becomes part of. The input's own + // budget may be tiny (e.g. for custom channel outputs whose + // value is mostly carried off-chain), so without accounting + // for the extra budget here we'd filter such inputs out + // permanently, even though their input set could comfortably + // pay its fees. + // + // The AuxSweeper interface requires the contribution to be + // non-negative and additive across inputs, so a singleton + // call returns this input's share and per-input credits sum + // to the set-level total used at set construction. On a + // lookup error we fall back to zero extra budget rather than + // dropping the input, so a transient aux failure doesn't + // recreate the silently-stranded mode this guard is meant to + // avoid. + extraBudget, err := fn.MapOptionZ( + b.auxSweeper, + func(aux AuxSweeper) fn.Result[btcutil.Amount] { + return aux.ExtraBudgetForInputs( + []input.Input{pi.Input}, + ) + }, + ).Unpack() + if err != nil { + log.Errorf("Unable to fetch extra budget for "+ + "input=%v, falling back to own budget: %v", + op, err) + + extraBudget = 0 + } + + // Defensively clamp and saturate against a misbehaving aux + // implementation. The contract requires a non-negative, + // bounded value, but if it returned a negative the filter + // would tighten and re-strand the input, and an overflow + // would wrap negative and silently drop it. This guard must + // only ever relax the filter relative to the input's own + // budget. + if extraBudget < 0 { + extraBudget = 0 + } + budget := pi.params.Budget + if extraBudget > math.MaxInt64-budget { + budget = btcutil.Amount(math.MaxInt64) + } else { + budget += extraBudget + } + // Skip inputs that has too little budget. minFee := minFeeRate.FeeForWeight(wu) - if pi.params.Budget < minFee { + if 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, + budget, minFee, minFeeRate.FeePerVByte(), wu.ToVB()) continue @@ -248,10 +298,10 @@ func (b *BudgetAggregator) filterInputs(inputs InputsMap) InputsMap { chainfee.SatPerKWeight(0), ) startingFee := startingFeeRate.FeeForWeight(wu) - if pi.params.Budget < startingFee { + if 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, + "size=%v", op, budget, startingFee, startingFeeRate.FeePerVByte(), wu.ToVB()) continue diff --git a/sweep/aggregator_test.go b/sweep/aggregator_test.go index bd674e08f0e..060ebd7dc0c 100644 --- a/sweep/aggregator_test.go +++ b/sweep/aggregator_test.go @@ -3,6 +3,7 @@ package sweep import ( "bytes" "errors" + "math" "testing" "github.com/btcsuite/btcd/btcutil/v2" @@ -164,6 +165,134 @@ func TestBudgetAggregatorFilterInputs(t *testing.T) { require.Contains(t, result, opHigh) } +// TestBudgetAggregatorFilterInputsAuxBudget checks that the aux sweeper's +// extra budget is folded into the filter's budget check, and that an aux +// lookup failure falls back to gating on the input's own budget rather than +// silently dropping the input. +func TestBudgetAggregatorFilterInputsAuxBudget(t *testing.T) { + t.Parallel() + + const wu lntypes.WeightUnit = 100 + inpSize := lntypes.VByte(input.InputSize).ToWU() + wu + + const minFeeRate = chainfee.SatPerKWeight(1000) + minFee := minFeeRate.FeeForWeight(inpSize) + + // shortfall is how much the own budget falls short of minFee; the aux + // sweeper covers exactly this gap in the "rescue" cases. + const shortfall = btcutil.Amount(100) + auxErr := errors.New("aux failure") + + testCases := []struct { + name string + ownBudget btcutil.Amount + auxResult fn.Result[btcutil.Amount] + expectKept bool + }{ + { + // The input's own budget falls short of the min fee, + // but the aux sweeper contributes enough extra budget + // to clear it. Pre-fix this input would have been + // filtered out. + name: "aux budget rescues low-own-budget input", + ownBudget: minFee - shortfall, + auxResult: fn.Ok(shortfall), + expectKept: true, + }, + { + // The aux lookup errors but the input's own budget + // already covers the min fee, so the conservative + // fallback (extraBudget=0) keeps it in. Pre-fix this + // input would have been silently dropped. + name: "aux error keeps sufficient input", + ownBudget: minFee, + auxResult: fn.Err[btcutil.Amount](auxErr), + expectKept: true, + }, + { + // The aux lookup errors and the input cannot pay its + // own way, so it is correctly filtered. + name: "aux error drops below-min-fee input", + ownBudget: minFee - shortfall, + auxResult: fn.Err[btcutil.Amount](auxErr), + expectKept: false, + }, + { + // A misbehaving aux returns a negative contribution. + // The defensive clamp must floor it at zero, so the + // input is gated on its own budget alone and kept. + // Without the clamp, the negative would tighten the + // filter and re-strand the input. + name: "negative aux clamps to zero", + ownBudget: minFee, + auxResult: fn.Ok(-shortfall), + expectKept: true, + }, + { + // A pathological aux returns a contribution that would + // overflow when added to a near-MaxInt64 own budget. + // Saturation must clamp the sum to MaxInt64; without + // it the sum would wrap negative and silently drop the + // input despite having ample budget. + name: "overflow saturates instead of wrapping", + ownBudget: btcutil.Amount(math.MaxInt64 - 1), + auxResult: fn.Ok(btcutil.Amount(math.MaxInt64 / 2)), + expectKept: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + estimator := &chainfee.MockEstimator{} + defer estimator.AssertExpectations(t) + estimator.On("RelayFeePerKW").Return(minFeeRate).Once() + + wt := &input.MockWitnessType{} + defer wt.AssertExpectations(t) + wt.On("SizeUpperBound").Return(wu, true, nil).Once() + + mockInput := &input.MockInput{} + defer mockInput.AssertExpectations(t) + op := wire.OutPoint{Hash: chainhash.Hash{1}} + mockInput.On("WitnessType").Return(wt) + mockInput.On("OutPoint").Return(op) + + // Stub RequiredTxOut unconditionally so a regression + // that lets the dropped case fall through to the dust + // check surfaces as a clean assertion failure rather + // than an unstubbed-mock panic. `Maybe()` is needed + // because the dropped case shouldn't actually reach + // this call. + mockInput.On("RequiredTxOut").Return(nil).Maybe() + + mockAux := &MockAuxSweeper{} + defer mockAux.AssertExpectations(t) + mockAux.On("ExtraBudgetForInputs").Return(tc.auxResult) + + inputs := InputsMap{ + op: &SweeperInput{ + Input: mockInput, + params: Params{Budget: tc.ownBudget}, + }, + } + + b := NewBudgetAggregator( + estimator, 0, + fn.Some[AuxSweeper](mockAux), + ) + result := b.filterInputs(inputs) + + if tc.expectKept { + require.Contains(t, result, op) + } else { + require.NotContains(t, result, op) + } + }) + } +} + // TestBudgetAggregatorSortInputs checks that inputs are sorted by based on // their budgets and force flag. func TestBudgetAggregatorSortInputs(t *testing.T) { diff --git a/sweep/interface.go b/sweep/interface.go index e9a562823c6..98863e2e115 100644 --- a/sweep/interface.go +++ b/sweep/interface.go @@ -88,6 +88,13 @@ type AuxSweeper interface { // should be allocated to sweep the given set of inputs. This can be // used to add extra funds to the sweep transaction, for example to // cover fees for additional outputs of custom channels. + // + // The returned amount must be non-negative, and the contribution + // must be additive across inputs: the result for a slice of inputs + // must equal the sum of the per-input results, so that callers may + // query the contribution of a single input by passing a singleton + // slice. The budget aggregator relies on this when pre-filtering + // inputs by their own budget plus their individual aux contribution. ExtraBudgetForInputs(inputs []input.Input) fn.Result[btcutil.Amount] // NotifyBroadcast is used to notify external callers of the broadcast From 22a586260120f1429fd2fc91b3200d12157376c3 Mon Sep 17 00:00:00 2001 From: Jared Tobin Date: Thu, 11 Jun 2026 13:30:59 -0230 Subject: [PATCH 2/2] docs: add release note --- docs/release-notes/release-notes-0.22.0.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/release-notes/release-notes-0.22.0.md b/docs/release-notes/release-notes-0.22.0.md index 2f7f0ca1b7e..d109c501664 100644 --- a/docs/release-notes/release-notes-0.22.0.md +++ b/docs/release-notes/release-notes-0.22.0.md @@ -42,6 +42,13 @@ regardless of peer connectivity. Uptime is now seeded from the peer's actual connection state. +* [Fixed a bug](https://github.com/lightningnetwork/lnd/pull/10897) in the + sweeper whereby inputs that receive an extra budget from an aux sweeper + (such as custom channel outputs, whose value is mostly carried off-chain) + were filtered against their own budget alone. This could permanently + exclude such inputs from sweeping even though their input set could + comfortably pay its fees. + # New Features ## Functional Enhancements @@ -116,3 +123,4 @@ * bitromortac * Boris Nagaev * Erick Cestari +* Jared Tobin