Repository: lightningnetwork/lnd (go)

Issue:
[bug]: `panic: runtime error` resuming HTLC attempt
### Background

On LND 0.19.0 rc2 during startup this panic was observed:
```
2025-04-08 18:51:38.941 [INF] CRTR: Resuming HTLC attempt 35020 for payment 53a3012ec149f3a478e13a7e16617fca658f87e80f26c8b5ffa7091be9ce34a6
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x19c407e]
goroutine 1191 [running]:
github.com/lightningnetwork/lnd/channeldb.(*HTLCAttemptInfo).attachOnionBlobAndCircuit(0xc00775ca08)
    github.com/lightningnetwork/lnd/channeldb/mp_payment.go:123 +0x1e
github.com/lightningnetwork/lnd/channeldb.(*HTLCAttemptInfo).Circuit(...)
    github.com/lightningnetwork/lnd/channeldb/mp_payment.go:111
github.com/lightningnetwork/lnd/routing.(*paymentLifecycle).collectResult(0xc00509a180, 0xc00775ca08)
    github.com/lightningnetwork/lnd/routing/payment_lifecycle.go:508 +0x11a
github.com/lightningnetwork/lnd/routing.(*paymentLifecycle).collectResultAsync.func1()
    github.com/lightningnetwork/lnd/routing/payment_lifecycle.go:464 +0x31
created by github.com/lightningnetwork/lnd/routing.(*paymentLifecycle).collectResultAsync in goroutine 1068
    github.com/lightningnetwork/lnd/routing/payment_lifecycle.go:463 +0x12a
```

Line for ref:
```
    onionBlob, circuit, err := GenerateSphinxPacket(
        &h.Route, h.Hash[:], h.SessionKey(),
    )
```

Based on the valid memory address, `h` itself is not `nil`, so the culprit likely lies in the other fields (i.e. `Route`, `Hash`, etc.)

### Your environment

* version of `lnd`: 0.19 rc2
* which operating system (`uname -a` on *Nix): Linux 5.10.225
* version of `btcd`, `bitcoind`, or other backend: bitcoind

### Steps to reproduce

Upgrade instance to LND 0.19 rc2.

### Expected behaviour

Start up.

### Actual behaviour

Runtime panic.


Pull request description:
Patch htlc attempt hash for legacy payments
Fix #9688 by making sure we always set the `Hash` field on legacy payments.

Patch:
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index 9362cd9b8..b39a358d7 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -28,10 +28,6 @@ jobs:
         uses: actions/setup-go@v5
         with:
           go-version: '${{ env.GO_VERSION }}'
-          cache: 'false'
-
-      - name: cleanup space
-        run: rm -rf /opt/hostedtoolcache
 
       - name: Set env
         run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
diff --git a/docs/release-notes/release-notes-0.19.0.md b/docs/release-notes/release-notes-0.19.0.md
index 31d1800cf..7eb5150f0 100644
--- a/docs/release-notes/release-notes-0.19.0.md
+++ b/docs/release-notes/release-notes-0.19.0.md
@@ -111,6 +111,9 @@ keysend payment validation is stricter.
   process of the node won't be interrupted if a non-fatal error is returned from
   the subsystems.
 
+* [Fixed](https://github.com/lightningnetwork/lnd/pull/9703) a possible panic
+  when reloading legacy inflight payments which don't have the MPP feature.
+
 # New Features
 
 * Add support for [archiving channel backup](https://github.com/lightningnetwork/lnd/pull/9232)
@@ -168,11 +171,6 @@ close transaction.
   [changed](https://github.com/lightningnetwork/lnd/pull/9627) so the sweeper
   will always attempt the sweep as long as the budget can be partially covered.
 
-* [Add](https://github.com/lightningnetwork/lnd/pull/9603) validation to ensure
-  that MPP parameters are compatible with the payment amount before attempting
-  the payment. This prevents payments from entering a path finding loop that
-  would eventually timeout.
-
 ## RPC Additions
 
 * [Add a new rpc endpoint](https://github.com/lightningnetwork/lnd/pull/8843)
diff --git a/lnrpc/chainrpc/chain_server.go b/lnrpc/chainrpc/chain_server.go
index da68e034b..3644d57f5 100644
--- a/lnrpc/chainrpc/chain_server.go
+++ b/lnrpc/chainrpc/chain_server.go
@@ -360,7 +360,7 @@ func (s *Server) GetBlockHash(_ context.Context,
 // particular transaction by its hash or for an output script by specifying a
 // zero hash.
 //
-// NOTE: This is part of the chainrpc.ChainNotifierServer interface.
+// NOTE: This is part of the chainrpc.ChainNotifierService interface.
 func (s *Server) RegisterConfirmationsNtfn(in *ConfRequest,
 	confStream ChainNotifier_RegisterConfirmationsNtfnServer) error {
 
@@ -483,7 +483,7 @@ func (s *Server) RegisterConfirmationsNtfn(in *ConfRequest,
 // A client can specify whether the spend request should be for a particular
 // outpoint  or for an output script by specifying a zero outpoint.
 //
-// NOTE: This is part of the chainrpc.ChainNotifierServer interface.
+// NOTE: This is part of the chainrpc.ChainNotifierService interface.
 func (s *Server) RegisterSpendNtfn(in *SpendRequest,
 	spendStream ChainNotifier_RegisterSpendNtfnServer) error {
 
@@ -597,7 +597,7 @@ func (s *Server) RegisterSpendNtfn(in *SpendRequest,
 // point. This allows clients to be idempotent by ensuring that they do not
 // missing processing a single block within the chain.
 //
-// NOTE: This is part of the chainrpc.ChainNotifierServer interface.
+// NOTE: This is part of the chainrpc.ChainNotifierService interface.
 func (s *Server) RegisterBlockEpochNtfn(in *BlockEpoch,
 	epochStream ChainNotifier_RegisterBlockEpochNtfnServer) error {
 
diff --git a/lnrpc/routerrpc/router_backend.go b/lnrpc/routerrpc/router_backend.go
index b74484d5c..59d933648 100644
--- a/lnrpc/routerrpc/router_backend.go
+++ b/lnrpc/routerrpc/router_backend.go
@@ -36,10 +36,6 @@ const (
 	// TODO(roasbeef): make this value dynamic based on expected number of
 	// attempts for given amount.
 	DefaultMaxParts = 16
-
-	// MaxPartsUpperLimit defines the maximum allowable number of splits
-	// for MPP/AMP when the user is attempting to send a payment.
-	MaxPartsUpperLimit = 1000
 )
 
 // RouterBackend contains the backend implementation of the router rpc sub
@@ -872,16 +868,6 @@ func (r *RouterBackend) extractIntentFromSendRequest(
 	if rpcPayReq.MaxShardSizeMsat > 0 {
 		shardAmtMsat := lnwire.MilliSatoshi(rpcPayReq.MaxShardSizeMsat)
 		payIntent.MaxShardAmt = &shardAmtMsat
-
-		// If the requested max_parts exceeds the allowed limit, then we
-		// cannot send the payment amount.
-		if payIntent.MaxParts > MaxPartsUpperLimit {
-			return nil, fmt.Errorf("requested max_parts (%v) "+
-				"exceeds the allowed upper limit of %v; cannot"+
-				" send payment amount with max_shard_size_msat"+
-				"=%v", payIntent.MaxParts, MaxPartsUpperLimit,
-				*payIntent.MaxShardAmt)
-		}
 	}
 
 	// Take fee limit from request.
@@ -1217,23 +1203,6 @@ func (r *RouterBackend) extractIntentFromSendRequest(
 		payIntent.DestFeatures = features
 	}
 
-	// Validate that the MPP parameters are compatible with the
-	// payment amount. In other words, the parameters are invalid if
-	// they do not permit sending the full payment amount.
-	if payIntent.MaxShardAmt != nil {
-		maxPossibleAmount := (*payIntent.MaxShardAmt) *
-			lnwire.MilliSatoshi(payIntent.MaxParts)
-
-		if payIntent.Amount > maxPossibleAmount {
-			return nil, fmt.Errorf("payment amount %v exceeds "+
-				"maximum possible amount %v with max_parts=%v "+
-				"and max_shard_size_msat=%v", payIntent.Amount,
-				maxPossibleAmount, payIntent.MaxParts,
-				*payIntent.MaxShardAmt,
-			)
-		}
-	}
-
 	// Do bounds checking with the block padding so the router isn't
 	// left with a zombie payment in case the user messes up.
 	err = routing.ValidateCLTVLimit(
diff --git a/routing/payment_lifecycle.go b/routing/payment_lifecycle.go
index 9a7414079..a64c8c87e 100644
--- a/routing/payment_lifecycle.go
+++ b/routing/payment_lifecycle.go
@@ -14,6 +14,7 @@ import (
 	"github.com/lightningnetwork/lnd/graph/db/models"
 	"github.com/lightningnetwork/lnd/htlcswitch"
 	"github.com/lightningnetwork/lnd/lntypes"
+	"github.com/lightningnetwork/lnd/lnutils"
 	"github.com/lightningnetwork/lnd/lnwire"
 	"github.com/lightningnetwork/lnd/routing/route"
 	"github.com/lightningnetwork/lnd/routing/shards"
@@ -500,7 +501,8 @@ func (p *paymentLifecycle) collectResultAsync(attempt *channeldb.HTLCAttempt) {
 func (p *paymentLifecycle) collectResult(
 	attempt *channeldb.HTLCAttempt) (*htlcswitch.PaymentResult, error) {
 
-	log.Tracef("Collecting result for attempt %v", spew.Sdump(attempt))
+	log.Tracef("Collecting result for attempt %v",
+		lnutils.SpewLogClosure(attempt))
 
 	result := &htlcswitch.PaymentResult{}
 
@@ -1060,6 +1062,38 @@ func marshallError(sendError error, time time.Time) *channeldb.HTLCFailInfo {
 	return response
 }
 
+// patchLegacyPaymentHash will make a copy of the passed attempt and sets its
+// Hash field to be the payment hash if it's nil.
+//
+// NOTE: For legacy payments, which were created before the AMP feature was
+// enabled, the `Hash` field in their HTLC attempts is nil. In that case, we use
+// the payment hash as the `attempt.Hash` as they are identical.
+func (p *paymentLifecycle) patchLegacyPaymentHash(
+	a channeldb.HTLCAttempt) channeldb.HTLCAttempt {
+
+	// Exit early if this is not a legacy attempt.
+	if a.Hash != nil {
+		return a
+	}
+
+	// Log a warning if the user is still using legacy payments, which has
+	// weaker support.
+	log.Warnf("Found legacy htlc attempt %v in payment %v", a.AttemptID,
+		p.identifier)
+
+	// Set the attempt's hash to be the payment hash, which is the payment's
+	// `PaymentHash`` in the `PaymentCreationInfo`. For legacy payments
+	// before AMP feature, the `Hash` field was not set so we use the
+	// payment hash instead.
+	//
+	// NOTE: During the router's startup, we have a similar logic in
+	// `resumePayments`, in which we will use the payment hash instead if
+	// the attempt's hash is nil.
+	a.Hash = &p.identifier
+
+	return a
+}
+
 // reloadInflightAttempts is called when the payment lifecycle is resumed after
 // a restart. It reloads all inflight attempts from the control tower and
 // collects the results of the attempts that have been sent before.
@@ -1075,6 +1109,10 @@ func (p *paymentLifecycle) reloadInflightAttempts() (DBMPPayment, error) {
 		log.Infof("Resuming HTLC attempt %v for payment %v",
 			a.AttemptID, p.identifier)
 
+		// Potentially attach the payment hash to the `Hash` field if
+		// it's a legacy payment.
+		a = p.patchLegacyPaymentHash(a)
+
 		p.resultCollector(&a)
 	}
 


Test patch:
diff --git a/lnrpc/routerrpc/router_backend_test.go b/lnrpc/routerrpc/router_backend_test.go
index 5a633d9e6..877a3cc17 100644
--- a/lnrpc/routerrpc/router_backend_test.go
+++ b/lnrpc/routerrpc/router_backend_test.go
@@ -7,10 +7,8 @@ import (
 	"testing"
 
 	"github.com/btcsuite/btcd/btcutil"
-	"github.com/btcsuite/btcd/chaincfg"
 	"github.com/lightningnetwork/lnd/lnrpc"
 	"github.com/lightningnetwork/lnd/lnwire"
-	"github.com/lightningnetwork/lnd/record"
 	"github.com/lightningnetwork/lnd/routing"
 	"github.com/lightningnetwork/lnd/routing/route"
 	"github.com/stretchr/testify/require"
@@ -477,370 +475,3 @@ func testUnmarshalAMP(t *testing.T, test unmarshalAMPTest) {
 		t.Fatalf("test case has non-standard outcome")
 	}
 }
-
-// extractIntentTestCase defines a test case for the
-// TestExtractIntentFromSendRequest function. It includes the test name, the
-// RouterBackend instance, the SendPaymentRequest to be tested, a boolean
-// indicating if the test case is valid, and the expected error message if
-// applicable.
-type extractIntentTestCase struct {
-	name             string
-	backend          *RouterBackend
-	sendReq          *SendPaymentRequest
-	valid            bool
-	expectedErrorMsg string
-}
-
-// TestExtractIntentFromSendRequest verifies that extractIntentFromSendRequest
-// correctly translates a SendPaymentRequest from an RPC client into a
-// LightningPayment intent.
-func TestExtractIntentFromSendRequest(t *testing.T) {
-	const paymentAmount = btcutil.Amount(300_000)
-
-	const paymentReq = "lnbcrt500u1pnh0xflpp56w08q26t896vg2e9mtdkrem320tp" +
-		"wws9z9sfr7dw86dx97d90u4sdqqcqzzsxqyz5vqsp5z9945kvfy5g9afmakz" +
-		"yrur2t4hhn2tr87un8j0r0e6l5m5zm0fus9qxpqysgqk98c6j7qefdpdmzt4" +
-		"g6aykds4ydvf2x9lpngqcfux3hv8qlraan9v3s9296r5w5eh959yzadgh5ck" +
-		"gjydgyfxdpumxtuk3p3caugmlqpz5necs"
-
-	destNodeBytes, err := hex.DecodeString(destKey)
-	require.NoError(t, err)
-
-	target, err := route.NewVertexFromBytes(destNodeBytes)
-	require.NoError(t, err)
-
-	testCases := []extractIntentTestCase{
-		{
-			name:    "Time preference out of range",
-			backend: &RouterBackend{},
-			sendReq: &SendPaymentRequest{
-				TimePref: 2,
-			},
-			valid:            false,
-			expectedErrorMsg: "time preference out of range",
-		},
-		{
-			name:    "Outgoing channel exclusivity violation",
-			backend: &RouterBackend{},
-			sendReq: &SendPaymentRequest{
-				OutgoingChanId:  38484,
-				OutgoingChanIds: []uint64{383322},
-			},
-			valid: false,
-			expectedErrorMsg: "outgoing_chan_id and " +
-				"outgoing_chan_ids are mutually exclusive",
-		},
-		{
-			name:    "Invalid last hop pubkey length",
-			backend: &RouterBackend{},
-			sendReq: &SendPaymentRequest{
-				LastHopPubkey: []byte{1},
-			},
-			valid:            false,
-			expectedErrorMsg: "invalid vertex length",
-		},
-		{
-			name: "total time lock exceeds max allowed",
-			backend: &RouterBackend{
-				MaxTotalTimelock: 1000,
-			},
-			sendReq: &SendPaymentRequest{
-				CltvLimit: 1001,
-			},
-			valid: false,
-			expectedErrorMsg: "total time lock of 1001 exceeds " +
-				"max allowed 1000",
-		},
-		{
-			name:    "Max parts exceed allowed limit",
-			backend: &RouterBackend{},
-			sendReq: &SendPaymentRequest{
-				MaxParts:         1001,
-				MaxShardSizeMsat: 300_000,
-			},
-			valid: false,
-			expectedErrorMsg: "requested max_parts (1001) exceeds" +
-				" the allowed upper limit",
-		},
-		{
-			name: "Fee limit conflict, both sat and msat " +
-				"specified",
-			backend: &RouterBackend{},
-			sendReq: &SendPaymentRequest{
-				FeeLimitSat:  1000000,
-				FeeLimitMsat: 1000000000,
-			},
-			valid: false,
-			expectedErrorMsg: "sat and msat arguments are " +
-				"mutually exclusive",
-		},
-		{
-			name:    "Fee limit cannot be negative",
-			backend: &RouterBackend{},
-			sendReq: &SendPaymentRequest{
-				FeeLimitSat: -1,
-			},
-			valid:            false,
-			expectedErrorMsg: "amount cannot be negative",
-		},
-		{
-			name: "Dest custom records with type below minimum" +
-				" range",
-			backend: &RouterBackend{},
-			sendReq: &SendPaymentRequest{
-				DestCustomRecords: map[uint64][]byte{
-					65530: {1, 2},
-				},
-			},
-			valid:            false,
-			expectedErrorMsg: "no custom records with types below",
-		},
-		{
-			name:    "MPP params with keysend payments",
-			backend: &RouterBackend{},
-			sendReq: &SendPaymentRequest{
-				DestCustomRecords: map[uint64][]byte{
-					record.KeySendType: {1, 2},
-				},
-				MaxShardSizeMsat: 300_000,
-			},
-			valid: false,
-			expectedErrorMsg: "MPP not supported with keysend " +
-				"payments",
-		},
-		{
-			name: "Custom record entry with TLV type below " +
-				"minimum range",
-			backend: &RouterBackend{},
-			sendReq: &SendPaymentRequest{
-				FirstHopCustomRecords: map[uint64][]byte{
-					65530: {1, 2},
-				},
-			},
-			valid:            false,
-			expectedErrorMsg: "custom records entry with TLV type",
-		},
-		{
-			name: "Amount conflict, both sat and msat specified",
-			backend: &RouterBackend{
-				ShouldSetExpEndorsement: func() bool {
-					return true
-				},
-			},
-			sendReq: &SendPaymentRequest{
-				Amt:     int64(paymentAmount),
-				AmtMsat: int64(paymentAmount) * 1000,
-			},
-			valid: false,
-			expectedErrorMsg: "sat and msat arguments are " +
-				"mutually exclusive",
-		},
-		{
-			name: "Both dest and payment_request provided",
-			backend: &RouterBackend{
-				ShouldSetExpEndorsement: func() bool {
-					return false
-				},
-			},
-			sendReq: &SendPaymentRequest{
-				Amt:            int64(paymentAmount),
-				PaymentRequest: "test",
-				Dest:           destNodeBytes,
-			},
-			valid: false,
-			expectedErrorMsg: "dest and payment_request " +
-				"cannot appear together",
-		},
-		{
-			name: "Both payment_hash and payment_request provided",
-			backend: &RouterBackend{
-				ShouldSetExpEndorsement: func() bool {
-					return false
-				},
-			},
-			sendReq: &SendPaymentRequest{
-				Amt:            int64(paymentAmount),
-				PaymentRequest: "test",
-				PaymentHash:    make([]byte, 32),
-			},
-			valid: false,
-			expectedErrorMsg: "payment_hash and payment_request " +
-				"cannot appear together",
-		},
-		{
-			name: "Both final_cltv_delta and payment_request " +
-				"provided",
-			backend: &RouterBackend{
-				ShouldSetExpEndorsement: func() bool {
-					return false
-				},
-			},
-			sendReq: &SendPaymentRequest{
-				Amt:            int64(paymentAmount),
-				PaymentRequest: "test",
-				FinalCltvDelta: 100,
-			},
-			valid: false,
-			expectedErrorMsg: "final_cltv_delta and " +
-				"payment_request cannot appear together",
-		},
-		{
-			name: "Invalid payment request length",
-			backend: &RouterBackend{
-				ShouldSetExpEndorsement: func() bool {
-					return false
-				},
-				ActiveNetParams: &chaincfg.RegressionNetParams,
-			},
-			sendReq: &SendPaymentRequest{
-				Amt:            int64(paymentAmount),
-				PaymentRequest: "test",
-			},
-			valid:            false,
-			expectedErrorMsg: "invalid bech32 string length",
-		},
-		{
-			name: "Expired invoice payment request",
-			backend: &RouterBackend{
-				ShouldSetExpEndorsement: func() bool {
-					return false
-				},
-				ActiveNetParams: &chaincfg.RegressionNetParams,
-			},
-			sendReq: &SendPaymentRequest{
-				Amt:            int64(paymentAmount),
-				PaymentRequest: paymentReq,
-			},
-			valid:            false,
-			expectedErrorMsg: "invoice expired.",
-		},
-		{
-			name: "Invalid dest vertex length",
-			backend: &RouterBackend{
-				ShouldSetExpEndorsement: func() bool {
-					return false
-				},
-			},
-			sendReq: &SendPaymentRequest{
-				Amt:  int64(paymentAmount),
-				Dest: []byte{1},
-			},
-			valid:            false,
-			expectedErrorMsg: "invalid vertex length",
-		},
-		{
-			name: "Payment request with missing amount",
-			backend: &RouterBackend{
-				ShouldSetExpEndorsement: func() bool {
-					return false
-				},
-			},
-			sendReq: &SendPaymentRequest{
-				Dest:           destNodeBytes,
-				FinalCltvDelta: 100,
-			},
-			valid:            false,
-			expectedErrorMsg: "amount must be specified",
-		},
-		{
-			name: "Destination lacks AMP support",
-			backend: &RouterBackend{
-				ShouldSetExpEndorsement: func() bool {
-					return false
-				},
-			},
-			sendReq: &SendPaymentRequest{
-				Dest:         destNodeBytes,
-				Amt:          int64(paymentAmount),
-				Amp:          true,
-				DestFeatures: []lnrpc.FeatureBit{},
-			},
-			valid: false,
-			expectedErrorMsg: "destination doesn't " +
-				"support AMP payments",
-		},
-		{
-			name: "Invalid payment hash length",
-			backend: &RouterBackend{
-				ShouldSetExpEndorsement: func() bool {
-					return false
-				},
-			},
-			sendReq: &SendPaymentRequest{
-				Dest:        destNodeBytes,
-				Amt:         int64(paymentAmount),
-				PaymentHash: make([]byte, 1),
-			},
-			valid:            false,
-			expectedErrorMsg: "invalid hash length",
-		},
-		{
-			name: "Payment amount exceeds maximum possible amount",
-			backend: &RouterBackend{
-				ShouldSetExpEndorsement: func() bool {
-					return false
-				},
-			},
-			sendReq: &SendPaymentRequest{
-				Dest:             destNodeBytes,
-				Amt:              int64(paymentAmount),
-				PaymentHash:      make([]byte, 32),
-				MaxParts:         10,
-				MaxShardSizeMsat: 300_000,
-			},
-			valid: false,
-			expectedErrorMsg: "payment amount 300000000 mSAT " +
-				"exceeds maximum possible amount",
-		},
-		{
-			name: "Reject self-payments if not permitted",
-			backend: &RouterBackend{
-				MaxTotalTimelock: 1000,
-				ShouldSetExpEndorsement: func() bool {
-					return false
-				},
-				SelfNode: target,
-			},
-			sendReq: &SendPaymentRequest{
-				Dest:        destNodeBytes,
-				Amt:         int64(paymentAmount),
-				PaymentHash: make([]byte, 32),
-			},
-			valid:            false,
-			expectedErrorMsg: "self-payments not allowed",
-		},
-		{
-			name: "Valid send req parameters, payment settled",
-			backend: &RouterBackend{
-				MaxTotalTimelock: 1000,
-				ShouldSetExpEndorsement: func() bool {
-					return false
-				},
-			},
-			sendReq: &SendPaymentRequest{
-				Dest:             destNodeBytes,
-				Amt:              int64(paymentAmount),
-				PaymentHash:      make([]byte, 32),
-				MaxParts:         10,
-				MaxShardSizeMsat: 30_000_000,
-			},
-			valid: true,
-		},
-	}
-
-	for _, test := range testCases {
-		t.Run(test.name, func(t *testing.T) {
-			t.Parallel()
-
-			_, err := test.backend.
-				extractIntentFromSendRequest(test.sendReq)
-
-			if test.valid {
-				require.NoError(t, err)
-			} else {
-				require.ErrorContains(t, err,
-					test.expectedErrorMsg)
-			}
-		})
-	}
-}
diff --git a/routing/payment_lifecycle_test.go b/routing/payment_lifecycle_test.go
index b04b04d4b..9df02ec32 100644
--- a/routing/payment_lifecycle_test.go
+++ b/routing/payment_lifecycle_test.go
@@ -1809,3 +1809,61 @@ func TestHandleAttemptResultSuccess(t *testing.T) {
 	require.NoError(t, err, "expected no error")
 	require.Equal(t, attempt, attemptResult.attempt)
 }
+
+// TestReloadInflightAttemptsLegacy checks that when handling a legacy HTLC
+// attempt, `collectResult` behaves as expected.
+func TestReloadInflightAttemptsLegacy(t *testing.T) {
+	t.Parallel()
+
+	// Create a test paymentLifecycle with the initial two calls mocked.
+	p, m := newTestPaymentLifecycle(t)
+
+	// Mount the resultCollector to check the full call path.
+	p.resultCollector = p.collectResultAsync
+
+	// Create testing params.
+	paymentAmt := 10_000
+	preimage := lntypes.Preimage{1}
+	attempt := makeSettledAttempt(t, paymentAmt, preimage)
+
+	// Make the attempt.Hash to be nil to mock a legacy payment.
+	attempt.Hash = nil
+
+	// Create a mock result returned from the switch.
+	result := &htlcswitch.PaymentResult{
+		Preimage: preimage,
+	}
+
+	// 1. calls `FetchPayment` and return the payment.
+	m.control.On("FetchPayment", p.identifier).Return(m.payment, nil).Once()
+
+	// 2. calls `InFlightHTLCs` and return the attempt.
+	attempts := []channeldb.HTLCAttempt{*attempt}
+	m.payment.On("InFlightHTLCs").Return(attempts).Once()
+
+	// 3. Mock the htlcswitch to return a the result chan.
+	resultChan := make(chan *htlcswitch.PaymentResult, 1)
+	m.payer.On("GetAttemptResult",
+		attempt.AttemptID, p.identifier, mock.Anything,
+	).Return(resultChan, nil).Once().Run(func(args mock.Arguments) {
+		// Send the preimage to the result chan.
+		resultChan <- result
+	})
+
+	// Now call the method under test.
+	payment, err := p.reloadInflightAttempts()
+	require.NoError(t, err)
+	require.Equal(t, m.payment, payment)
+
+	var r *switchResult
+
+	// Assert the result is returned within testTimeout.
+	waitErr := wait.NoError(func() error {
+		r = <-p.resultCollected
+		return nil
+	}, testTimeout)
+	require.NoError(t, waitErr, "timeout waiting for result")
+
+	// Assert the result is received as expected.
+	require.Equal(t, result, r.result)
+}