sweep: account for aux extra budget when filtering inputs
What changed, and why it matters
This commit fixes a bug in LND's transaction sweeping logic that could permanently strand certain custom-channel (asset) outputs. The sweeper's budget filter was ignoring extra funds contributed by an optional 'aux sweeper' helper, so it would reject inputs whose own on-chain budget looked too small even though a separate budget pool was available to pay their fees. After a single unrelated sweep failure, the required fee estimate could rise enough that the input was silently dropped forever and never swept. The fix adds the aux contribution to the filter's budget check and falls back safely if the aux helper is temporarily unavailable.
Apply the patch. Operators running LND with custom channels / aux sweepers should upgrade to avoid stranding of asset outputs. Monitor logs for 'Skipped input' and aux-sweeper errors after upgrade to confirm the fix is active.
Security signals we found
Denial-of-service / fund stranding: custom-channel outputs could be silently excluded from all future sweeps
Fee-ratchet interaction: non-fee failures (wallet UTXO collisions) could raise startingFeeRate and trigger the filter
Missing budget accounting: filter did not mirror set-construction logic that already used aux extra budget
Silent failure mode: filtered inputs are logged but never retried, leading to permanent stranding
Defense-in-depth fix: lookup errors fall back to zero extra budget rather than dropping the input
Evidence from the diff
In sweep/aggregator.go, BudgetAggregator.filterInputs now queries AuxSweeper.ExtraBudgetForInputs for each input and adds that amount to pi.params.Budget before comparing against minFee and startingFee. If the aux lookup errors, it logs the error and falls back to extraBudget=0 instead of dropping the input. The AuxSweeper interface documentation in sweep/interface.go is updated to require non-negative, additive per-input results so singleton queries are valid. Tests in sweep/aggregator_test.go cover: aux budget rescuing a low-own-budget input, aux error with sufficient own budget keeping the input, and aux error with insufficient own budget correctly filtering it.
Changed components
sweep/aggregator.gosweep/aggregator_test.gosweep/interface.goBudgetAggregator.filterInputsAuxSweeper.ExtraBudgetForInputsInspect captured patch +151 / −4
diff --git a/sweep/aggregator.go b/sweep/aggregator.go
index 9bc6ca3..1cb9d29 100644
--- a/sweep/aggregator.go
+++ b/sweep/aggregator.go
@@ -232,12 +232,46 @@ 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
+ }
+
+ budget := pi.params.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 +282,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 bd674e0..5659cf5 100644
--- a/sweep/aggregator_test.go
+++ b/sweep/aggregator_test.go
@@ -164,6 +164,112 @@ 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,
+ },
+ }
+
+ 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 e9a5628..98863e2 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
Why this scored 57/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.