Repository: lightningnetwork/lnd
Pull request #9493 (2025-02-10 08:37:04)

For some lncli cmds we should not replace the content with other data
In https://github.com/lightningnetwork/lnd/pull/9390, we added the SCID and the ChannelID to the output of the LNCLI, but for some cmds it makes things not really practical so for some cmds we do not replace the content with addtional data.


coderabbitai[bot]: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- This is an auto-generated comment: skip review by coderabbit.ai -->

> [!IMPORTANT]
> ## Review skipped
> 
> Auto reviews are limited to specific labels.
> 
> <details>
> <summary>:label: Labels to auto review (1)</summary>
> 
> * llm-review
> 
> </details>
> 
> Please check the settings in the CodeRabbit UI or the `.coderabbit.yaml` file in this repository. To trigger a single review, invoke the `@coderabbitai review` command.
> 
> You can disable this status message by setting the `reviews.review_status` to `false` in the CodeRabbit configuration file.

<!-- end of auto-generated comment: skip review by coderabbit.ai -->
<!-- tips_start -->

---

Thank you for using CodeRabbit. We offer it for free to the OSS community and would appreciate your support in helping us grow. If you find it useful, would you consider giving us a shout-out on your favorite social media?

<details>
<summary>❤️ Share</summary>

- [X](https://twitter.com/intent/tweet?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A&url=https%3A//coderabbit.ai)
- [Mastodon](https://mastodon.social/share?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A%20https%3A%2F%2Fcoderabbit.ai)
- [Reddit](https://www.reddit.com/submit?title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&text=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code.%20Check%20it%20out%3A%20https%3A//coderabbit.ai)
- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fcoderabbit.ai&mini=true&title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&summary=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code)

</details>

<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Generate unit testing code for this file.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai generate unit testing code for this file.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and generate unit testing code.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR. ([Beta](https://docs.coderabbit.ai/early-access#beta))
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
ziggie1984: > Which makes me think if we should even turn things around and only enable the replacement of these fields for very specific commands like listchannels?

Yes was thinking the same, let's only use it where we actually need not everywhere. Should we also move the custom_data replacment to the other function as well and keep one clean printing function ?

Patch:
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 4c2624fc4..2d102e89f 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -262,7 +262,6 @@ jobs:
       - name: Send coverage
         uses: ziggie1984/actions-goveralls@c440f43938a4032b627d2b03d61d4ae1a2ba2b5c
         if: matrix.unit_type == 'btcd unit-cover'
-        continue-on-error: true
         with:
           path-to-profile: coverage.txt
           flag-name: 'unit'
@@ -316,7 +315,6 @@ jobs:
 
       - name: Send coverage
         if: ${{ contains(matrix.args, 'cover=1') }}
-        continue-on-error: true
         uses: ziggie1984/actions-goveralls@c440f43938a4032b627d2b03d61d4ae1a2ba2b5c
         with:
           path-to-profile: coverage.txt
@@ -384,7 +382,6 @@ jobs:
 
       - name: Send coverage
         if: ${{ contains(matrix.args, 'cover=1') }}
-        continue-on-error: true
         uses: shogo82148/actions-goveralls@v1
         with:
           path-to-profile: coverage.txt
diff --git a/channeldb/mp_payment.go b/channeldb/mp_payment.go
index b94bfd418..cf5669a50 100644
--- a/channeldb/mp_payment.go
+++ b/channeldb/mp_payment.go
@@ -10,10 +10,7 @@ import (
 
 	"github.com/btcsuite/btcd/btcec/v2"
 	"github.com/btcsuite/btcd/wire"
-	"github.com/davecgh/go-spew/spew"
-	sphinx "github.com/lightningnetwork/lightning-onion"
 	"github.com/lightningnetwork/lnd/lntypes"
-	"github.com/lightningnetwork/lnd/lnutils"
 	"github.com/lightningnetwork/lnd/lnwire"
 	"github.com/lightningnetwork/lnd/routing/route"
 )
@@ -48,19 +45,12 @@ type HTLCAttemptInfo struct {
 	// in which the payment's PaymentHash in the PaymentCreationInfo should
 	// be used.
 	Hash *lntypes.Hash
-
-	// onionBlob is the cached value for onion blob created from the sphinx
-	// construction.
-	onionBlob [lnwire.OnionPacketSize]byte
-
-	// circuit is the cached value for sphinx circuit.
-	circuit *sphinx.Circuit
 }
 
 // NewHtlcAttempt creates a htlc attempt.
 func NewHtlcAttempt(attemptID uint64, sessionKey *btcec.PrivateKey,
 	route route.Route, attemptTime time.Time,
-	hash *lntypes.Hash) (*HTLCAttempt, error) {
+	hash *lntypes.Hash) *HTLCAttempt {
 
 	var scratch [btcec.PrivKeyBytesLen]byte
 	copy(scratch[:], sessionKey.Serialize())
@@ -74,11 +64,7 @@ func NewHtlcAttempt(attemptID uint64, sessionKey *btcec.PrivateKey,
 		Hash:             hash,
 	}
 
-	if err := info.attachOnionBlobAndCircuit(); err != nil {
-		return nil, err
-	}
-
-	return &HTLCAttempt{HTLCAttemptInfo: info}, nil
+	return &HTLCAttempt{HTLCAttemptInfo: info}
 }
 
 // SessionKey returns the ephemeral key used for a htlc attempt. This function
@@ -93,45 +79,6 @@ func (h *HTLCAttemptInfo) SessionKey() *btcec.PrivateKey {
 	return h.cachedSessionKey
 }
 
-// OnionBlob returns the onion blob created from the sphinx construction.
-func (h *HTLCAttemptInfo) OnionBlob() ([lnwire.OnionPacketSize]byte, error) {
-	var zeroBytes [lnwire.OnionPacketSize]byte
-	if h.onionBlob == zeroBytes {
-		if err := h.attachOnionBlobAndCircuit(); err != nil {
-			return zeroBytes, err
-		}
-	}
-
-	return h.onionBlob, nil
-}
-
-// Circuit returns the sphinx circuit for this attempt.
-func (h *HTLCAttemptInfo) Circuit() (*sphinx.Circuit, error) {
-	if h.circuit == nil {
-		if err := h.attachOnionBlobAndCircuit(); err != nil {
-			return nil, err
-		}
-	}
-
-	return h.circuit, nil
-}
-
-// attachOnionBlobAndCircuit creates a sphinx packet and caches the onion blob
-// and circuit for this attempt.
-func (h *HTLCAttemptInfo) attachOnionBlobAndCircuit() error {
-	onionBlob, circuit, err := generateSphinxPacket(
-		&h.Route, h.Hash[:], h.SessionKey(),
-	)
-	if err != nil {
-		return err
-	}
-
-	copy(h.onionBlob[:], onionBlob)
-	h.circuit = circuit
-
-	return nil
-}
-
 // HTLCAttempt contains information about a specific HTLC attempt for a given
 // payment. It contains the HTLCAttemptInfo used to send the HTLC, as well
 // as a timestamp and any known outcome of the attempt.
@@ -682,69 +629,3 @@ func serializeTime(w io.Writer, t time.Time) error {
 	_, err := w.Write(scratch[:])
 	return err
 }
-
-// generateSphinxPacket generates then encodes a sphinx packet which encodes
-// the onion route specified by the passed layer 3 route. The blob returned
-// from this function can immediately be included within an HTLC add packet to
-// be sent to the first hop within the route.
-func generateSphinxPacket(rt *route.Route, paymentHash []byte,
-	sessionKey *btcec.PrivateKey) ([]byte, *sphinx.Circuit, error) {
-
-	// Now that we know we have an actual route, we'll map the route into a
-	// sphinx payment path which includes per-hop payloads for each hop
-	// that give each node within the route the necessary information
-	// (fees, CLTV value, etc.) to properly forward the payment.
-	sphinxPath, err := rt.ToSphinxPath()
-	if err != nil {
-		return nil, nil, err
-	}
-
-	log.Tracef("Constructed per-hop payloads for payment_hash=%x: %v",
-		paymentHash, lnutils.NewLogClosure(func() string {
-			path := make(
-				[]sphinx.OnionHop, sphinxPath.TrueRouteLength(),
-			)
-			for i := range path {
-				hopCopy := sphinxPath[i]
-				path[i] = hopCopy
-			}
-
-			return spew.Sdump(path)
-		}),
-	)
-
-	// Next generate the onion routing packet which allows us to perform
-	// privacy preserving source routing across the network.
-	sphinxPacket, err := sphinx.NewOnionPacket(
-		sphinxPath, sessionKey, paymentHash,
-		sphinx.DeterministicPacketFiller,
-	)
-	if err != nil {
-		return nil, nil, err
-	}
-
-	// Finally, encode Sphinx packet using its wire representation to be
-	// included within the HTLC add packet.
-	var onionBlob bytes.Buffer
-	if err := sphinxPacket.Encode(&onionBlob); err != nil {
-		return nil, nil, err
-	}
-
-	log.Tracef("Generated sphinx packet: %v",
-		lnutils.NewLogClosure(func() string {
-			// We make a copy of the ephemeral key and unset the
-			// internal curve here in order to keep the logs from
-			// getting noisy.
-			key := *sphinxPacket.EphemeralKey
-			packetCopy := *sphinxPacket
-			packetCopy.EphemeralKey = &key
-
-			return spew.Sdump(packetCopy)
-		}),
-	)
-
-	return onionBlob.Bytes(), &sphinx.Circuit{
-		SessionKey:  sessionKey,
-		PaymentPath: sphinxPath.NodeKeys(),
-	}, nil
-}
diff --git a/cmd/commands/cmd_payments.go b/cmd/commands/cmd_payments.go
index 2fdf97cba..8b93f9458 100644
--- a/cmd/commands/cmd_payments.go
+++ b/cmd/commands/cmd_payments.go
@@ -1254,6 +1254,7 @@ func queryRoutes(ctx *cli.Context) error {
 	}
 
 	printRespJSON(route)
+
 	return nil
 }
 
diff --git a/cmd/commands/commands.go b/cmd/commands/commands.go
index 60579a70a..cfeaeabac 100644
--- a/cmd/commands/commands.go
+++ b/cmd/commands/commands.go
@@ -212,6 +212,7 @@ func printJSON(resp interface{}) {
 	_, _ = out.WriteTo(os.Stdout)
 }
 
+// printRespJSON prints the response in a json format.
 func printRespJSON(resp proto.Message) {
 	jsonBytes, err := lnrpc.ProtoJSONMarshalOpts.Marshal(resp)
 	if err != nil {
@@ -219,6 +220,21 @@ func printRespJSON(resp proto.Message) {
 		return
 	}
 
+	// Make the custom data human readable.
+	jsonBytesReplaced := replaceCustomData(jsonBytes)
+
+	fmt.Printf("%s\n", jsonBytesReplaced)
+}
+
+// printModifiedProtoJSON prints the response with some additional formatting
+// and replacements.
+func printModifiedProtoJSON(resp proto.Message) {
+	jsonBytes, err := lnrpc.ProtoJSONMarshalOpts.Marshal(resp)
+	if err != nil {
+		fmt.Println("unable to decode response: ", err)
+		return
+	}
+
 	// Replace custom_channel_data in the JSON.
 	jsonBytesReplaced := replaceCustomData(jsonBytes)
 
@@ -1853,7 +1869,7 @@ func ListChannels(ctx *cli.Context) error {
 		return err
 	}
 
-	printRespJSON(resp)
+	printModifiedProtoJSON(resp)
 
 	return nil
 }
@@ -1915,7 +1931,7 @@ func closedChannels(ctx *cli.Context) error {
 		return err
 	}
 
-	printRespJSON(resp)
+	printModifiedProtoJSON(resp)
 
 	return nil
 }
diff --git a/docs/release-notes/release-notes-0.19.0.md b/docs/release-notes/release-notes-0.19.0.md
index 315176272..e70492d6f 100644
--- a/docs/release-notes/release-notes-0.19.0.md
+++ b/docs/release-notes/release-notes-0.19.0.md
@@ -68,11 +68,6 @@
   fail to persist (and hence, propagate) node announcements containing address 
   types (such as a DNS hostname) unknown to LND.
 
-* [Fixed an edge case](https://github.com/lightningnetwork/lnd/pull/9150) where
-  the payment may become stuck if the invoice times out while the node
-  restarts, for details check [this
-  issue](https://github.com/lightningnetwork/lnd/issues/8975#issuecomment-2270528222).
-
 # New Features
 
 * [Support](https://github.com/lightningnetwork/lnd/pull/8390) for 
@@ -262,11 +257,6 @@ The underlying functionality between those two options remain the same.
 
 * Log rotation can now use ZSTD
 
-* [Remove redundant 
-  iteration](https://github.com/lightningnetwork/lnd/pull/9496) over a node's 
-  persisted channels when updating the graph cache with a new node or node 
-  update.
-
 ## Deprecations
 
 ### ⚠️ **Warning:** The following RPCs will be removed in release version **0.21**:
diff --git a/docs/release-notes/release-notes-0.20.0.md b/docs/release-notes/release-notes-0.20.0.md
deleted file mode 100644
index 1ee2e05fa..000000000
--- a/docs/release-notes/release-notes-0.20.0.md
+++ /dev/null
@@ -1,74 +0,0 @@
-# Release Notes
-- [Bug Fixes](#bug-fixes)
-- [New Features](#new-features)
-    - [Functional Enhancements](#functional-enhancements)
-    - [RPC Additions](#rpc-additions)
-    - [lncli Additions](#lncli-additions)
-- [Improvements](#improvements)
-    - [Functional Updates](#functional-updates)
-    - [RPC Updates](#rpc-updates)
-    - [lncli Updates](#lncli-updates)
-    - [Breaking Changes](#breaking-changes)
-    - [Performance Improvements](#performance-improvements)
-    - [Deprecations](#deprecations)
-- [Technical and Architectural Updates](#technical-and-architectural-updates)
-    - [BOLT Spec Updates](#bolt-spec-updates)
-    - [Testing](#testing)
-    - [Database](#database)
-    - [Code Health](#code-health)
-    - [Tooling and Documentation](#tooling-and-documentation)
-
-# Bug Fixes
-
-# New Features
-
-## Functional Enhancements
-
-## RPC Additions
-
-## lncli Additions
-
-# Improvements
-## Functional Updates
-
-## RPC Updates
-
-## lncli Updates
-
-## Code Health
-
-## Breaking Changes
-## Performance Improvements
-
-## Deprecations
-
-### ⚠️ **Warning:** The following RPCs will be removed in release version **0.21**:
-
-| Deprecated RPC Method | REST Equivalent | HTTP Method | Path | Replaced By |
-|----------------------|----------------|-------------|------------------------------|------------------|
-| [`lnrpc.SendToRoute`](https://lightning.engineering/api-docs/api/lnd/lightning/send-to-route/index.html) <br> [`routerrpc.SendToRoute`](https://lightning.engineering/api-docs/api/lnd/router/send-to-route/) | ❌ (No direct REST equivalent) | — | — | [`routerrpc.SendToRouteV2`](https://lightning.engineering/api-docs/api/lnd/router/send-to-route-v2/) |
-| [`lnrpc.SendPayment`](https://lightning.engineering/api-docs/api/lnd/lightning/send-payment/) <br> [`routerrpc.SendPayment`](https://lightning.engineering/api-docs/api/lnd/router/send-payment/) | ✅ | `POST` | `/v1/channels/transaction-stream` | [`routerrpc.SendPaymentV2`](https://lightning.engineering/api-docs/api/lnd/router/send-payment-v2/index.html) |
-| [`lnrpc.SendToRouteSync`](https://lightning.engineering/api-docs/api/lnd/lightning/send-to-route-sync/index.html) | ✅ | `POST` | `/v1/channels/transactions/route` | [`routerrpc.SendToRouteV2`](https://lightning.engineering/api-docs/api/lnd/router/send-to-route-v2/) |
-| [`lnrpc.SendPaymentSync`](https://lightning.engineering/api-docs/api/lnd/lightning/send-payment-sync/index.html) | ✅ | `POST` | `/v1/channels/transactions` | [`routerrpc.SendPaymentV2`](https://lightning.engineering/api-docs/api/lnd/router/send-payment-v2/index.html) |
-| [`router.TrackPayment`](https://lightning.engineering/api-docs/api/lnd/router/track-payment/index.html) | ❌ (No direct REST equivalent) | — | — | [`routerrpc.TrackPaymentV2`](https://lightning.engineering/api-docs/api/lnd/router/track-payment-v2/) |
-
-🚨 **Users are strongly encouraged** to transition to the new **V2 methods** before release **0.21** to ensure compatibility:
-
-| New RPC Method | REST Equivalent | HTTP Method | Path |
-|---------------|----------------|-------------|------------------------|
-| [`routerrpc.SendToRouteV2`](https://lightning.engineering/api-docs/api/lnd/router/send-to-route-v2/) | ✅ | `POST` | `/v2/router/route/send` |
-| [`routerrpc.SendPaymentV2`](https://lightning.engineering/api-docs/api/lnd/router/send-payment-v2/index.html) | ✅ | `POST` | `/v2/router/send` |
-| [`routerrpc.TrackPaymentV2`](https://lightning.engineering/api-docs/api/lnd/router/track-payment-v2/) | ✅ | `GET` | `/v2/router/track/{payment_hash}` |
-
-# Technical and Architectural Updates
-## BOLT Spec Updates
-
-## Testing
-
-## Database
-
-## Code Health
-
-## Tooling and Documentation
-
-# Contributors (Alphabetical Order)
diff --git a/graph/db/graph.go b/graph/db/graph.go
index 6759e18b8..06bd0257d 100644
--- a/graph/db/graph.go
+++ b/graph/db/graph.go
@@ -231,13 +231,13 @@ func NewChannelGraph(db kvdb.Backend, options ...OptionModifier) (*ChannelGraph,
 		log.Debugf("Populating in-memory channel graph, this might " +
 			"take a while...")
 
-		err := g.ForEachNodeCacheable(func(node route.Vertex,
-			features *lnwire.FeatureVector) error {
+		err := g.ForEachNodeCacheable(
+			func(tx kvdb.RTx, node GraphCacheNode) error {
+				g.graphCache.AddNodeFeatures(node)
 
-			g.graphCache.AddNodeFeatures(node, features)
-
-			return nil
-		})
+				return nil
+			},
+		)
 		if err != nil {
 			return nil, err
 		}
@@ -772,8 +772,8 @@ func (c *ChannelGraph) forEachNode(
 // graph, executing the passed callback with each node encountered. If the
 // callback returns an error, then the transaction is aborted and the iteration
 // stops early.
-func (c *ChannelGraph) ForEachNodeCacheable(cb func(route.Vertex,
-	*lnwire.FeatureVector) error) error {
+func (c *ChannelGraph) ForEachNodeCacheable(cb func(kvdb.RTx,
+	GraphCacheNode) error) error {
 
 	traversal := func(tx kvdb.RTx) error {
 		// First grab the nodes bucket which stores the mapping from
@@ -792,7 +792,7 @@ func (c *ChannelGraph) ForEachNodeCacheable(cb func(route.Vertex,
 			}
 
 			nodeReader := bytes.NewReader(nodeBytes)
-			node, features, err := deserializeLightningNodeCacheable( //nolint:ll
+			cacheableNode, err := deserializeLightningNodeCacheable(
 				nodeReader,
 			)
 			if err != nil {
@@ -801,7 +801,7 @@ func (c *ChannelGraph) ForEachNodeCacheable(cb func(route.Vertex,
 
 			// Execute the callback, the transaction will abort if
 			// this returns an error.
-			return cb(node, features)
+			return cb(tx, cacheableNode)
 		})
 	}
 
@@ -901,9 +901,13 @@ func (c *ChannelGraph) AddLightningNode(node *models.LightningNode,
 	r := &batch.Request{
 		Update: func(tx kvdb.RwTx) error {
 			if c.graphCache != nil {
-				c.graphCache.AddNodeFeatures(
+				cNode := newGraphCacheNode(
 					node.PubKeyBytes, node.Features,
 				)
+				err := c.graphCache.AddNode(tx, cNode)
+				if err != nil {
+					return err
+				}
 			}
 
 			return addLightningNode(tx, node)
@@ -3055,6 +3059,50 @@ func (c *ChannelGraph) fetchLightningNode(tx kvdb.RTx,
 	return node, nil
 }
 
+// graphCacheNode is a struct that wraps a LightningNode in a way that it can be
+// cached in the graph cache.
+type graphCacheNode struct {
+	pubKeyBytes route.Vertex
+	features    *lnwire.FeatureVector
+}
+
+// newGraphCacheNode returns a new cache optimized node.
+func newGraphCacheNode(pubKey route.Vertex,
+	features *lnwire.FeatureVector) *graphCacheNode {
+
+	return &graphCacheNode{
+		pubKeyBytes: pubKey,
+		features:    features,
+	}
+}
+
+// PubKey returns the node's public identity key.
+func (n *graphCacheNode) PubKey() route.Vertex {
+	return n.pubKeyBytes
+}
+
+// Features returns the node's features.
+func (n *graphCacheNode) Features() *lnwire.FeatureVector {
+	return n.features
+}
+
+// ForEachChannel iterates through all channels of this node, executing the
+// passed callback with an edge info structure and the policies of each end
+// of the channel. The first edge policy is the outgoing edge *to* the
+// connecting node, while the second is the incoming edge *from* the
+// connecting node. If the callback returns an error, then the iteration is
+// halted with the error propagated back up to the caller.
+//
+// Unknown policies are passed into the callback as nil values.
+func (n *graphCacheNode) ForEachChannel(tx kvdb.RTx,
+	cb func(kvdb.RTx, *models.ChannelEdgeInfo, *models.ChannelEdgePolicy,
+		*models.ChannelEdgePolicy) error) error {
+
+	return nodeTraversal(tx, n.pubKeyBytes[:], nil, cb)
+}
+
+var _ GraphCacheNode = (*graphCacheNode)(nil)
+
 // HasLightningNode determines if the graph has a vertex identified by the
 // target node identity public key. If the node exists in the database, a
 // timestamp of when the data for the node was lasted updated is returned along
@@ -4014,59 +4062,60 @@ func fetchLightningNode(nodeBucket kvdb.RBucket,
 	return deserializeLightningNode(nodeReader)
 }
 
-func deserializeLightningNodeCacheable(r io.Reader) (route.Vertex,
-	*lnwire.FeatureVector, error) {
-
-	var (
-		pubKey      route.Vertex
-		features    = lnwire.EmptyFeatureVector()
-		nodeScratch [8]byte
+func deserializeLightningNodeCacheable(r io.Reader) (*graphCacheNode, error) {
+	// Always populate a feature vector, even if we don't have a node
+	// announcement and short circuit below.
+	node := newGraphCacheNode(
+		route.Vertex{},
+		lnwire.EmptyFeatureVector(),
 	)
 
+	var nodeScratch [8]byte
+
 	// Skip ahead:
 	// - LastUpdate (8 bytes)
 	if _, err := r.Read(nodeScratch[:]); err != nil {
-		return pubKey, nil, err
+		return nil, err
 	}
 
-	if _, err := io.ReadFull(r, pubKey[:]); err != nil {
-		return pubKey, nil, err
+	if _, err := io.ReadFull(r, node.pubKeyBytes[:]); err != nil {
+		return nil, err
 	}
 
 	// Read the node announcement flag.
 	if _, err := r.Read(nodeScratch[:2]); err != nil {
-		return pubKey, nil, err
+		return nil, err
 	}
 	hasNodeAnn := byteOrder.Uint16(nodeScratch[:2])
 
 	// The rest of the data is optional, and will only be there if we got a
 	// node announcement for this node.
 	if hasNodeAnn == 0 {
-		return pubKey, features, nil
+		return node, nil
 	}
 
 	// We did get a node announcement for this node, so we'll have the rest
 	// of the data available.
 	var rgb uint8
 	if err := binary.Read(r, byteOrder, &rgb); err != nil {
-		return pubKey, nil, err
+		return nil, err
 	}
 	if err := binary.Read(r, byteOrder, &rgb); err != nil {
-		return pubKey, nil, err
+		return nil, err
 	}
 	if err := binary.Read(r, byteOrder, &rgb); err != nil {
-		return pubKey, nil, err
+		return nil, err
 	}
 
 	if _, err := wire.ReadVarString(r, 0); err != nil {
-		return pubKey, nil, err
+		return nil, err
 	}
 
-	if err := features.Decode(r); err != nil {
-		return pubKey, nil, err
+	if err := node.features.Decode(r); err != nil {
+		return nil, err
 	}
 
-	return pubKey, features, nil
+	return node, nil
 }
 
 func deserializeLightningNode(r io.Reader) (models.LightningNode, error) {
diff --git a/graph/db/graph_cache.go b/graph/db/graph_cache.go
index cefa88a85..a034e23af 100644
--- a/graph/db/graph_cache.go
+++ b/graph/db/graph_cache.go
@@ -6,10 +6,33 @@ import (
 
 	"github.com/btcsuite/btcd/btcutil"
 	"github.com/lightningnetwork/lnd/graph/db/models"
+	"github.com/lightningnetwork/lnd/kvdb"
 	"github.com/lightningnetwork/lnd/lnwire"
 	"github.com/lightningnetwork/lnd/routing/route"
 )
 
+// GraphCacheNode is an interface for all the information the cache needs to know
+// about a lightning node.
+type GraphCacheNode interface {
+	// PubKey is the node's public identity key.
+	PubKey() route.Vertex
+
+	// Features returns the node's p2p features.
+	Features() *lnwire.FeatureVector
+
+	// ForEachChannel iterates through all channels of a given node,
+	// executing the passed callback with an edge info structure and the
+	// policies of each end of the channel. The first edge policy is the
+	// outgoing edge *to* the connecting node, while the second is the
+	// incoming edge *from* the connecting node. If the callback returns an
+	// error, then the iteration is halted with the error propagated back up
+	// to the caller.
+	ForEachChannel(kvdb.RTx,
+		func(kvdb.RTx, *models.ChannelEdgeInfo,
+			*models.ChannelEdgePolicy,
+			*models.ChannelEdgePolicy) error) error
+}
+
 // DirectedChannel is a type that stores the channel information as seen from
 // one side of the channel.
 type DirectedChannel struct {
@@ -101,13 +124,33 @@ func (c *GraphCache) Stats() string {
 }
 
 // AddNodeFeatures adds a graph node and its features to the cache.
-func (c *GraphCache) AddNodeFeatures(node route.Vertex,
-	features *lnwire.FeatureVector) {
+func (c *GraphCache) AddNodeFeatures(node GraphCacheNode) {
+	nodePubKey := node.PubKey()
 
+	// Only hold the lock for a short time. The `ForEachChannel()` below is
+	// possibly slow as it has to go to the backend, so we can unlock
+	// between the calls. And the AddChannel() method will acquire its own
+	// lock anyway.
 	c.mtx.Lock()
-	defer c.mtx.Unlock()
+	c.nodeFeatures[nodePubKey] = node.Features()
+	c.mtx.Unlock()
+}
+
+// AddNode adds a graph node, including all the (directed) channels of that
+// node.
+func (c *GraphCache) AddNode(tx kvdb.RTx, node GraphCacheNode) error {
+	c.AddNodeFeatures(node)
+
+	return node.ForEachChannel(
+		tx, func(tx kvdb.RTx, info *models.ChannelEdgeInfo,
+			outPolicy *models.ChannelEdgePolicy,
+			inPolicy *models.ChannelEdgePolicy) error {
+
+			c.AddChannel(info, outPolicy, inPolicy)
 
-	c.nodeFeatures[node] = features
+			return nil
+		},
+	)
 }
 
 // AddChannel adds a non-directed channel, meaning that the order of policy 1
diff --git a/routing/payment_lifecycle.go b/routing/payment_lifecycle.go
index 9a7414079..180d38a63 100644
--- a/routing/payment_lifecycle.go
+++ b/routing/payment_lifecycle.go
@@ -24,17 +24,6 @@ import (
 // the payment lifecycle is exiting .
 var ErrPaymentLifecycleExiting = errors.New("payment lifecycle exiting")
 
-// switchResult is the result sent back from the switch after processing the
-// HTLC.
-type switchResult struct {
-	// attempt is the HTLC sent to the switch.
-	attempt *channeldb.HTLCAttempt
-
-	// result is sent from the switch which contains either a preimage if
-	// ths HTLC is settled or an error if it's failed.
-	result *htlcswitch.PaymentResult
-}
-
 // paymentLifecycle holds all information about the current state of a payment
 // needed to resume if from any point.
 type paymentLifecycle struct {
@@ -50,9 +39,11 @@ type paymentLifecycle struct {
 	// to stop.
 	quit chan struct{}
 
-	// resultCollected is used to send the result returned from the switch
-	// for a given HTLC attempt.
-	resultCollected chan *switchResult
+	// resultCollected is used to signal that the result of an attempt has
+	// been collected. A nil error means the attempt is either successful
+	// or failed with temporary error. Otherwise, we should exit the
+	// lifecycle loop as a terminal error has occurred.
+	resultCollected chan error
 
 	// resultCollector is a function that is used to collect the result of
 	// an HTLC attempt, which is always mounted to `p.collectResultAsync`
@@ -75,7 +66,7 @@ func newPaymentLifecycle(r *ChannelRouter, feeLimit lnwire.MilliSatoshi,
 		shardTracker:          shardTracker,
 		currentHeight:         currentHeight,
 		quit:                  make(chan struct{}),
-		resultCollected:       make(chan *switchResult, 1),
+		resultCollected:       make(chan error, 1),
 		firstHopCustomRecords: firstHopCustomRecords,
 	}
 
@@ -121,13 +112,6 @@ const (
 )
 
 // decideNextStep is used to determine the next step in the payment lifecycle.
-// It first checks whether the current state of the payment allows more HTLC
-// attempts to be made. If allowed, it will return so the lifecycle can continue
-// making new attempts. Otherwise, it checks whether we need to wait for the
-// results of already sent attempts. If needed, it will block until one of the
-// results is sent back. then process its result here. When there's no need to
-// wait for results, the method will exit with `stepExit` such that the payment
-// lifecycle loop will terminate.
 func (p *paymentLifecycle) decideNextStep(
 	payment DBMPPayment) (stateStep, error) {
 
@@ -137,53 +121,46 @@ func (p *paymentLifecycle) decideNextStep(
 		return stepExit, err
 	}
 
-	// Exit early we need to make more attempts.
-	if allow {
-		return stepProceed, nil
-	}
-
-	// We cannot make more attempts, we now check whether we need to wait
-	// for results.
-	wait, err := payment.NeedWaitAttempts()
-	if err != nil {
-		return stepExit, err
-	}
-
-	// If we are not allowed to make new HTLC attempts and there's no need
-	// to wait, the lifecycle is done and we can exit.
-	if !wait {
-		return stepExit, nil
-	}
+	if !allow {
+		// Check whether we need to wait for results.
+		wait, err := payment.NeedWaitAttempts()
+		if err != nil {
+			return stepExit, err
+		}
 
-	log.Tracef("Waiting for attempt results for payment %v", p.identifier)
+		// If we are not allowed to make new HTLC attempts and there's
+		// no need to wait, the lifecycle is done and we can exit.
+		if !wait {
+			return stepExit, nil
+		}
 
-	// Otherwise we wait for the result for one HTLC attempt then continue
-	// the lifecycle.
-	select {
-	case r := <-p.resultCollected:
-		log.Tracef("Received attempt result for payment %v",
+		log.Tracef("Waiting for attempt results for payment %v",
 			p.identifier)
 
-		// Handle the result here. If there's no error, we will return
-		// stepSkip and move to the next lifecycle iteration, which will
-		// refresh the payment and wait for the next attempt result, if
-		// any.
-		_, err := p.handleAttemptResult(r.attempt, r.result)
+		// Otherwise we wait for one HTLC attempt then continue
+		// the lifecycle.
+		//
+		// NOTE: we don't check `p.quit` since `decideNextStep` is
+		// running in the same goroutine as `resumePayment`.
+		select {
+		case err := <-p.resultCollected:
+			// If an error is returned, exit with it.
+			if err != nil {
+				return stepExit, err
+			}
 
-		// We would only get a DB-related error here, which will cause
-		// us to abort the payment flow.
-		if err != nil {
-			return stepExit, err
-		}
+			log.Tracef("Received attempt result for payment %v",
+				p.identifier)
 
-	case <-p.quit:
-		return stepExit, ErrPaymentLifecycleExiting
+		case <-p.router.quit:
+			return stepExit, ErrRouterShuttingDown
+		}
 
-	case <-p.router.quit:
-		return stepExit, ErrRouterShuttingDown
+		return stepSkip, nil
 	}
 
-	return stepSkip, nil
+	// Otherwise we need to make more attempts.
+	return stepProceed, nil
 }
 
 // resumePayment resumes the paymentLifecycle from the current state.
@@ -198,11 +175,20 @@ func (p *paymentLifecycle) resumePayment(ctx context.Context) ([32]byte,
 	// If we had any existing attempts outstanding, we'll start by spinning
 	// up goroutines that'll collect their results and deliver them to the
 	// lifecycle loop below.
-	payment, err := p.reloadInflightAttempts()
+	payment, err := p.router.cfg.Control.FetchPayment(p.identifier)
 	if err != nil {
 		return [32]byte{}, nil, err
 	}
 
+	for _, a := range payment.InFlightHTLCs() {
+		a := a
+
+		log.Infof("Resuming HTLC attempt %v for payment %v",
+			a.AttemptID, p.identifier)
+
+		p.resultCollector(&a)
+	}
+
 	// Get the payment status.
 	status := payment.GetStatus()
 
@@ -225,18 +211,23 @@ func (p *paymentLifecycle) resumePayment(ctx context.Context) ([32]byte,
 	// critical error during path finding.
 lifecycle:
 	for {
-		// We update the payment state on every iteration.
-		currentPayment, ps, err := p.reloadPayment()
+		// We update the payment state on every iteration. Since the
+		// payment state is affected by multiple goroutines (ie,
+		// collectResultAsync), it is NOT guaranteed that we always
+		// have the latest state here. This is fine as long as the
+		// state is consistent as a whole.
+		payment, err = p.router.cfg.Control.FetchPayment(p.identifier)
 		if err != nil {
 			return exitWithErr(err)
 		}
 
-		// Reassign status so it can be read in `exitWithErr`.
-		status = currentPayment.GetStatus()
+		ps := payment.GetState()
+		remainingFees := p.calcFeeBudget(ps.FeesPaid)
 
-		// Reassign payment such that when the lifecycle exits, the
-		// latest payment can be read when we access its terminal info.
-		payment = currentPayment
+		status = payment.GetStatus()
+		log.Debugf("Payment %v: status=%v, active_shards=%v, "+
+			"rem_value=%v, fee_limit=%v", p.identifier, status,
+			ps.NumAttemptsInFlight, ps.RemainingAmt, remainingFees)
 
 		// We now proceed our lifecycle with the following tasks in
 		// order,
@@ -297,6 +288,13 @@ lifecycle:
 
 		log.Tracef("Found route: %s", spew.Sdump(rt.Hops))
 
+		// Allow the traffic shaper to add custom records to the
+		// outgoing HTLC and also adjust the amount if needed.
+		err = p.amendFirstHopData(rt)
+		if err != nil {
+			return exitWithErr(err)
+		}
+
 		// We found a route to try, create a new HTLC attempt to try.
 		attempt, err := p.registerAttempt(rt, ps.RemainingAmt)
 		if err != nil {
@@ -393,13 +391,6 @@ func (p *paymentLifecycle) requestRoute(
 
 	// Exit early if there's no error.
 	if err == nil {
-		// Allow the traffic shaper to add custom records to the
-		// outgoing HTLC and also adjust the amount if needed.
-		err = p.amendFirstHopData(rt)
-		if err != nil {
-			return nil, err
-		}
-
 		return rt, nil
 	}
 
@@ -453,60 +444,62 @@ type attemptResult struct {
 }
 
 // collectResultAsync launches a goroutine that will wait for the result of the
-// given HTLC attempt to be available then save its result in a map. Once
-// received, it will send the result returned from the switch to channel
-// `resultCollected`.
+// given HTLC attempt to be available then handle its result. Once received, it
+// will send a nil error to channel `resultCollected` to indicate there's a
+// result.
 func (p *paymentLifecycle) collectResultAsync(attempt *channeldb.HTLCAttempt) {
 	log.Debugf("Collecting result for attempt %v in payment %v",
 		attempt.AttemptID, p.identifier)
 
 	go func() {
-		result, err := p.collectResult(attempt)
+		// Block until the result is available.
+		_, err := p.collectResult(attempt)
 		if err != nil {
-			log.Errorf("Error collecting result for attempt %v in "+
-				"payment %v: %v", attempt.AttemptID,
+			log.Errorf("Error collecting result for attempt %v "+
+				"in payment %v: %v", attempt.AttemptID,
 				p.identifier, err)
-
-			return
 		}
 
 		log.Debugf("Result collected for attempt %v in payment %v",
 			attempt.AttemptID, p.identifier)
 
-		// Create a switch result and send it to the resultCollected
-		// chan, which gets processed when the lifecycle is waiting for
-		// a result to be received in decideNextStep.
-		r := &switchResult{
-			attempt: attempt,
-			result:  result,
-		}
-
-		// Signal that a result has been collected.
+		// Once the result is collected, we signal it by writing the
+		// error to `resultCollected`.
 		select {
-		// Send the result so decideNextStep can proceed.
-		case p.resultCollected <- r:
+		// Send the signal or quit.
+		case p.resultCollected <- err:
 
 		case <-p.quit:
 			log.Debugf("Lifecycle exiting while collecting "+
 				"result for payment %v", p.identifier)
 
 		case <-p.router.quit:
+			return
 		}
 	}()
 }
 
-// collectResult waits for the result of the given HTLC attempt to be sent by
-// the switch and returns it.
-func (p *paymentLifecycle) collectResult(
-	attempt *channeldb.HTLCAttempt) (*htlcswitch.PaymentResult, error) {
+// collectResult waits for the result for the given attempt to be available
+// from the Switch, then records the attempt outcome with the control tower.
+// An attemptResult is returned, indicating the final outcome of this HTLC
+// attempt.
+func (p *paymentLifecycle) collectResult(attempt *channeldb.HTLCAttempt) (
+	*attemptResult, error) {
 
 	log.Tracef("Collecting result for attempt %v", spew.Sdump(attempt))
 
-	result := &htlcswitch.PaymentResult{}
+	// We'll retrieve the hash specific to this shard from the
+	// shardTracker, since it will be needed to regenerate the circuit
+	// below.
+	hash, err := p.shardTracker.GetHash(attempt.AttemptID)
+	if err != nil {
+		return p.failAttempt(attempt.AttemptID, err)
+	}
 
 	// Regenerate the circuit for this attempt.
-	circuit, err := attempt.Circuit()
-
+	_, circuit, err := generateSphinxPacket(
+		&attempt.Route, hash[:], attempt.SessionKey(),
+	)
 	// TODO(yy): We generate this circuit to create the error decryptor,
 	// which is then used in htlcswitch as the deobfuscator to decode the
 	// error from `UpdateFailHTLC`. However, suppose it's an
@@ -519,7 +512,8 @@ func (p *paymentLifecycle) collectResult(
 	if err != nil {
 		log.Debugf("Unable to generate circuit for attempt %v: %v",
 			attempt.AttemptID, err)
-		return nil, err
+
+		return p.failAttempt(attempt.AttemptID, err)
 	}
 
 	// Using the created circuit, initialize the error decrypter, so we can
@@ -545,21 +539,22 @@ func (p *paymentLifecycle) collectResult(
 		log.Errorf("Failed getting result for attemptID %d "+
 			"from switch: %v", attempt.AttemptID, err)
 
-		result.Error = err
-
-		return result, nil
+		return p.handleSwitchErr(attempt, err)
 	}
 
 	// The switch knows about this payment, we'll wait for a result to be
 	// available.
+	var (
+		result *htlcswitch.PaymentResult
+		ok     bool
+	)
+
 	select {
-	case r, ok := <-resultChan:
+	case result, ok = <-resultChan:
 		if !ok {
 			return nil, htlcswitch.ErrSwitchExiting
 		}
 
-		result = r
-
 	case <-p.quit:
 		return nil, ErrPaymentLifecycleExiting
 
@@ -567,7 +562,46 @@ func (p *paymentLifecycle) collectResult(
 		return nil, ErrRouterShuttingDown
 	}
 
-	return result, nil
+	// In case of a payment failure, fail the attempt with the control
+	// tower and return.
+	if result.Error != nil {
+		return p.handleSwitchErr(attempt, result.Error)
+	}
+
+	// We successfully got a payment result back from the switch.
+	log.Debugf("Payment %v succeeded with pid=%v",
+		p.identifier, attempt.AttemptID)
+
+	// Report success to mission control.
+	err = p.router.cfg.MissionControl.ReportPaymentSuccess(
+		attempt.AttemptID, &attempt.Route,
+	)
+	if err != nil {
+		log.Errorf("Error reporting payment success to mc: %v", err)
+	}
+
+	// In case of success we atomically store settle result to the DB move
+	// the shard to the settled state.
+	htlcAttempt, err := p.router.cfg.Control.SettleAttempt(
+		p.identifier, attempt.AttemptID,
+		&channeldb.HTLCSettleInfo{
+			Preimage:   result.Preimage,
+			SettleTime: p.router.cfg.Clock.Now(),
+		},
+	)
+	if err != nil {
+		log.Errorf("Error settling attempt %v for payment %v with "+
+			"preimage %v: %v", attempt.AttemptID, p.identifier,
+			result.Preimage, err)
+
+		// We won't mark the attempt as failed since we already have
+		// the preimage.
+		return nil, err
+	}
+
+	return &attemptResult{
+		attempt: htlcAttempt,
+	}, nil
 }
 
 // registerAttempt is responsible for creating and saving an HTLC attempt in db
@@ -641,9 +675,11 @@ func (p *paymentLifecycle) createNewPaymentAttempt(rt *route.Route,
 
 	// We now have all the information needed to populate the current
 	// attempt information.
-	return channeldb.NewHtlcAttempt(
+	attempt := channeldb.NewHtlcAttempt(
 		attemptID, sessionKey, *rt, p.router.cfg.Clock.Now(), &hash,
 	)
+
+	return attempt, nil
 }
 
 // sendAttempt attempts to send the current attempt to the switch to complete
@@ -675,7 +711,9 @@ func (p *paymentLifecycle) sendAttempt(
 	// Generate the raw encoded sphinx packet to be included along
 	// with the htlcAdd message that we send directly to the
 	// switch.
-	onionBlob, err := attempt.OnionBlob()
+	onionBlob, _, err := generateSphinxPacket(
+		&rt, attempt.Hash[:], attempt.SessionKey(),
+	)
 	if err != nil {
 		log.Errorf("Failed to create onion blob: attempt=%d in "+
 			"payment=%v, err:%v", attempt.AttemptID,
@@ -684,7 +722,7 @@ func (p *paymentLifecycle) sendAttempt(
 		return p.failAttempt(attempt.AttemptID, err)
 	}
 
-	htlcAdd.OnionBlob = onionBlob
+	copy(htlcAdd.OnionBlob[:], onionBlob)
 
 	// Send it to the Switch. When this method returns we assume
 	// the Switch successfully has persisted the payment attempt,
@@ -847,8 +885,8 @@ func (p *paymentLifecycle) handleSwitchErr(attempt *channeldb.HTLCAttempt,
 	// case we can safely send a new payment attempt, and wait for its
 	// result to be available.
 	if errors.Is(sendErr, htlcswitch.ErrPaymentIDNotFound) {
-		log.Warnf("Failing attempt=%v for payment=%v as it's not "+
-			"found in the Switch", attempt.AttemptID, p.identifier)
+		log.Debugf("Attempt ID %v for payment %v not found in the "+
+			"Switch, retrying.", attempt.AttemptID, p.identifier)
 
 		return p.failAttempt(attemptID, sendErr)
 	}
@@ -1059,106 +1097,3 @@ func marshallError(sendError error, time time.Time) *channeldb.HTLCFailInfo {
 
 	return response
 }
-
-// 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.
-func (p *paymentLifecycle) reloadInflightAttempts() (DBMPPayment, error) {
-	payment, err := p.router.cfg.Control.FetchPayment(p.identifier)
-	if err != nil {
-		return nil, err
-	}
-
-	for _, a := range payment.InFlightHTLCs() {
-		a := a
-
-		log.Infof("Resuming HTLC attempt %v for payment %v",
-			a.AttemptID, p.identifier)
-
-		p.resultCollector(&a)
-	}
-
-	return payment, nil
-}
-
-// reloadPayment returns the latest payment found in the db (control tower).
-func (p *paymentLifecycle) reloadPayment() (DBMPPayment,
-	*channeldb.MPPaymentState, error) {
-
-	// Read the db to get the latest state of the payment.
-	payment, err := p.router.cfg.Control.FetchPayment(p.identifier)
-	if err != nil {
-		return nil, nil, err
-	}
-
-	ps := payment.GetState()
-	remainingFees := p.calcFeeBudget(ps.FeesPaid)
-
-	log.Debugf("Payment %v: status=%v, active_shards=%v, rem_value=%v, "+
-		"fee_limit=%v", p.identifier, payment.GetStatus(),
-		ps.NumAttemptsInFlight, ps.RemainingAmt, remainingFees)
-
-	return payment, ps, nil
-}
-
-// handleAttemptResult processes the result of an HTLC attempt returned from
-// the htlcswitch.
-func (p *paymentLifecycle) handleAttemptResult(attempt *channeldb.HTLCAttempt,
-	result *htlcswitch.PaymentResult) (*attemptResult, error) {
-
-	// If the result has an error, we need to further process it by failing
-	// the attempt and maybe fail the payment.
-	if result.Error != nil {
-		return p.handleSwitchErr(attempt, result.Error)
-	}
-
-	// We got an attempt settled result back from the switch.
-	log.Debugf("Payment(%v): attempt(%v) succeeded", p.identifier,
-		attempt.AttemptID)
-
-	// Report success to mission control.
-	err := p.router.cfg.MissionControl.ReportPaymentSuccess(
-		attempt.AttemptID, &attempt.Route,
-	)
-	if err != nil {
-		log.Errorf("Error reporting payment success to mc: %v", err)
-	}
-
-	// In case of success we atomically store settle result to the DB and
-	// move the shard to the settled state.
-	htlcAttempt, err := p.router.cfg.Control.SettleAttempt(
-		p.identifier, attempt.AttemptID,
-		&channeldb.HTLCSettleInfo{
-			Preimage:   result.Preimage,
-			SettleTime: p.router.cfg.Clock.Now(),
-		},
-	)
-	if err != nil {
-		log.Errorf("Error settling attempt %v for payment %v with "+
-			"preimage %v: %v", attempt.AttemptID, p.identifier,
-			result.Preimage, err)
-
-		// We won't mark the attempt as failed since we already have
-		// the preimage.
-		return nil, err
-	}
-
-	return &attemptResult{
-		attempt: htlcAttempt,
-	}, nil
-}
-
-// collectAndHandleResult waits for the result for the given attempt to be
-// available from the Switch, then records the attempt outcome with the control
-// tower. An attemptResult is returned, indicating the final outcome of this
-// HTLC attempt.
-func (p *paymentLifecycle) collectAndHandleResult(
-	attempt *channeldb.HTLCAttempt) (*attemptResult, error) {
-
-	result, err := p.collectResult(attempt)
-	if err != nil {
-		return nil, err
-	}
-
-	return p.handleAttemptResult(attempt, result)
-}
diff --git a/routing/router.go b/routing/router.go
index 323fe7616..468510a6c 100644
--- a/routing/router.go
+++ b/routing/router.go
@@ -1,6 +1,7 @@
 package routing
 
 import (
+	"bytes"
 	"context"
 	"fmt"
 	"math"
@@ -14,6 +15,7 @@ import (
 	"github.com/btcsuite/btcd/btcutil"
 	"github.com/davecgh/go-spew/spew"
 	"github.com/go-errors/errors"
+	sphinx "github.com/lightningnetwork/lightning-onion"
 	"github.com/lightningnetwork/lnd/amp"
 	"github.com/lightningnetwork/lnd/channeldb"
 	"github.com/lightningnetwork/lnd/clock"
@@ -720,6 +722,71 @@ func generateNewSessionKey() (*btcec.PrivateKey, error) {
 	return btcec.NewPrivateKey()
 }
 
+// generateSphinxPacket generates then encodes a sphinx packet which encodes
+// the onion route specified by the passed layer 3 route. The blob returned
+// from this function can immediately be included within an HTLC add packet to
+// be sent to the first hop within the route.
+func generateSphinxPacket(rt *route.Route, paymentHash []byte,
+	sessionKey *btcec.PrivateKey) ([]byte, *sphinx.Circuit, error) {
+
+	// Now that we know we have an actual route, we'll map the route into a
+	// sphinx payment path which includes per-hop payloads for each hop
+	// that give each node within the route the necessary information
+	// (fees, CLTV value, etc.) to properly forward the payment.
+	sphinxPath, err := rt.ToSphinxPath()
+	if err != nil {
+		return nil, nil, err
+	}
+
+	log.Tracef("Constructed per-hop payloads for payment_hash=%x: %v",
+		paymentHash, lnutils.NewLogClosure(func() string {
+			path := make(
+				[]sphinx.OnionHop, sphinxPath.TrueRouteLength(),
+			)
+			for i := range path {
+				hopCopy := sphinxPath[i]
+				path[i] = hopCopy
+			}
+
+			return spew.Sdump(path)
+		}),
+	)
+
+	// Next generate the onion routing packet which allows us to perform
+	// privacy preserving source routing across the network.
+	sphinxPacket, err := sphinx.NewOnionPacket(
+		sphinxPath, sessionKey, paymentHash,
+		sphinx.DeterministicPacketFiller,
+	)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	// Finally, encode Sphinx packet using its wire representation to be
+	// included within the HTLC add packet.
+	var onionBlob bytes.Buffer
+	if err := sphinxPacket.Encode(&onionBlob); err != nil {
+		return nil, nil, err
+	}
+
+	log.Tracef("Generated sphinx packet: %v",
+		lnutils.NewLogClosure(func() string {
+			// We make a copy of the ephemeral key and unset the
+			// internal curve here in order to keep the logs from
+			// getting noisy.
+			key := *sphinxPacket.EphemeralKey
+			packetCopy := *sphinxPacket
+			packetCopy.EphemeralKey = &key
+			return spew.Sdump(packetCopy)
+		}),
+	)
+
+	return onionBlob.Bytes(), &sphinx.Circuit{
+		SessionKey:  sessionKey,
+		PaymentPath: sphinxPath.NodeKeys(),
+	}, nil
+}
+
 // LightningPayment describes a payment to be sent through the network to the
 // final destination.
 type LightningPayment struct {
@@ -1186,7 +1253,7 @@ func (r *ChannelRouter) sendToRoute(htlcHash lntypes.Hash, rt *route.Route,
 
 	// The attempt was successfully sent, wait for the result to be
 	// available.
-	result, err = p.collectAndHandleResult(attempt)
+	result, err = p.collectResult(attempt)
 	if err != nil {
 		return nil, err
 	}


Test patch:
diff --git a/channeldb/mp_payment_test.go b/channeldb/mp_payment_test.go
index 13e39871a..51eda72bb 100644
--- a/channeldb/mp_payment_test.go
+++ b/channeldb/mp_payment_test.go
@@ -5,22 +5,12 @@ import (
 	"fmt"
 	"testing"
 
-	"github.com/btcsuite/btcd/btcec/v2"
 	"github.com/lightningnetwork/lnd/lntypes"
 	"github.com/lightningnetwork/lnd/lnwire"
 	"github.com/lightningnetwork/lnd/routing/route"
 	"github.com/stretchr/testify/require"
 )
 
-var (
-	testHash = [32]byte{
-		0xb7, 0x94, 0x38, 0x5f, 0x2d, 0x1e, 0xf7, 0xab,
-		0x4d, 0x92, 0x73, 0xd1, 0x90, 0x63, 0x81, 0xb4,
-		0x4f, 0x2f, 0x6f, 0x25, 0x88, 0xa3, 0xef, 0xb9,
-		0x6a, 0x49, 0x18, 0x83, 0x31, 0x98, 0x47, 0x53,
-	}
-)
-
 // TestLazySessionKeyDeserialize tests that we can read htlc attempt session
 // keys that were previously serialized as a private key as raw bytes.
 func TestLazySessionKeyDeserialize(t *testing.T) {
@@ -588,15 +578,3 @@ func makeAttemptInfo(total, amtForwarded int) HTLCAttemptInfo {
 		},
 	}
 }
-
-// TestEmptyRoutesGenerateSphinxPacket tests that the generateSphinxPacket
-// function is able to gracefully handle being passed a nil set of hops for the
-// route by the caller.
-func TestEmptyRoutesGenerateSphinxPacket(t *testing.T) {
-	t.Parallel()
-
-	sessionKey, _ := btcec.NewPrivateKey()
-	emptyRoute := &route.Route{}
-	_, _, err := generateSphinxPacket(emptyRoute, testHash[:], sessionKey)
-	require.ErrorIs(t, err, route.ErrNoRouteHopsProvided)
-}
diff --git a/channeldb/payment_control_test.go b/channeldb/payment_control_test.go
index 9bc486a83..fb965bb32 100644
--- a/channeldb/payment_control_test.go
+++ b/channeldb/payment_control_test.go
@@ -28,7 +28,7 @@ func genPreimage() ([32]byte, error) {
 	return preimage, nil
 }
 
-func genInfo(t *testing.T) (*PaymentCreationInfo, *HTLCAttemptInfo,
+func genInfo() (*PaymentCreationInfo, *HTLCAttemptInfo,
 	lntypes.Preimage, error) {
 
 	preimage, err := genPreimage()
@@ -38,14 +38,9 @@ func genInfo(t *testing.T) (*PaymentCreationInfo, *HTLCAttemptInfo,
 	}
 
 	rhash := sha256.Sum256(preimage[:])
-	var hash lntypes.Hash
-	copy(hash[:], rhash[:])
-
-	attempt, err := NewHtlcAttempt(
-		0, priv, *testRoute.Copy(), time.Time{}, &hash,
+	attempt := NewHtlcAttempt(
+		0, priv, *testRoute.Copy(), time.Time{}, nil,
 	)
-	require.NoError(t, err)
-
 	return &PaymentCreationInfo{
 		PaymentIdentifier: rhash,
 		Value:             testRoute.ReceiverAmt(),
@@ -65,7 +60,7 @@ func TestPaymentControlSwitchFail(t *testing.T) {
 
 	pControl := NewPaymentControl(db)
 
-	info, attempt, preimg, err := genInfo(t)
+	info, attempt, preimg, err := genInfo()
 	require.NoError(t, err, "unable to generate htlc message")
 
 	// Sends base htlc message which initiate StatusInFlight.
@@ -201,7 +196,7 @@ func TestPaymentControlSwitchDoubleSend(t *testing.T) {
 
 	pControl := NewPaymentControl(db)
 
-	info, attempt, preimg, err := genInfo(t)
+	info, attempt, preimg, err := genInfo()
 	require.NoError(t, err, "unable to generate htlc message")
 
 	// Sends base htlc message which initiate base status and move it to
@@ -271,7 +266,7 @@ func TestPaymentControlSuccessesWithoutInFlight(t *testing.T) {
 
 	pControl := NewPaymentControl(db)
 
-	info, _, preimg, err := genInfo(t)
+	info, _, preimg, err := genInfo()
 	require.NoError(t, err, "unable to generate htlc message")
 
 	// Attempt to complete the payment should fail.
@@ -296,7 +291,7 @@ func TestPaymentControlFailsWithoutInFlight(t *testing.T) {
 
 	pControl := NewPaymentControl(db)
 
-	info, _, _, err := genInfo(t)
+	info, _, _, err := genInfo()
 	require.NoError(t, err, "unable to generate htlc message")
 
 	// Calling Fail should return an error.
@@ -351,7 +346,7 @@ func TestPaymentControlDeleteNonInFlight(t *testing.T) {
 	var numSuccess, numInflight int
 
 	for _, p := range payments {
-		info, attempt, preimg, err := genInfo(t)
+		info, attempt, preimg, err := genInfo()
 		if err != nil {
 			t.Fatalf("unable to generate htlc message: %v", err)
 		}
@@ -689,7 +684,7 @@ func TestPaymentControlMultiShard(t *testing.T) {
 
 		pControl := NewPaymentControl(db)
 
-		info, attempt, preimg, err := genInfo(t)
+		info, attempt, preimg, err := genInfo()
 		if err != nil {
 			t.Fatalf("unable to generate htlc message: %v", err)
 		}
@@ -953,7 +948,7 @@ func TestPaymentControlMPPRecordValidation(t *testing.T) {
 
 	pControl := NewPaymentControl(db)
 
-	info, attempt, _, err := genInfo(t)
+	info, attempt, _, err := genInfo()
 	require.NoError(t, err, "unable to generate htlc message")
 
 	// Init the payment.
@@ -1002,7 +997,7 @@ func TestPaymentControlMPPRecordValidation(t *testing.T) {
 
 	// Create and init a new payment. This time we'll check that we cannot
 	// register an MPP attempt if we already registered a non-MPP one.
-	info, attempt, _, err = genInfo(t)
+	info, attempt, _, err = genInfo()
 	require.NoError(t, err, "unable to generate htlc message")
 
 	err = pControl.InitPayment(info.PaymentIdentifier, info)
@@ -1276,7 +1271,7 @@ func createTestPayments(t *testing.T, p *PaymentControl, payments []*payment) {
 	attemptID := uint64(0)
 
 	for i := 0; i < len(payments); i++ {
-		info, attempt, preimg, err := genInfo(t)
+		info, attempt, preimg, err := genInfo()
 		require.NoError(t, err, "unable to generate htlc message")
 
 		// Set the payment id accordingly in the payments slice.
diff --git a/channeldb/payments_test.go b/channeldb/payments_test.go
index b2a0292a4..0c3753e66 100644
--- a/channeldb/payments_test.go
+++ b/channeldb/payments_test.go
@@ -64,6 +64,7 @@ var (
 		TotalAmount:   1234567,
 		SourcePubKey:  vertex,
 		Hops: []*route.Hop{
+			testHop3,
 			testHop2,
 			testHop1,
 		},
@@ -97,7 +98,7 @@ var (
 	}
 )
 
-func makeFakeInfo(t *testing.T) (*PaymentCreationInfo, *HTLCAttemptInfo) {
+func makeFakeInfo() (*PaymentCreationInfo, *HTLCAttemptInfo) {
 	var preimg lntypes.Preimage
 	copy(preimg[:], rev[:])
 
@@ -112,10 +113,9 @@ func makeFakeInfo(t *testing.T) (*PaymentCreationInfo, *HTLCAttemptInfo) {
 		PaymentRequest: []byte("test"),
 	}
 
-	a, err := NewHtlcAttempt(
+	a := NewHtlcAttempt(
 		44, priv, testRoute, time.Unix(100, 0), &hash,
 	)
-	require.NoError(t, err)
 
 	return c, &a.HTLCAttemptInfo
 }
@@ -123,7 +123,7 @@ func makeFakeInfo(t *testing.T) (*PaymentCreationInfo, *HTLCAttemptInfo) {
 func TestSentPaymentSerialization(t *testing.T) {
 	t.Parallel()
 
-	c, s := makeFakeInfo(t)
+	c, s := makeFakeInfo()
 
 	var b bytes.Buffer
 	require.NoError(t, serializePaymentCreationInfo(&b, c), "serialize")
@@ -174,9 +174,6 @@ func TestSentPaymentSerialization(t *testing.T) {
 	require.NoError(t, err, "deserialize")
 	require.Equal(t, s.Route, newWireInfo.Route)
 
-	err = newWireInfo.attachOnionBlobAndCircuit()
-	require.NoError(t, err)
-
 	// Clear routes to allow DeepEqual to compare the remaining fields.
 	newWireInfo.Route = route.Route{}
 	s.Route = route.Route{}
@@ -520,7 +517,7 @@ func TestQueryPayments(t *testing.T) {
 
 			for i := 0; i < nonDuplicatePayments; i++ {
 				// Generate a test payment.
-				info, _, preimg, err := genInfo(t)
+				info, _, preimg, err := genInfo()
 				if err != nil {
 					t.Fatalf("unable to create test "+
 						"payment: %v", err)
@@ -621,7 +618,7 @@ func TestFetchPaymentWithSequenceNumber(t *testing.T) {
 	pControl := NewPaymentControl(db)
 
 	// Generate a test payment which does not have duplicates.
-	noDuplicates, _, _, err := genInfo(t)
+	noDuplicates, _, _, err := genInfo()
 	require.NoError(t, err)
 
 	// Create a new payment entry in the database.
@@ -635,7 +632,7 @@ func TestFetchPaymentWithSequenceNumber(t *testing.T) {
 	require.NoError(t, err)
 
 	// Generate a test payment which we will add duplicates to.
-	hasDuplicates, _, preimg, err := genInfo(t)
+	hasDuplicates, _, preimg, err := genInfo()
 	require.NoError(t, err)
 
 	// Create a new payment entry in the database.
@@ -786,7 +783,7 @@ func putDuplicatePayment(t *testing.T, duplicateBucket kvdb.RwBucket,
 	require.NoError(t, err)
 
 	// Generate fake information for the duplicate payment.
-	info, _, _, err := genInfo(t)
+	info, _, _, err := genInfo()
 	require.NoError(t, err)
 
 	// Write the payment info to disk under the creation info key. This code
diff --git a/graph/db/graph_cache_test.go b/graph/db/graph_cache_test.go
index 087fb81ac..69abb2597 100644
--- a/graph/db/graph_cache_test.go
+++ b/graph/db/graph_cache_test.go
@@ -5,6 +5,7 @@ import (
 	"testing"
 
 	"github.com/lightningnetwork/lnd/graph/db/models"
+	"github.com/lightningnetwork/lnd/kvdb"
 	"github.com/lightningnetwork/lnd/lnwire"
 	"github.com/lightningnetwork/lnd/routing/route"
 	"github.com/stretchr/testify/require"
@@ -24,6 +25,39 @@ var (
 	pubKey2, _ = route.NewVertexFromBytes(pubKey2Bytes)
 )
 
+type node struct {
+	pubKey   route.Vertex
+	features *lnwire.FeatureVector
+
+	edgeInfos   []*models.ChannelEdgeInfo
+	outPolicies []*models.ChannelEdgePolicy
+	inPolicies  []*models.ChannelEdgePolicy
+}
+
+func (n *node) PubKey() route.Vertex {
+	return n.pubKey
+}
+func (n *node) Features() *lnwire.FeatureVector {
+	return n.features
+}
+
+func (n *node) ForEachChannel(tx kvdb.RTx,
+	cb func(kvdb.RTx, *models.ChannelEdgeInfo, *models.ChannelEdgePolicy,
+		*models.ChannelEdgePolicy) error) error {
+
+	for idx := range n.edgeInfos {
+		err := cb(
+			tx, n.edgeInfos[idx], n.outPolicies[idx],
+			n.inPolicies[idx],
+		)
+		if err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
 // TestGraphCacheAddNode tests that a channel going from node A to node B can be
 // cached correctly, independent of the direction we add the channel as.
 func TestGraphCacheAddNode(t *testing.T) {
@@ -51,15 +85,21 @@ func TestGraphCacheAddNode(t *testing.T) {
 			ChannelFlags: lnwire.ChanUpdateChanFlags(channelFlagB),
 			ToNode:       nodeA,
 		}
+		node := &node{
+			pubKey:   nodeA,
+			features: lnwire.EmptyFeatureVector(),
+			edgeInfos: []*models.ChannelEdgeInfo{{
+				ChannelID: 1000,
+				// Those are direction independent!
+				NodeKey1Bytes: pubKey1,
+				NodeKey2Bytes: pubKey2,
+				Capacity:      500,
+			}},
+			outPolicies: []*models.ChannelEdgePolicy{outPolicy1},
+			inPolicies:  []*models.ChannelEdgePolicy{inPolicy1},
+		}
 		cache := NewGraphCache(10)
-		cache.AddNodeFeatures(nodeA, lnwire.EmptyFeatureVector())
-		cache.AddChannel(&models.ChannelEdgeInfo{
-			ChannelID: 1000,
-			// Those are direction independent!
-			NodeKey1Bytes: pubKey1,
-			NodeKey2Bytes: pubKey2,
-			Capacity:      500,
-		}, outPolicy1, inPolicy1)
+		require.NoError(t, cache.AddNode(nil, node))
 
 		var fromChannels, toChannels []*DirectedChannel
 		_ = cache.ForEachChannel(nodeA, func(c *DirectedChannel) error {
diff --git a/graph/db/graph_test.go b/graph/db/graph_test.go
index eb658e2f1..3b3454b6b 100644
--- a/graph/db/graph_test.go
+++ b/graph/db/graph_test.go
@@ -1105,22 +1105,24 @@ func TestGraphTraversalCacheable(t *testing.T) {
 	// Iterate through all the known channels within the graph DB by
 	// iterating over each node, once again if the map is empty that
 	// indicates that all edges have properly been reached.
-	var nodes []route.Vertex
-	err = graph.ForEachNodeCacheable(func(node route.Vertex,
-		features *lnwire.FeatureVector) error {
+	var nodes []GraphCacheNode
+	err = graph.ForEachNodeCacheable(
+		func(tx kvdb.RTx, node GraphCacheNode) error {
+			delete(nodeMap, node.PubKey())
 
-		delete(nodeMap, node)
-		nodes = append(nodes, node)
+			nodes = append(nodes, node)
 
-		return nil
-	})
+			return nil
+		},
+	)
 	require.NoError(t, err)
 	require.Len(t, nodeMap, 0)
 
 	err = graph.db.View(func(tx kvdb.RTx) error {
 		for _, node := range nodes {
-			err := graph.ForEachNodeChannelTx(tx, node,
-				func(tx kvdb.RTx, info *models.ChannelEdgeInfo,
+			err := node.ForEachChannel(
+				tx, func(tx kvdb.RTx,
+					info *models.ChannelEdgeInfo,
 					policy *models.ChannelEdgePolicy,
 					policy2 *models.ChannelEdgePolicy) error { //nolint:ll
 
@@ -3881,37 +3883,44 @@ func BenchmarkForEachChannel(b *testing.B) {
 			maxHTLCs      lnwire.MilliSatoshi
 		)
 
-		var nodes []route.Vertex
-		err = graph.ForEachNodeCacheable(func(node route.Vertex,
-			vector *lnwire.FeatureVector) error {
-
-			nodes = append(nodes, node)
+		var nodes []GraphCacheNode
+		err = graph.ForEachNodeCacheable(
+			func(tx kvdb.RTx, node GraphCacheNode) error {
+				nodes = append(nodes, node)
 
-			return nil
-		})
+				return nil
+			},
+		)
 		require.NoError(b, err)
 
-		for _, n := range nodes {
-			cb := func(tx kvdb.RTx,
-				info *models.ChannelEdgeInfo,
-				policy *models.ChannelEdgePolicy,
-				policy2 *models.ChannelEdgePolicy) error { //nolint:ll
-
-				// We need to do something with
-				// the data here, otherwise the
-				// compiler is going to optimize
-				// this away, and we get bogus
-				// results.
-				totalCapacity += info.Capacity
-				maxHTLCs += policy.MaxHTLC
-				maxHTLCs += policy2.MaxHTLC
+		err = graph.db.View(func(tx kvdb.RTx) error {
+			for _, n := range nodes {
+				cb := func(tx kvdb.RTx,
+					info *models.ChannelEdgeInfo,
+					policy *models.ChannelEdgePolicy,
+					policy2 *models.ChannelEdgePolicy) error { //nolint:ll
 
-				return nil
+					// We need to do something with
+					// the data here, otherwise the
+					// compiler is going to optimize
+					// this away, and we get bogus
+					// results.
+					totalCapacity += info.Capacity
+					maxHTLCs += policy.MaxHTLC
+					maxHTLCs += policy2.MaxHTLC
+
+					return nil
+				}
+
+				err := n.ForEachChannel(tx, cb)
+				if err != nil {
+					return err
+				}
 			}
 
-			err := graph.ForEachNodeChannel(n, cb)
-			require.NoError(b, err)
-		}
+			return nil
+		}, func() {})
+		require.NoError(b, err)
 	}
 }
 
diff --git a/itest/lnd_multi-hop_force_close_test.go b/itest/lnd_multi-hop_force_close_test.go
index da760a289..a7db9ea28 100644
--- a/itest/lnd_multi-hop_force_close_test.go
+++ b/itest/lnd_multi-hop_force_close_test.go
@@ -357,11 +357,8 @@ func runLocalClaimOutgoingHTLC(ht *lntest.HarnessTest,
 	// We'll create two random payment hashes unknown to carol, then send
 	// each of them by manually specifying the HTLC details.
 	carolPubKey := carol.PubKey[:]
-
-	preimageDust := ht.RandomPreimage()
-	preimage := ht.RandomPreimage()
-	dustPayHash := preimageDust.Hash()
-	payHash := preimage.Hash()
+	dustPayHash := ht.Random32Bytes()
+	payHash := ht.Random32Bytes()
 
 	// If this is a taproot channel, then we'll need to make some manual
 	// route hints so Alice can actually find a route.
@@ -373,7 +370,7 @@ func runLocalClaimOutgoingHTLC(ht *lntest.HarnessTest,
 	req := &routerrpc.SendPaymentRequest{
 		Dest:           carolPubKey,
 		Amt:            int64(dustHtlcAmt),
-		PaymentHash:    dustPayHash[:],
+		PaymentHash:    dustPayHash,
 		FinalCltvDelta: finalCltvDelta,
 		FeeLimitMsat:   noFeeLimitMsat,
 		RouteHints:     routeHints,
@@ -383,7 +380,7 @@ func runLocalClaimOutgoingHTLC(ht *lntest.HarnessTest,
 	req = &routerrpc.SendPaymentRequest{
 		Dest:           carolPubKey,
 		Amt:            int64(htlcAmt),
-		PaymentHash:    payHash[:],
+		PaymentHash:    payHash,
 		FinalCltvDelta: finalCltvDelta,
 		FeeLimitMsat:   noFeeLimitMsat,
 		RouteHints:     routeHints,
@@ -533,25 +530,6 @@ func runLocalClaimOutgoingHTLC(ht *lntest.HarnessTest,
 	// Once this transaction has been confirmed, Bob should detect that he
 	// no longer has any pending channels.
 	ht.AssertNumPendingForceClose(bob, 0)
-
-	// Now that Bob has claimed his HTLCs, Alice should mark the two
-	// payments as failed.
-	//
-	// Alice will mark this payment as failed with no route as the only
-	// route she has is Alice->Bob->Carol. This won't be the case if she
-	// has a second route, as another attempt will be tried.
-	//
-	// TODO(yy): we should instead mark this payment as timed out if she has
-	// a second route to try this payment, which is the timeout set by Alice
-	// when sending the payment.
-	expectedReason := lnrpc.PaymentFailureReason_FAILURE_REASON_NO_ROUTE
-	p := ht.AssertPaymentFailureReason(alice, preimage, expectedReason)
-	require.Equal(ht, lnrpc.Failure_PERMANENT_CHANNEL_FAILURE,
-		p.Htlcs[0].Failure.Code)
-
-	p = ht.AssertPaymentFailureReason(alice, preimageDust, expectedReason)
-	require.Equal(ht, lnrpc.Failure_PERMANENT_CHANNEL_FAILURE,
-		p.Htlcs[0].Failure.Code)
 }
 
 // testMultiHopReceiverPreimageClaimAnchor tests
diff --git a/lntest/harness_assertion.go b/lntest/harness_assertion.go
index 9af81a7cd..8130ac167 100644
--- a/lntest/harness_assertion.go
+++ b/lntest/harness_assertion.go
@@ -1600,11 +1600,8 @@ func (h *HarnessTest) AssertPaymentStatus(hn *node.HarnessNode,
 
 // AssertPaymentFailureReason asserts that the given node lists a payment with
 // the given preimage which has the expected failure reason.
-func (h *HarnessTest) AssertPaymentFailureReason(
-	hn *node.HarnessNode, preimage lntypes.Preimage,
-	reason lnrpc.PaymentFailureReason) *lnrpc.Payment {
-
-	var payment *lnrpc.Payment
+func (h *HarnessTest) AssertPaymentFailureReason(hn *node.HarnessNode,
+	preimage lntypes.Preimage, reason lnrpc.PaymentFailureReason) {
 
 	payHash := preimage.Hash()
 	err := wait.NoError(func() error {
@@ -1613,19 +1610,14 @@ func (h *HarnessTest) AssertPaymentFailureReason(
 			return err
 		}
 
-		payment = p
-
 		if reason == p.FailureReason {
 			return nil
 		}
 
 		return fmt.Errorf("payment: %v failure reason not match, "+
-			"want %s(%d) got %s(%d)", payHash, reason, reason,
-			p.FailureReason, p.FailureReason)
+			"want %s got %s", payHash, reason, p.Status)
 	}, DefaultTimeout)
 	require.NoError(h, err, "timeout checking payment failure reason")
-
-	return payment
 }
 
 // AssertActiveNodesSynced asserts all active nodes have synced to the chain.
diff --git a/routing/control_tower_test.go b/routing/control_tower_test.go
index 532e639d6..5fb271afa 100644
--- a/routing/control_tower_test.go
+++ b/routing/control_tower_test.go
@@ -535,23 +535,15 @@ func genInfo() (*channeldb.PaymentCreationInfo, *channeldb.HTLCAttemptInfo,
 	}
 
 	rhash := sha256.Sum256(preimage[:])
-	var hash lntypes.Hash
-	copy(hash[:], rhash[:])
-
-	attempt, err := channeldb.NewHtlcAttempt(
-		1, priv, testRoute, time.Time{}, &hash,
-	)
-	if err != nil {
-		return nil, nil, lntypes.Preimage{}, err
-	}
-
 	return &channeldb.PaymentCreationInfo{
 			PaymentIdentifier: rhash,
 			Value:             testRoute.ReceiverAmt(),
 			CreationTime:      time.Unix(time.Now().Unix(), 0),
 			PaymentRequest:    []byte("hola"),
 		},
-		&attempt.HTLCAttemptInfo, preimage, nil
+		&channeldb.NewHtlcAttempt(
+			1, priv, testRoute, time.Time{}, nil,
+		).HTLCAttemptInfo, preimage, nil
 }
 
 func genPreimage() ([32]byte, error) {
diff --git a/routing/payment_lifecycle_test.go b/routing/payment_lifecycle_test.go
index b04b04d4b..72aa63141 100644
--- a/routing/payment_lifecycle_test.go
+++ b/routing/payment_lifecycle_test.go
@@ -260,15 +260,10 @@ func createDummyRoute(t *testing.T, amt lnwire.MilliSatoshi) *route.Route {
 func makeSettledAttempt(t *testing.T, total int,
 	preimage lntypes.Preimage) *channeldb.HTLCAttempt {
 
-	a := &channeldb.HTLCAttempt{
+	return &channeldb.HTLCAttempt{
 		HTLCAttemptInfo: makeAttemptInfo(t, total),
 		Settle:          &channeldb.HTLCSettleInfo{Preimage: preimage},
 	}
-
-	hash := preimage.Hash()
-	a.Hash = &hash
-
-	return a
 }
 
 func makeFailedAttempt(t *testing.T, total int) *channeldb.HTLCAttempt {
@@ -284,7 +279,6 @@ func makeAttemptInfo(t *testing.T, amt int) channeldb.HTLCAttemptInfo {
 	rt := createDummyRoute(t, lnwire.MilliSatoshi(amt))
 	return channeldb.HTLCAttemptInfo{
 		Route: *rt,
-		Hash:  &lntypes.Hash{1, 2, 3},
 	}
 }
 
@@ -522,8 +516,7 @@ func TestRequestRouteFailPaymentError(t *testing.T) {
 	ct.AssertExpectations(t)
 }
 
-// TestDecideNextStep checks the method `decideNextStep` behaves as expected
-// given the returned values from `AllowMoreAttempts` and `NeedWaitAttempts`.
+// TestDecideNextStep checks the method `decideNextStep` behaves as expected.
 func TestDecideNextStep(t *testing.T) {
 	t.Parallel()
 
@@ -538,8 +531,15 @@ func TestDecideNextStep(t *testing.T) {
 		name              string
 		allowMoreAttempts *mockReturn
 		needWaitAttempts  *mockReturn
-		expectedStep      stateStep
-		expectedErr       error
+
+		// When the attemptResultChan has returned.
+		closeResultChan bool
+
+		// Whether the router has quit.
+		routerQuit bool
+
+		expectedStep stateStep
+		expectedErr  error
 	}{
 		{
 			name:              "allow more attempts",
@@ -548,36 +548,52 @@ func TestDecideNextStep(t *testing.T) {
 			expectedErr:       nil,
 		},
 		{
-			name:              "error checking allow more attempts",
+			name:              "error on allow more attempts",
 			allowMoreAttempts: &mockReturn{false, errDummy},
 			expectedStep:      stepExit,
 			expectedErr:       errDummy,
 		},
 		{
-			name:              "no need to wait attempts",
+			name:              "no wait and exit",
 			allowMoreAttempts: &mockReturn{false, nil},
 			needWaitAttempts:  &mockReturn{false, nil},
 			expectedStep:      stepExit,
 			expectedErr:       nil,
 		},
 		{
-			name:              "error checking wait attempts",
+			name:              "wait returns an error",
 			allowMoreAttempts: &mockReturn{false, nil},
 			needWaitAttempts:  &mockReturn{false, errDummy},
 			expectedStep:      stepExit,
 			expectedErr:       errDummy,
 		},
+
+		{
+			name:              "wait and exit on result chan",
+			allowMoreAttempts: &mockReturn{false, nil},
+			needWaitAttempts:  &mockReturn{true, nil},
+			closeResultChan:   true,
+			expectedStep:      stepSkip,
+			expectedErr:       nil,
+		},
+		{
+			name:              "wait and exit on router quit",
+			allowMoreAttempts: &mockReturn{false, nil},
+			needWaitAttempts:  &mockReturn{true, nil},
+			routerQuit:        true,
+			expectedStep:      stepExit,
+			expectedErr:       ErrRouterShuttingDown,
+		},
 	}
 
 	for _, tc := range testCases {
 		tc := tc
 
 		// Create a test paymentLifecycle.
-		p, _ := newTestPaymentLifecycle(t)
+		p := createTestPaymentLifecycle()
 
 		// Make a mock payment.
 		payment := &mockMPPayment{}
-		defer payment.AssertExpectations(t)
 
 		// Mock the method AllowMoreAttempts.
 		payment.On("AllowMoreAttempts").Return(
@@ -593,188 +609,27 @@ func TestDecideNextStep(t *testing.T) {
 			).Once()
 		}
 
+		// Send a nil error to the attemptResultChan if requested.
+		if tc.closeResultChan {
+			p.resultCollected = make(chan error, 1)
+			p.resultCollected <- nil
+		}
+
+		// Quit the router if requested.
+		if tc.routerQuit {
+			close(p.router.quit)
+		}
+
 		// Once the setup is finished, run the test cases.
 		t.Run(tc.name, func(t *testing.T) {
 			step, err := p.decideNextStep(payment)
 			require.Equal(t, tc.expectedStep, step)
 			require.ErrorIs(t, tc.expectedErr, err)
 		})
-	}
-}
-
-// TestDecideNextStepOnRouterQuit checks the method `decideNextStep` behaves as
-// expected when the router is quit.
-func TestDecideNextStepOnRouterQuit(t *testing.T) {
-	t.Parallel()
-
-	// Create a test paymentLifecycle.
-	p, _ := newTestPaymentLifecycle(t)
-
-	// Make a mock payment.
-	payment := &mockMPPayment{}
-	defer payment.AssertExpectations(t)
-
-	// Mock the method AllowMoreAttempts to return false.
-	payment.On("AllowMoreAttempts").Return(false, nil).Once()
-
-	// Mock the method NeedWaitAttempts to wait for results.
-	payment.On("NeedWaitAttempts").Return(true, nil).Once()
-
-	// Quit the router.
-	close(p.router.quit)
-
-	// Call the method under test.
-	step, err := p.decideNextStep(payment)
-
-	// We expect stepExit and an error to be returned.
-	require.Equal(t, stepExit, step)
-	require.ErrorIs(t, err, ErrRouterShuttingDown)
-}
-
-// TestDecideNextStepOnLifecycleQuit checks the method `decideNextStep` behaves
-// as expected when the lifecycle is quit.
-func TestDecideNextStepOnLifecycleQuit(t *testing.T) {
-	t.Parallel()
-
-	// Create a test paymentLifecycle.
-	p, _ := newTestPaymentLifecycle(t)
-
-	// Make a mock payment.
-	payment := &mockMPPayment{}
-	defer payment.AssertExpectations(t)
-
-	// Mock the method AllowMoreAttempts to return false.
-	payment.On("AllowMoreAttempts").Return(false, nil).Once()
-
-	// Mock the method NeedWaitAttempts to wait for results.
-	payment.On("NeedWaitAttempts").Return(true, nil).Once()
-
-	// Quit the paymentLifecycle.
-	close(p.quit)
 
-	// Call the method under test.
-	step, err := p.decideNextStep(payment)
-
-	// We expect stepExit and an error to be returned.
-	require.Equal(t, stepExit, step)
-	require.ErrorIs(t, err, ErrPaymentLifecycleExiting)
-}
-
-// TestDecideNextStepHandleAttemptResultSucceed checks the method
-// `decideNextStep` behaves as expected when successfully handled the attempt
-// result.
-func TestDecideNextStepHandleAttemptResultSucceed(t *testing.T) {
-	t.Parallel()
-
-	// Create a test paymentLifecycle.
-	p, m := newTestPaymentLifecycle(t)
-
-	// Mock the clock to return a current time.
-	m.clock.On("Now").Return(time.Now())
-
-	// Make a mock payment.
-	payment := &mockMPPayment{}
-	defer payment.AssertExpectations(t)
-
-	// Mock the method AllowMoreAttempts to return false.
-	payment.On("AllowMoreAttempts").Return(false, nil).Once()
-
-	// Mock the method NeedWaitAttempts to wait for results.
-	payment.On("NeedWaitAttempts").Return(true, nil).Once()
-
-	paymentAmt := 10_000
-	preimage := lntypes.Preimage{1}
-	attempt := makeSettledAttempt(t, paymentAmt, preimage)
-
-	// Create a result that contains a preimage.
-	result := &htlcswitch.PaymentResult{
-		Preimage: preimage,
+		// Check the payment's methods are called as expected.
+		payment.AssertExpectations(t)
 	}
-
-	// Create a switch result and send it to the `resultCollected`` chan.
-	r := &switchResult{
-		attempt: attempt,
-		result:  result,
-	}
-	p.resultCollected <- r
-
-	// We now mock the behavior of `handleAttemptResult` - we are not
-	// testing this method's behavior here, so we simply mock it to return
-	// no error.
-	//
-	// Since the result doesn't contain an error, `ReportPaymentSuccess`
-	// should be called.
-	m.missionControl.On("ReportPaymentSuccess", mock.Anything,
-		mock.Anything).Return(nil).Once()
-
-	// The settled htlc should be returned from `SettleAttempt`.
-	m.control.On("SettleAttempt", mock.Anything, mock.Anything,
-		mock.Anything).Return(attempt, nil).Once()
-
-	// Call the method under test.
-	step, err := p.decideNextStep(payment)
-
-	// We expect stepSkip and no error to be returned.
-	require.Equal(t, stepSkip, step)
-	require.NoError(t, err)
-}
-
-// TestDecideNextStepHandleAttemptResultFail checks the method `decideNextStep`
-// behaves as expected when it fails to handle the attempt result.
-func TestDecideNextStepHandleAttemptResultFail(t *testing.T) {
-	t.Parallel()
-
-	// Create a test paymentLifecycle.
-	p, m := newTestPaymentLifecycle(t)
-
-	// Mock the clock to return a current time.
-	m.clock.On("Now").Return(time.Now())
-
-	// Make a mock payment.
-	payment := &mockMPPayment{}
-	defer payment.AssertExpectations(t)
-
-	// Mock the method AllowMoreAttempts to return false.
-	payment.On("AllowMoreAttempts").Return(false, nil).Once()
-
-	// Mock the method NeedWaitAttempts to wait for results.
-	payment.On("NeedWaitAttempts").Return(true, nil).Once()
-
-	paymentAmt := 10_000
-	preimage := lntypes.Preimage{1}
-	attempt := makeSettledAttempt(t, paymentAmt, preimage)
-
-	// Create a result that contains a preimage.
-	result := &htlcswitch.PaymentResult{
-		Preimage: preimage,
-	}
-
-	// Create a switch result and send it to the `resultCollected`` chan.
-	r := &switchResult{
-		attempt: attempt,
-		result:  result,
-	}
-	p.resultCollected <- r
-
-	// We now mock the behavior of `handleAttemptResult` - we are not
-	// testing this method's behavior here, so we simply mock it to return
-	// an error.
-	//
-	// Since the result doesn't contain an error, `ReportPaymentSuccess`
-	// should be called.
-	m.missionControl.On("ReportPaymentSuccess",
-		mock.Anything, mock.Anything).Return(nil).Once()
-
-	// Mock SettleAttempt to return an error.
-	m.control.On("SettleAttempt", mock.Anything, mock.Anything,
-		mock.Anything).Return(attempt, errDummy).Once()
-
-	// Call the method under test.
-	step, err := p.decideNextStep(payment)
-
-	// We expect stepExit and the above error to be returned.
-	require.Equal(t, stepExit, step)
-	require.ErrorIs(t, err, errDummy)
 }
 
 // TestResumePaymentFailOnFetchPayment checks when we fail to fetch the
@@ -1448,6 +1303,11 @@ func TestCollectResultExitOnErr(t *testing.T) {
 	paymentAmt := 10_000
 	attempt := makeFailedAttempt(t, paymentAmt)
 
+	// Mock shardTracker to return the payment hash.
+	m.shardTracker.On("GetHash",
+		attempt.AttemptID,
+	).Return(p.identifier, nil).Once()
+
 	// Mock the htlcswitch to return a dummy error.
 	m.payer.On("GetAttemptResult",
 		attempt.AttemptID, p.identifier, mock.Anything,
@@ -1472,7 +1332,7 @@ func TestCollectResultExitOnErr(t *testing.T) {
 	m.clock.On("Now").Return(time.Now())
 
 	// Now call the method under test.
-	result, err := p.collectAndHandleResult(attempt)
+	result, err := p.collectResult(attempt)
 	require.ErrorIs(t, err, errDummy, "expected dummy error")
 	require.Nil(t, result, "expected nil attempt")
 }
@@ -1488,6 +1348,11 @@ func TestCollectResultExitOnResultErr(t *testing.T) {
 	paymentAmt := 10_000
 	attempt := makeFailedAttempt(t, paymentAmt)
 
+	// Mock shardTracker to return the payment hash.
+	m.shardTracker.On("GetHash",
+		attempt.AttemptID,
+	).Return(p.identifier, nil).Once()
+
 	// Mock the htlcswitch to return a the result chan.
 	resultChan := make(chan *htlcswitch.PaymentResult, 1)
 	m.payer.On("GetAttemptResult",
@@ -1518,7 +1383,7 @@ func TestCollectResultExitOnResultErr(t *testing.T) {
 	m.clock.On("Now").Return(time.Now())
 
 	// Now call the method under test.
-	result, err := p.collectAndHandleResult(attempt)
+	result, err := p.collectResult(attempt)
 	require.ErrorIs(t, err, errDummy, "expected dummy error")
 	require.Nil(t, result, "expected nil attempt")
 }
@@ -1534,6 +1399,11 @@ func TestCollectResultExitOnSwitchQuit(t *testing.T) {
 	paymentAmt := 10_000
 	attempt := makeFailedAttempt(t, paymentAmt)
 
+	// Mock shardTracker to return the payment hash.
+	m.shardTracker.On("GetHash",
+		attempt.AttemptID,
+	).Return(p.identifier, nil).Once()
+
 	// Mock the htlcswitch to return a the result chan.
 	resultChan := make(chan *htlcswitch.PaymentResult, 1)
 	m.payer.On("GetAttemptResult",
@@ -1544,7 +1414,7 @@ func TestCollectResultExitOnSwitchQuit(t *testing.T) {
 	})
 
 	// Now call the method under test.
-	result, err := p.collectAndHandleResult(attempt)
+	result, err := p.collectResult(attempt)
 	require.ErrorIs(t, err, htlcswitch.ErrSwitchExiting,
 		"expected switch exit")
 	require.Nil(t, result, "expected nil attempt")
@@ -1561,6 +1431,11 @@ func TestCollectResultExitOnRouterQuit(t *testing.T) {
 	paymentAmt := 10_000
 	attempt := makeFailedAttempt(t, paymentAmt)
 
+	// Mock shardTracker to return the payment hash.
+	m.shardTracker.On("GetHash",
+		attempt.AttemptID,
+	).Return(p.identifier, nil).Once()
+
 	// Mock the htlcswitch to return a the result chan.
 	resultChan := make(chan *htlcswitch.PaymentResult, 1)
 	m.payer.On("GetAttemptResult",
@@ -1571,7 +1446,7 @@ func TestCollectResultExitOnRouterQuit(t *testing.T) {
 	})
 
 	// Now call the method under test.
-	result, err := p.collectAndHandleResult(attempt)
+	result, err := p.collectResult(attempt)
 	require.ErrorIs(t, err, ErrRouterShuttingDown, "expected router exit")
 	require.Nil(t, result, "expected nil attempt")
 }
@@ -1587,6 +1462,11 @@ func TestCollectResultExitOnLifecycleQuit(t *testing.T) {
 	paymentAmt := 10_000
 	attempt := makeFailedAttempt(t, paymentAmt)
 
+	// Mock shardTracker to return the payment hash.
+	m.shardTracker.On("GetHash",
+		attempt.AttemptID,
+	).Return(p.identifier, nil).Once()
+
 	// Mock the htlcswitch to return a the result chan.
 	resultChan := make(chan *htlcswitch.PaymentResult, 1)
 	m.payer.On("GetAttemptResult",
@@ -1597,7 +1477,7 @@ func TestCollectResultExitOnLifecycleQuit(t *testing.T) {
 	})
 
 	// Now call the method under test.
-	result, err := p.collectAndHandleResult(attempt)
+	result, err := p.collectResult(attempt)
 	require.ErrorIs(t, err, ErrPaymentLifecycleExiting,
 		"expected lifecycle exit")
 	require.Nil(t, result, "expected nil attempt")
@@ -1615,6 +1495,11 @@ func TestCollectResultExitOnSettleErr(t *testing.T) {
 	preimage := lntypes.Preimage{1}
 	attempt := makeSettledAttempt(t, paymentAmt, preimage)
 
+	// Mock shardTracker to return the payment hash.
+	m.shardTracker.On("GetHash",
+		attempt.AttemptID,
+	).Return(p.identifier, nil).Once()
+
 	// Mock the htlcswitch to return a the result chan.
 	resultChan := make(chan *htlcswitch.PaymentResult, 1)
 	m.payer.On("GetAttemptResult",
@@ -1641,7 +1526,7 @@ func TestCollectResultExitOnSettleErr(t *testing.T) {
 	m.clock.On("Now").Return(time.Now())
 
 	// Now call the method under test.
-	result, err := p.collectAndHandleResult(attempt)
+	result, err := p.collectResult(attempt)
 	require.ErrorIs(t, err, errDummy, "expected settle error")
 	require.Nil(t, result, "expected nil attempt")
 }
@@ -1657,6 +1542,11 @@ func TestCollectResultSuccess(t *testing.T) {
 	preimage := lntypes.Preimage{1}
 	attempt := makeSettledAttempt(t, paymentAmt, preimage)
 
+	// Mock shardTracker to return the payment hash.
+	m.shardTracker.On("GetHash",
+		attempt.AttemptID,
+	).Return(p.identifier, nil).Once()
+
 	// Mock the htlcswitch to return a the result chan.
 	resultChan := make(chan *htlcswitch.PaymentResult, 1)
 	m.payer.On("GetAttemptResult",
@@ -1683,7 +1573,7 @@ func TestCollectResultSuccess(t *testing.T) {
 	m.clock.On("Now").Return(time.Now())
 
 	// Now call the method under test.
-	result, err := p.collectAndHandleResult(attempt)
+	result, err := p.collectResult(attempt)
 	require.NoError(t, err, "expected no error")
 	require.Equal(t, preimage, result.attempt.Settle.Preimage,
 		"preimage mismatch")
@@ -1700,10 +1590,10 @@ func TestCollectResultAsyncSuccess(t *testing.T) {
 	preimage := lntypes.Preimage{1}
 	attempt := makeSettledAttempt(t, paymentAmt, preimage)
 
-	// Create a mock result returned from the switch.
-	result := &htlcswitch.PaymentResult{
-		Preimage: preimage,
-	}
+	// Mock shardTracker to return the payment hash.
+	m.shardTracker.On("GetHash",
+		attempt.AttemptID,
+	).Return(p.identifier, nil).Once()
 
 	// Mock the htlcswitch to return a the result chan.
 	resultChan := make(chan *htlcswitch.PaymentResult, 1)
@@ -1711,86 +1601,13 @@ func TestCollectResultAsyncSuccess(t *testing.T) {
 		attempt.AttemptID, p.identifier, mock.Anything,
 	).Return(resultChan, nil).Once().Run(func(args mock.Arguments) {
 		// Send the preimage to the result chan.
-		resultChan <- result
+		resultChan <- &htlcswitch.PaymentResult{
+			Preimage: preimage,
+		}
 	})
 
-	// Now call the method under test.
-	p.collectResultAsync(attempt)
-
-	var r *switchResult
-
-	// Assert the result is returned within 5 seconds.
-	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, attempt, r.attempt)
-	require.Equal(t, result, r.result)
-}
-
-// TestHandleAttemptResultWithError checks that when the `Error` field in the
-// result is not nil, it's properly handled by `handleAttemptResult`.
-func TestHandleAttemptResultWithError(t *testing.T) {
-	t.Parallel()
-
-	// Create a test paymentLifecycle with the initial two calls mocked.
-	p, m := newTestPaymentLifecycle(t)
-
-	paymentAmt := 10_000
-	preimage := lntypes.Preimage{1}
-	attempt := makeSettledAttempt(t, paymentAmt, preimage)
-
-	// Create a result that contains an error.
-	//
-	// NOTE: The error is chosen so we can quickly exit `handleSwitchErr`
-	// since we are not testing its behavior here.
-	result := &htlcswitch.PaymentResult{
-		Error: htlcswitch.ErrPaymentIDNotFound,
-	}
-
-	// The above error will end up being handled by `handleSwitchErr`, in
-	// which we'd cancel the shard and fail the attempt.
-	//
-	// `CancelShard` should be called with the attemptID.
-	m.shardTracker.On("CancelShard", attempt.AttemptID).Return(nil).Once()
-
-	// Mock `FailAttempt` to return a dummy error.
-	m.control.On("FailAttempt",
-		p.identifier, attempt.AttemptID, mock.Anything,
-	).Return(nil, errDummy).Once()
-
-	// Mock the clock to return a current time.
-	m.clock.On("Now").Return(time.Now())
-
-	// Call the method under test and expect the dummy error to be
-	// returned.
-	attemptResult, err := p.handleAttemptResult(attempt, result)
-	require.ErrorIs(t, err, errDummy, "expected fail error")
-	require.Nil(t, attemptResult, "expected nil attempt result")
-}
-
-// TestHandleAttemptResultSuccess checks that when the result contains no error
-// but a preimage, it's handled correctly by `handleAttemptResult`.
-func TestHandleAttemptResultSuccess(t *testing.T) {
-	t.Parallel()
-
-	// Create a test paymentLifecycle with the initial two calls mocked.
-	p, m := newTestPaymentLifecycle(t)
-
-	paymentAmt := 10_000
-	preimage := lntypes.Preimage{1}
-	attempt := makeSettledAttempt(t, paymentAmt, preimage)
-
-	// Create a result that contains a preimage.
-	result := &htlcswitch.PaymentResult{
-		Preimage: preimage,
-	}
-
-	// Since the result doesn't contain an error, `ReportPaymentSuccess`
-	// should be called.
+	// Once the result is received, `ReportPaymentSuccess` should be
+	// called.
 	m.missionControl.On("ReportPaymentSuccess",
 		attempt.AttemptID, &attempt.Route,
 	).Return(nil).Once()
@@ -1803,9 +1620,17 @@ func TestHandleAttemptResultSuccess(t *testing.T) {
 	// Mock the clock to return a current time.
 	m.clock.On("Now").Return(time.Now())
 
-	// Call the method under test and expect the dummy error to be
-	// returned.
-	attemptResult, err := p.handleAttemptResult(attempt, result)
+	// Now call the method under test.
+	p.collectResultAsync(attempt)
+
+	// Assert the result is returned within 5 seconds.
+	var err error
+	waitErr := wait.NoError(func() error {
+		err = <-p.resultCollected
+		return nil
+	}, testTimeout)
+	require.NoError(t, waitErr, "timeout waiting for result")
+
+	// Assert that a nil error is received.
 	require.NoError(t, err, "expected no error")
-	require.Equal(t, attempt, attemptResult.attempt)
 }
diff --git a/routing/router_test.go b/routing/router_test.go
index 543f3b000..22c9d14e5 100644
--- a/routing/router_test.go
+++ b/routing/router_test.go
@@ -48,6 +48,13 @@ var (
 
 	testFeatures = lnwire.NewFeatureVector(nil, lnwire.Features)
 
+	testHash = [32]byte{
+		0xb7, 0x94, 0x38, 0x5f, 0x2d, 0x1e, 0xf7, 0xab,
+		0x4d, 0x92, 0x73, 0xd1, 0x90, 0x63, 0x81, 0xb4,
+		0x4f, 0x2f, 0x6f, 0x25, 0x88, 0xa3, 0xef, 0xb9,
+		0x6a, 0x49, 0x18, 0x83, 0x31, 0x98, 0x47, 0x53,
+	}
+
 	testTime = time.Date(2018, time.January, 9, 14, 00, 00, 0, time.UTC)
 
 	priv1, _    = btcec.NewPrivateKey()
@@ -1228,6 +1235,18 @@ func TestFindPathFeeWeighting(t *testing.T) {
 	require.Equal(t, ctx.aliases["luoji"], path[0].policy.ToNodePubKey())
 }
 
+// TestEmptyRoutesGenerateSphinxPacket tests that the generateSphinxPacket
+// function is able to gracefully handle being passed a nil set of hops for the
+// route by the caller.
+func TestEmptyRoutesGenerateSphinxPacket(t *testing.T) {
+	t.Parallel()
+
+	sessionKey, _ := btcec.NewPrivateKey()
+	emptyRoute := &route.Route{}
+	_, _, err := generateSphinxPacket(emptyRoute, testHash[:], sessionKey)
+	require.ErrorIs(t, err, route.ErrNoRouteHopsProvided)
+}
+
 // TestUnknownErrorSource tests that if the source of an error is unknown, all
 // edges along the route will be pruned.
 func TestUnknownErrorSource(t *testing.T) {