sweep: add missing output to the weight estimation
What changed, and why it matters
This commit fixes a bug in LND's transaction fee estimation when 'overlay channels' are used. Previously, the code forgot to count an extra output when estimating how large (and therefore how expensive) the sweep transaction would be. That could lead to paying too little in fees, making the transaction slow to confirm, or in edge cases disrupt the sweep. The fix adds the missing output to the weight estimate and adds tests to confirm the behavior.
Treat as a routine correctness fix. Review whether the underestimated fee could have caused any sweep transactions to become stuck in mempools under low-fee conditions, and consider backporting to releases that include overlay-channel support. No emergency response is indicated by the diff alone.
Security signals we found
Incorrect transaction weight estimation leading to underpaid fees
Potential transaction delay or stuck sweep when overlay channels are used
Fee-bumping logic may operate from an underestimated base fee
No direct funds loss or cryptographic weakness in the diff
Evidence from the diff
In sweep/fee_bumper.go, prepareSweepTx now appends the optional extra change output (extraChangeOut, produced when an AuxSweeper / overlay channel is present) to the changePkScripts slice before creating the weight estimator. Without this, the transaction weight was underestimated, so the fee computed by feeRate.FeeForWeight() was too low for transactions that include the extra output. The commit also adds TestPrepareSweepTx in sweep/fee_bumper_test.go with cases covering no extra output, extra output present, insufficient inputs, high-fee-rate failure, and immature locktime.
Changed components
sweep/fee_bumper.gosweep/fee_bumper_test.goprepareSweepTxAuxSweeper / overlay channel sweep pathInspect captured patch +208 / −0
diff --git a/sweep/fee_bumper.go b/sweep/fee_bumper.go
index 18d848b..a23213f 100644
--- a/sweep/fee_bumper.go
+++ b/sweep/fee_bumper.go
@@ -1668,6 +1668,16 @@ func prepareSweepTx(inputs []input.Input, changePkScript lnwallet.AddrWithKey,
return 0, noChange, noLocktime, err
}
+ // We also add the extra change output to the change pk scripts.
+ //
+ // NOTE: The weight estimation will not be quite accurate because the
+ // witness data is greater when overlay channels are used. But that
+ // shouldn't be a problem since we will increase the fee rate
+ // incrementally via the fee function.
+ extraChangeOut.WhenSome(func(o SweepOutput) {
+ changePkScripts = append(changePkScripts, o.TxOut.PkScript)
+ })
+
// Creating a weight estimator with nil outputs and zero max fee rate.
// We don't allow adding customized outputs in the sweeping tx, and the
// fee rate is already being managed before we get here.
diff --git a/sweep/fee_bumper_test.go b/sweep/fee_bumper_test.go
index fb939e7..d697f90 100644
--- a/sweep/fee_bumper_test.go
+++ b/sweep/fee_bumper_test.go
@@ -14,6 +14,7 @@ import (
"github.com/lightningnetwork/lnd/fn/v2"
"github.com/lightningnetwork/lnd/input"
"github.com/lightningnetwork/lnd/keychain"
+ "github.com/lightningnetwork/lnd/lntypes"
"github.com/lightningnetwork/lnd/lnwallet"
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
"github.com/stretchr/testify/mock"
@@ -2109,3 +2110,200 @@ func createTestSpendEvent(tx *wire.MsgTx) *chainntnfs.SpendEvent {
Cancel: func() {},
}
}
+
+// TestPrepareSweepTx tests the prepareSweepTx function behavior.
+func TestPrepareSweepTx(t *testing.T) {
+ t.Parallel()
+
+ // Create test inputs with different values.
+ inp1 := createTestInput(1000000, input.WitnessKeyHash)
+ inp2 := createTestInput(2000000, input.WitnessKeyHash)
+
+ // Test fee rate and height.
+ feeRate := chainfee.SatPerKWeight(1000)
+ currentHeight := int32(800000)
+
+ testCases := []struct {
+ name string
+ inputs []input.Input
+ changePkScript lnwallet.AddrWithKey
+ feeRate chainfee.SatPerKWeight
+ currentHeight int32
+ auxSweeper fn.Option[AuxSweeper]
+ expectedErr error
+ checkResults func(t *testing.T, fee btcutil.Amount,
+ changeOuts fn.Option[[]SweepOutput],
+ locktime fn.Option[int32])
+ }{
+ {
+ name: "successful sweep with change - no " +
+ "extra output",
+ inputs: []input.Input{&inp1, &inp2},
+ changePkScript: changePkScript,
+ feeRate: feeRate,
+ currentHeight: currentHeight,
+ auxSweeper: fn.None[AuxSweeper](),
+ expectedErr: nil,
+ checkResults: func(t *testing.T, fee btcutil.Amount,
+ changeOuts fn.Option[[]SweepOutput],
+ locktime fn.Option[int32]) {
+
+ // Calculate expected weight - only regular
+ // change output, no extra.
+ expectedWeight, err := calcSweepTxWeight(
+ []input.Input{&inp1, &inp2},
+ [][]byte{
+ changePkScript.DeliveryAddress,
+ },
+ )
+ require.NoError(t, err)
+
+ // Expected fee based on fee rate and weight.
+ expectedFee := feeRate.FeeForWeight(
+ expectedWeight,
+ )
+
+ require.Equal(t, fee, expectedFee)
+ },
+ },
+ {
+ name: "successful sweep with extra output",
+ inputs: []input.Input{&inp1, &inp2},
+ changePkScript: changePkScript,
+ feeRate: feeRate,
+ currentHeight: currentHeight,
+ auxSweeper: fn.Some[AuxSweeper](&MockAuxSweeper{}),
+ expectedErr: nil,
+ checkResults: func(t *testing.T, fee btcutil.Amount,
+ changeOuts fn.Option[[]SweepOutput],
+ locktime fn.Option[int32]) {
+
+ // Calculate expected weight - includes both
+ // regular change and extra output.
+ expectedWeight, err := calcSweepTxWeight(
+ []input.Input{&inp1, &inp2},
+ [][]byte{changePkScript.DeliveryAddress,
+ changePkScript.DeliveryAddress},
+ )
+ require.NoError(t, err)
+
+ // Expected fee based on fee rate and weight.
+ expectedFee := feeRate.FeeForWeight(
+ expectedWeight,
+ )
+
+ require.Equal(t, fee, expectedFee)
+
+ // Should have change outputs (both regular
+ // and extra).
+ require.True(t, changeOuts.IsSome())
+ outputs := changeOuts.UnwrapOr([]SweepOutput{})
+ require.Equal(t, 2, len(outputs))
+
+ // Check if extra output is present.
+ hasExtra := false
+ for _, out := range outputs {
+ if out.IsExtra {
+ hasExtra = true
+ break
+ }
+ }
+ require.True(
+ t, hasExtra, "Should have extra output",
+ )
+
+ // Locktime should be None since no inputs
+ // require locktime.
+ require.True(t, locktime.IsNone())
+ },
+ },
+ {
+ name: "insufficient inputs",
+ inputs: []input.Input{},
+ changePkScript: changePkScript,
+ feeRate: feeRate,
+ currentHeight: currentHeight,
+ auxSweeper: fn.None[AuxSweeper](),
+ expectedErr: ErrNotEnoughInputs,
+ },
+ {
+ name: "high fee rate causes insufficient " +
+ "inputs",
+ inputs: []input.Input{&inp1},
+ changePkScript: changePkScript,
+ feeRate: chainfee.SatPerKWeight(10000000),
+ currentHeight: currentHeight,
+ auxSweeper: fn.None[AuxSweeper](),
+ expectedErr: ErrNotEnoughInputs,
+ },
+ {
+ name: "immature locktime",
+ inputs: []input.Input{
+ createTestInputWithLocktime(
+ 1000000, input.WitnessKeyHash,
+ uint32(currentHeight+100),
+ ),
+ },
+ changePkScript: changePkScript,
+ feeRate: feeRate,
+ currentHeight: currentHeight,
+ auxSweeper: fn.None[AuxSweeper](),
+ expectedErr: ErrLocktimeImmature,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ fee, changeOuts, locktime, err := prepareSweepTx(
+ tc.inputs,
+ tc.changePkScript,
+ tc.feeRate,
+ tc.currentHeight,
+ tc.auxSweeper,
+ )
+
+ // Check error expectations.
+ if tc.expectedErr != nil {
+ require.ErrorIs(t, err, tc.expectedErr)
+ return
+ }
+
+ // For successful cases, run additional checks.
+ require.NoError(t, err)
+ if tc.checkResults != nil {
+ tc.checkResults(t, fee, changeOuts, locktime)
+ }
+ })
+ }
+}
+
+// createTestInputWithLocktime creates a test input with a specific locktime
+// requirement.
+func createTestInputWithLocktime(value int64, witnessType input.WitnessType,
+ locktime uint32) *input.BaseInput {
+
+ // Create a unique test identifier based on input count.
+ hash := chainhash.Hash{}
+ hash[lntypes.HashSize-1] = byte(testInputCount.Add(1))
+
+ // Use NewCsvInputWithCltv to create an input with locktime requirement.
+ inp := input.NewCsvInputWithCltv(
+ &wire.OutPoint{
+ Hash: hash,
+ },
+ witnessType,
+ &input.SignDescriptor{
+ Output: &wire.TxOut{
+ Value: value,
+ },
+ KeyDesc: keychain.KeyDescriptor{
+ PubKey: testPubKey,
+ },
+ },
+ 1, 0, locktime,
+ )
+
+ return inp
+}
Why this scored 42/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.