Skip to content

Commit 4fbcc92

Browse files
committed
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.
1 parent 0dbe2b1 commit 4fbcc92

3 files changed

Lines changed: 167 additions & 4 deletions

File tree

sweep/aggregator.go

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package sweep
22

33
import (
4+
"math"
45
"sort"
56

67
"github.com/btcsuite/btcd/btcutil/v2"
@@ -232,12 +233,61 @@ func (b *BudgetAggregator) filterInputs(inputs InputsMap) InputsMap {
232233
// https://github.com/lightning/bolts/blob/master/03-transactions.md#appendix-a-expected-weights
233234
wu := lntypes.VByte(input.InputSize).ToWU() + witnessSize
234235

236+
// If an aux sweeper is set, it may contribute an extra budget
237+
// to any input set this input becomes part of. The input's own
238+
// budget may be tiny (e.g. for custom channel outputs whose
239+
// value is mostly carried off-chain), so without accounting
240+
// for the extra budget here we'd filter such inputs out
241+
// permanently, even though their input set could comfortably
242+
// pay its fees.
243+
//
244+
// The AuxSweeper interface requires the contribution to be
245+
// non-negative and additive across inputs, so a singleton
246+
// call returns this input's share and per-input credits sum
247+
// to the set-level total used at set construction. On a
248+
// lookup error we fall back to zero extra budget rather than
249+
// dropping the input, so a transient aux failure doesn't
250+
// recreate the silently-stranded mode this guard is meant to
251+
// avoid.
252+
extraBudget, err := fn.MapOptionZ(
253+
b.auxSweeper,
254+
func(aux AuxSweeper) fn.Result[btcutil.Amount] {
255+
return aux.ExtraBudgetForInputs(
256+
[]input.Input{pi.Input},
257+
)
258+
},
259+
).Unpack()
260+
if err != nil {
261+
log.Errorf("Unable to fetch extra budget for "+
262+
"input=%v, falling back to own budget: %v",
263+
op, err)
264+
265+
extraBudget = 0
266+
}
267+
268+
// Defensively clamp and saturate against a misbehaving aux
269+
// implementation. The contract requires a non-negative,
270+
// bounded value, but if it returned a negative the filter
271+
// would tighten and re-strand the input, and an overflow
272+
// would wrap negative and silently drop it. This guard must
273+
// only ever relax the filter relative to the input's own
274+
// budget.
275+
if extraBudget < 0 {
276+
extraBudget = 0
277+
}
278+
budget := pi.params.Budget
279+
if extraBudget > math.MaxInt64-budget {
280+
budget = btcutil.Amount(math.MaxInt64)
281+
} else {
282+
budget += extraBudget
283+
}
284+
235285
// Skip inputs that has too little budget.
236286
minFee := minFeeRate.FeeForWeight(wu)
237-
if pi.params.Budget < minFee {
287+
if budget < minFee {
238288
log.Warnf("Skipped input=%v: has budget=%v, but the "+
239289
"min fee requires %v (feerate=%v), size=%v", op,
240-
pi.params.Budget, minFee,
290+
budget, minFee,
241291
minFeeRate.FeePerVByte(), wu.ToVB())
242292

243293
continue
@@ -248,10 +298,10 @@ func (b *BudgetAggregator) filterInputs(inputs InputsMap) InputsMap {
248298
chainfee.SatPerKWeight(0),
249299
)
250300
startingFee := startingFeeRate.FeeForWeight(wu)
251-
if pi.params.Budget < startingFee {
301+
if budget < startingFee {
252302
log.Errorf("Skipped input=%v: has budget=%v, but the "+
253303
"starting fee requires %v (feerate=%v), "+
254-
"size=%v", op, pi.params.Budget, startingFee,
304+
"size=%v", op, budget, startingFee,
255305
startingFeeRate.FeePerVByte(), wu.ToVB())
256306

257307
continue

sweep/aggregator_test.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,112 @@ func TestBudgetAggregatorFilterInputs(t *testing.T) {
164164
require.Contains(t, result, opHigh)
165165
}
166166

167+
// TestBudgetAggregatorFilterInputsAuxBudget checks that the aux sweeper's
168+
// extra budget is folded into the filter's budget check, and that an aux
169+
// lookup failure falls back to gating on the input's own budget rather than
170+
// silently dropping the input.
171+
func TestBudgetAggregatorFilterInputsAuxBudget(t *testing.T) {
172+
t.Parallel()
173+
174+
const wu lntypes.WeightUnit = 100
175+
inpSize := lntypes.VByte(input.InputSize).ToWU() + wu
176+
177+
const minFeeRate = chainfee.SatPerKWeight(1000)
178+
minFee := minFeeRate.FeeForWeight(inpSize)
179+
180+
// shortfall is how much the own budget falls short of minFee; the aux
181+
// sweeper covers exactly this gap in the "rescue" cases.
182+
const shortfall = btcutil.Amount(100)
183+
auxErr := errors.New("aux failure")
184+
185+
testCases := []struct {
186+
name string
187+
ownBudget btcutil.Amount
188+
auxResult fn.Result[btcutil.Amount]
189+
expectKept bool
190+
}{
191+
{
192+
// The input's own budget falls short of the min fee,
193+
// but the aux sweeper contributes enough extra budget
194+
// to clear it. Pre-fix this input would have been
195+
// filtered out.
196+
name: "aux budget rescues low-own-budget input",
197+
ownBudget: minFee - shortfall,
198+
auxResult: fn.Ok(shortfall),
199+
expectKept: true,
200+
},
201+
{
202+
// The aux lookup errors but the input's own budget
203+
// already covers the min fee, so the conservative
204+
// fallback (extraBudget=0) keeps it in. Pre-fix this
205+
// input would have been silently dropped.
206+
name: "aux error keeps sufficient input",
207+
ownBudget: minFee,
208+
auxResult: fn.Err[btcutil.Amount](auxErr),
209+
expectKept: true,
210+
},
211+
{
212+
// The aux lookup errors and the input cannot pay its
213+
// own way, so it is correctly filtered.
214+
name: "aux error drops below-min-fee input",
215+
ownBudget: minFee - shortfall,
216+
auxResult: fn.Err[btcutil.Amount](auxErr),
217+
expectKept: false,
218+
},
219+
}
220+
221+
for _, tc := range testCases {
222+
t.Run(tc.name, func(t *testing.T) {
223+
t.Parallel()
224+
225+
estimator := &chainfee.MockEstimator{}
226+
defer estimator.AssertExpectations(t)
227+
estimator.On("RelayFeePerKW").Return(minFeeRate).Once()
228+
229+
wt := &input.MockWitnessType{}
230+
defer wt.AssertExpectations(t)
231+
wt.On("SizeUpperBound").Return(wu, true, nil).Once()
232+
233+
mockInput := &input.MockInput{}
234+
defer mockInput.AssertExpectations(t)
235+
op := wire.OutPoint{Hash: chainhash.Hash{1}}
236+
mockInput.On("WitnessType").Return(wt)
237+
mockInput.On("OutPoint").Return(op)
238+
239+
// Stub RequiredTxOut unconditionally so a regression
240+
// that lets the dropped case fall through to the dust
241+
// check surfaces as a clean assertion failure rather
242+
// than an unstubbed-mock panic. `Maybe()` is needed
243+
// because the dropped case shouldn't actually reach
244+
// this call.
245+
mockInput.On("RequiredTxOut").Return(nil).Maybe()
246+
247+
mockAux := &MockAuxSweeper{}
248+
defer mockAux.AssertExpectations(t)
249+
mockAux.On("ExtraBudgetForInputs").Return(tc.auxResult)
250+
251+
inputs := InputsMap{
252+
op: &SweeperInput{
253+
Input: mockInput,
254+
params: Params{Budget: tc.ownBudget},
255+
},
256+
}
257+
258+
b := NewBudgetAggregator(
259+
estimator, 0,
260+
fn.Some[AuxSweeper](mockAux),
261+
)
262+
result := b.filterInputs(inputs)
263+
264+
if tc.expectKept {
265+
require.Contains(t, result, op)
266+
} else {
267+
require.NotContains(t, result, op)
268+
}
269+
})
270+
}
271+
}
272+
167273
// TestBudgetAggregatorSortInputs checks that inputs are sorted by based on
168274
// their budgets and force flag.
169275
func TestBudgetAggregatorSortInputs(t *testing.T) {

sweep/interface.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,13 @@ type AuxSweeper interface {
8888
// should be allocated to sweep the given set of inputs. This can be
8989
// used to add extra funds to the sweep transaction, for example to
9090
// cover fees for additional outputs of custom channels.
91+
//
92+
// The returned amount must be non-negative, and the contribution
93+
// must be additive across inputs: the result for a slice of inputs
94+
// must equal the sum of the per-input results, so that callers may
95+
// query the contribution of a single input by passing a singleton
96+
// slice. The budget aggregator relies on this when pre-filtering
97+
// inputs by their own budget plus their individual aux contribution.
9198
ExtraBudgetForInputs(inputs []input.Input) fn.Result[btcutil.Amount]
9299

93100
// NotifyBroadcast is used to notify external callers of the broadcast

0 commit comments

Comments
 (0)