What changed, and why it matters
This commit adds logic to LND's contract court so that when an incoming HTLC is claimed on-chain via a re-signed success transaction, the resolver correctly identifies which output of that transaction it should sweep next. Previously, the code may have relied on a fixed output index; now it checks the actual output at the same index as the HTLC input and verifies it matches the expected sweep descriptor. The change is defensive and includes tests, but the commit message frames it as fixing a real correctness issue that could affect fund recovery.
Treat as a security-relevant correctness fix. Review the full call sites of matchSecondLevelOutput in subsequent commits to confirm the returned outpoint is used for sweeping and that no other resolver paths still assume a fixed second-level output index. Run the new unit tests and consider backporting to maintained release branches.
Security signals we found
Funds-recovery correctness issue in on-chain HTLC resolution
SIGHASH_SINGLE|ANYONECANPAY signature semantics allow output index movement
New validation matches actual second-level output against sweep descriptor
Malformed-state errors separated from non-match cases
Unit tests added for edge cases including decoy commitment descriptor
Evidence from the diff
The patch introduces matchSecondLevelOutput in contractcourt/htlc_success_resolver.go. Because the success transaction input uses SIGHASH_SINGLE|ANYONECANPAY, it commits only to the output at the same index. A re-signed success transaction can therefore move the committed output to a different index while keeping the HTLC input signature valid. The new helper validates that the output at the input’s index exactly matches the SweepSignDesc (value and PkScript) and returns the confirmed outpoint. It also distinguishes malformed resolver/notifier state from complete non-matches. A unit test verifies matching, decoy detection, missing outputs, nil outputs, and nil transaction handling.
Changed components
contractcourt/htlc_success_resolver.gocontractcourt/htlc_success_resolver_test.goIncoming HTLC success resolution pathInspect captured patch +206 / −0
### contractcourt/htlc_success_resolver.go
@@ -27,6 +27,9 @@ import (
// errInvalidSpendDetails identifies malformed notifier spend data.
var errInvalidSpendDetails = errors.New("invalid spend details")
+// errInvalidSuccessResolver identifies malformed success resolver state.
+var errInvalidSuccessResolver = errors.New("invalid success resolver")
+
// htlcSuccessResolver is a resolver that's capable of sweeping an incoming
// HTLC output on-chain. If this is the remote party's commitment, we'll sweep
// it directly from the commitment output *immediately*. If this is our
@@ -507,6 +510,60 @@ func (h *htlcSuccessResolver) validatedSpendInput(
return spendingInput, nil
}
+// matchSecondLevelOutput checks whether the transaction spending the
+// commitment HTLC created the output expected by our sweep descriptor.
+//
+// The HTLC input uses SINGLE|ANYONECANPAY, so it commits to the transaction
+// output at the same index. A match returns that output's actual outpoint.
+func (h *htlcSuccessResolver) matchSecondLevelOutput(
+ spendingTx *wire.MsgTx,
+ outputIndex uint32) (wire.OutPoint, bool, error) {
+
+ var zeroOutpoint wire.OutPoint
+ if spendingTx == nil {
+ return zeroOutpoint, false, fmt.Errorf(
+ "%w: missing spending tx", errInvalidSpendDetails,
+ )
+ }
+
+ expected := h.htlcResolution.SweepSignDesc.Output
+ if expected == nil {
+ return zeroOutpoint, false, fmt.Errorf(
+ "%w: missing expected output for %v",
+ errInvalidSuccessResolver, h.outpoint(),
+ )
+ }
+
+ // The success output should be at the same index as the HTLC input
+ // being spent. If that output is missing, this cannot be our success
+ // tx.
+ if outputIndex >= uint32(len(spendingTx.TxOut)) {
+ return zeroOutpoint, false, nil
+ }
+
+ actual := spendingTx.TxOut[outputIndex]
+ if actual == nil {
+ return zeroOutpoint, false, fmt.Errorf(
+ "%w: output %d is nil", errInvalidSpendDetails,
+ outputIndex,
+ )
+ }
+
+ // The spender consumed the HTLC, but only an exact value/script match
+ // gives us a second-level success output that our sweep descriptor can
+ // spend.
+ if actual.Value != expected.Value ||
+ !bytes.Equal(actual.PkScript, expected.PkScript) {
+
+ return zeroOutpoint, false, nil
+ }
+
+ return wire.OutPoint{
+ Hash: spendingTx.TxHash(),
+ Index: outputIndex,
+ }, true, nil
+}
+
// sweepRemoteCommitOutput creates a sweep request to sweep the HTLC output on
// the remote commitment via the direct preimage-spend.
func (h *htlcSuccessResolver) sweepRemoteCommitOutput() error {
### contractcourt/htlc_success_resolver_test.go
@@ -325,6 +325,33 @@ func cloneTxOut(txOut *wire.TxOut) *wire.TxOut {
}
}
+// newSuccessTestResolution creates a success resolution with distinct
+// commitment and second-level output descriptors.
+func newSuccessTestResolution(
+ commitOutpoint wire.OutPoint) lnwallet.IncomingHtlcResolution {
+
+ secondLevelOutput := cloneTxOut(testSignDesc.Output)
+ secondLevelOutput.PkScript = []byte{txscript.OP_TRUE}
+ sweepSignDesc := testSignDesc
+ sweepSignDesc.Output = secondLevelOutput
+
+ successTx := &wire.MsgTx{
+ TxIn: []*wire.TxIn{{PreviousOutPoint: commitOutpoint}},
+ TxOut: []*wire.TxOut{cloneTxOut(secondLevelOutput)},
+ }
+
+ return lnwallet.IncomingHtlcResolution{
+ Preimage: testResPreimage,
+ SignedSuccessTx: successTx,
+ SignDetails: &input.SignDetails{
+ SignDesc: testSignDesc,
+ PeerSig: testSig,
+ },
+ ClaimOutpoint: wire.OutPoint{Hash: successTx.TxHash()},
+ SweepSignDesc: sweepSignDesc,
+ }
+}
+
// newTaprootSuccessSpendFixture creates success and auxiliary leaves with the
// same script and different leaf versions.
func newTaprootSuccessSpendFixture(
@@ -398,6 +425,128 @@ func TestHtlcSuccessTaprootClassification(t *testing.T) {
}))
}
+// TestHtlcSuccessMatchSecondLevelOutput tests matching the success transaction
+// output against the sweep descriptor.
+func TestHtlcSuccessMatchSecondLevelOutput(t *testing.T) {
+ claim := wire.OutPoint{Index: 2}
+ newMatch := func() (*htlcSuccessResolver, *wire.MsgTx) {
+ resolution := newSuccessTestResolution(claim)
+ tx := &wire.MsgTx{
+ TxIn: []*wire.TxIn{
+ {PreviousOutPoint: wire.OutPoint{Index: 1}},
+ {PreviousOutPoint: claim},
+ },
+ TxOut: []*wire.TxOut{
+ cloneTxOut(
+ resolution.SignDetails.SignDesc.Output,
+ ),
+ cloneTxOut(resolution.SweepSignDesc.Output),
+ },
+ }
+
+ return &htlcSuccessResolver{
+ htlcResolution: resolution,
+ }, tx
+ }
+
+ testCases := []struct {
+ name string
+ prepare func(*htlcSuccessResolver, *wire.MsgTx) *wire.MsgTx
+ matches bool
+ expectedErr error
+ }{
+ {
+ name: "match",
+ matches: true,
+ },
+ {
+ name: "commitment descriptor decoy",
+ prepare: func(resolver *htlcSuccessResolver,
+ tx *wire.MsgTx) *wire.MsgTx {
+
+ // This decoy proves the matcher uses the sweep
+ // descriptor, not the commitment descriptor.
+ resolution := &resolver.htlcResolution
+ signDetails := resolution.SignDetails
+ tx.TxOut[1] = cloneTxOut(
+ signDetails.SignDesc.Output,
+ )
+
+ return tx
+ },
+ },
+ {
+ name: "missing indexed output",
+ prepare: func(_ *htlcSuccessResolver,
+ tx *wire.MsgTx) *wire.MsgTx {
+
+ tx.TxOut = tx.TxOut[:1]
+
+ return tx
+ },
+ },
+ {
+ name: "missing expected output",
+ prepare: func(resolver *htlcSuccessResolver,
+ tx *wire.MsgTx) *wire.MsgTx {
+
+ resolution := &resolver.htlcResolution
+ resolution.SweepSignDesc.Output = nil
+
+ return tx
+ },
+ expectedErr: errInvalidSuccessResolver,
+ },
+ {
+ name: "nil indexed output",
+ prepare: func(_ *htlcSuccessResolver,
+ tx *wire.MsgTx) *wire.MsgTx {
+
+ tx.TxOut[1] = nil
+
+ return tx
+ },
+ expectedErr: errInvalidSpendDetails,
+ },
+ {
+ name: "nil transaction",
+ prepare: func(_ *htlcSuccessResolver,
+ _ *wire.MsgTx) *wire.MsgTx {
+
+ return nil
+ },
+ expectedErr: errInvalidSpendDetails,
+ },
+ }
+
+ for _, testCase := range testCases {
+ t.Run(testCase.name, func(t *testing.T) {
+ resolver, tx := newMatch()
+ if testCase.prepare != nil {
+ tx = testCase.prepare(resolver, tx)
+ }
+
+ outpoint, matches, err :=
+ resolver.matchSecondLevelOutput(tx, 1)
+ if testCase.expectedErr != nil {
+ require.ErrorIs(t, err, testCase.expectedErr)
+ return
+ }
+
+ require.NoError(t, err)
+ require.Equal(t, testCase.matches, matches)
+ if matches {
+ require.Equal(t, wire.OutPoint{
+ Hash: tx.TxHash(),
+ Index: 1,
+ }, outpoint)
+ } else {
+ require.Zero(t, outpoint)
+ }
+ })
+ }
+}
+
// TestHtlcSuccessSecondStageResolution tests successful sweep of a second
// stage htlc claim, going through the Nursery.
func TestHtlcSuccessSecondStageResolution(t *testing.T) {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.