Repository: lightningnetwork/lnd
Pull request #8641 (2024-04-11 13:00:08)

[5/5]: lnwallet: extensions to aux leaf store to integrate custom channels
Depends on https://github.com/lightningnetwork/lnd/pull/8632.

Extends the aux leaf store with more required parameters.

Link to all PRs in the saga:
 - 1/5: https://github.com/lightningnetwork/lnd/pull/8683
 - 2/5: https://github.com/lightningnetwork/lnd/pull/8684
 - 3/5: https://github.com/lightningnetwork/lnd/pull/8622
 - 4/5: https://github.com/lightningnetwork/lnd/pull/8632
 - 5/5: https://github.com/lightningnetwork/lnd/pull/8641
 - 6/5: https://github.com/lightningnetwork/lnd/pull/8771


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

> [!IMPORTANT]
> ## Auto Review Skipped
> 
> Auto reviews are disabled on this repository.
> 
> 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.
> 

<!-- 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>.`
	- `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 generate interesting stats about this repository and render them as a table.`
	- `@coderabbitai show all the console.log statements in this repository.`
	- `@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.`

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 as PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger a review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai help` to get help.


Additionally, you can add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.

### CodeRabbit Configration 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://coderabbit.ai/docs) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](https://discord.com/invite/GsXnASn26c) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
guggero: This PR should now contain everything from the PoC branch and is ready for review.

Patch:
diff --git a/.golangci.yml b/.golangci.yml
index 2d6f539c5..d9d7323c2 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -254,8 +254,8 @@ issues:
         - forbidigo
         - godot
 
-    # Allow fmt.Printf() in lncli.
-    - path: cmd/lncli/*
+    # Allow fmt.Printf() in commands.
+    - path: cmd/commands/*
       linters:
         - forbidigo
 
diff --git a/channeldb/channel.go b/channeldb/channel.go
index c9e8a5885..4e0c2d2d9 100644
--- a/channeldb/channel.go
+++ b/channeldb/channel.go
@@ -2750,6 +2750,8 @@ func (h *HTLC) Copy() HTLC {
 	copy(clone.Signature[:], h.Signature)
 	copy(clone.RHash[:], h.RHash[:])
 	copy(clone.ExtraData, h.ExtraData)
+	clone.BlindingPoint = h.BlindingPoint
+	clone.CustomRecords = h.CustomRecords.Copy()
 
 	return clone
 }
diff --git a/cmd/lncli/arg_parse.go b/cmd/commands/arg_parse.go
similarity index 90%
rename from cmd/lncli/arg_parse.go
rename to cmd/commands/arg_parse.go
index 49d165d55..1d8bbe198 100644
--- a/cmd/lncli/arg_parse.go
+++ b/cmd/commands/arg_parse.go
@@ -1,4 +1,4 @@
-package main
+package commands
 
 import (
 	"regexp"
@@ -42,7 +42,7 @@ func parseTime(s string, base time.Time) (uint64, error) {
 
 var lightningPrefix = "lightning:"
 
-// stripPrefix removes accidentally copied 'lightning:' prefix.
-func stripPrefix(s string) string {
+// StripPrefix removes accidentally copied 'lightning:' prefix.
+func StripPrefix(s string) string {
 	return strings.TrimSpace(strings.TrimPrefix(s, lightningPrefix))
 }
diff --git a/cmd/lncli/autopilotrpc_active.go b/cmd/commands/autopilotrpc_active.go
similarity index 99%
rename from cmd/lncli/autopilotrpc_active.go
rename to cmd/commands/autopilotrpc_active.go
index 961e85994..212ef4579 100644
--- a/cmd/lncli/autopilotrpc_active.go
+++ b/cmd/commands/autopilotrpc_active.go
@@ -1,7 +1,7 @@
 //go:build autopilotrpc
 // +build autopilotrpc
 
-package main
+package commands
 
 import (
 	"github.com/lightningnetwork/lnd/lnrpc/autopilotrpc"
diff --git a/cmd/lncli/autopilotrpc_default.go b/cmd/commands/autopilotrpc_default.go
similarity index 92%
rename from cmd/lncli/autopilotrpc_default.go
rename to cmd/commands/autopilotrpc_default.go
index 7fb885217..393b6f124 100644
--- a/cmd/lncli/autopilotrpc_default.go
+++ b/cmd/commands/autopilotrpc_default.go
@@ -1,7 +1,7 @@
 //go:build !autopilotrpc
 // +build !autopilotrpc
 
-package main
+package commands
 
 import "github.com/urfave/cli"
 
diff --git a/cmd/lncli/chainrpc_active.go b/cmd/commands/chainrpc_active.go
similarity index 99%
rename from cmd/lncli/chainrpc_active.go
rename to cmd/commands/chainrpc_active.go
index 48946e0d5..0f1f8b612 100644
--- a/cmd/lncli/chainrpc_active.go
+++ b/cmd/commands/chainrpc_active.go
@@ -1,7 +1,7 @@
 //go:build chainrpc
 // +build chainrpc
 
-package main
+package commands
 
 import (
 	"bytes"
diff --git a/cmd/lncli/chainrpc_default.go b/cmd/commands/chainrpc_default.go
similarity index 91%
rename from cmd/lncli/chainrpc_default.go
rename to cmd/commands/chainrpc_default.go
index fa1ea99e2..28440a839 100644
--- a/cmd/lncli/chainrpc_default.go
+++ b/cmd/commands/chainrpc_default.go
@@ -1,7 +1,7 @@
 //go:build !chainrpc
 // +build !chainrpc
 
-package main
+package commands
 
 import "github.com/urfave/cli"
 
diff --git a/cmd/lncli/cmd_custom.go b/cmd/commands/cmd_custom.go
similarity index 98%
rename from cmd/lncli/cmd_custom.go
rename to cmd/commands/cmd_custom.go
index 7ff5d8a71..728d70bd3 100644
--- a/cmd/lncli/cmd_custom.go
+++ b/cmd/commands/cmd_custom.go
@@ -1,4 +1,4 @@
-package main
+package commands
 
 import (
 	"encoding/hex"
diff --git a/cmd/lncli/cmd_debug.go b/cmd/commands/cmd_debug.go
similarity index 99%
rename from cmd/lncli/cmd_debug.go
rename to cmd/commands/cmd_debug.go
index 758bff576..37024f5ec 100644
--- a/cmd/lncli/cmd_debug.go
+++ b/cmd/commands/cmd_debug.go
@@ -1,4 +1,4 @@
-package main
+package commands
 
 import (
 	"bytes"
diff --git a/cmd/lncli/cmd_import_mission_control.go b/cmd/commands/cmd_import_mission_control.go
similarity index 99%
rename from cmd/lncli/cmd_import_mission_control.go
rename to cmd/commands/cmd_import_mission_control.go
index 23935753d..b4aa19931 100644
--- a/cmd/lncli/cmd_import_mission_control.go
+++ b/cmd/commands/cmd_import_mission_control.go
@@ -1,4 +1,4 @@
-package main
+package commands
 
 import (
 	"context"
diff --git a/cmd/lncli/cmd_invoice.go b/cmd/commands/cmd_invoice.go
similarity index 98%
rename from cmd/lncli/cmd_invoice.go
rename to cmd/commands/cmd_invoice.go
index 4c60294ca..7801c4dcf 100644
--- a/cmd/lncli/cmd_invoice.go
+++ b/cmd/commands/cmd_invoice.go
@@ -1,4 +1,4 @@
-package main
+package commands
 
 import (
 	"encoding/hex"
@@ -9,7 +9,7 @@ import (
 	"github.com/urfave/cli"
 )
 
-var addInvoiceCommand = cli.Command{
+var AddInvoiceCommand = cli.Command{
 	Name:     "addinvoice",
 	Category: "Invoices",
 	Usage:    "Add a new invoice.",
@@ -307,7 +307,7 @@ func decodePayReq(ctx *cli.Context) error {
 	}
 
 	resp, err := client.DecodePayReq(ctxc, &lnrpc.PayReqString{
-		PayReq: stripPrefix(payreq),
+		PayReq: StripPrefix(payreq),
 	})
 	if err != nil {
 		return err
diff --git a/cmd/lncli/cmd_macaroon.go b/cmd/commands/cmd_macaroon.go
similarity index 99%
rename from cmd/lncli/cmd_macaroon.go
rename to cmd/commands/cmd_macaroon.go
index 54e03057a..ed299182f 100644
--- a/cmd/lncli/cmd_macaroon.go
+++ b/cmd/commands/cmd_macaroon.go
@@ -1,4 +1,4 @@
-package main
+package commands
 
 import (
 	"bytes"
diff --git a/cmd/lncli/cmd_mission_control.go b/cmd/commands/cmd_mission_control.go
similarity index 99%
rename from cmd/lncli/cmd_mission_control.go
rename to cmd/commands/cmd_mission_control.go
index 323acdff6..fe4acb25c 100644
--- a/cmd/lncli/cmd_mission_control.go
+++ b/cmd/commands/cmd_mission_control.go
@@ -1,4 +1,4 @@
-package main
+package commands
 
 import (
 	"fmt"
@@ -265,6 +265,7 @@ func setCfg(ctx *cli.Context) error {
 			Config: mcCfg.Config,
 		},
 	)
+
 	return err
 }
 
@@ -366,5 +367,6 @@ func resetMissionControl(ctx *cli.Context) error {
 
 	req := &routerrpc.ResetMissionControlRequest{}
 	_, err := client.ResetMissionControl(ctxc, req)
+
 	return err
 }
diff --git a/cmd/lncli/cmd_open_channel.go b/cmd/commands/cmd_open_channel.go
similarity index 99%
rename from cmd/lncli/cmd_open_channel.go
rename to cmd/commands/cmd_open_channel.go
index 74cb1668a..e0efd397b 100644
--- a/cmd/lncli/cmd_open_channel.go
+++ b/cmd/commands/cmd_open_channel.go
@@ -1,4 +1,4 @@
-package main
+package commands
 
 import (
 	"bytes"
diff --git a/cmd/lncli/cmd_payments.go b/cmd/commands/cmd_payments.go
similarity index 98%
rename from cmd/lncli/cmd_payments.go
rename to cmd/commands/cmd_payments.go
index 550bb8f3e..277c82653 100644
--- a/cmd/lncli/cmd_payments.go
+++ b/cmd/commands/cmd_payments.go
@@ -1,4 +1,4 @@
-package main
+package commands
 
 import (
 	"bytes"
@@ -141,8 +141,8 @@ var (
 	}
 )
 
-// paymentFlags returns common flags for sendpayment and payinvoice.
-func paymentFlags() []cli.Flag {
+// PaymentFlags returns common flags for sendpayment and payinvoice.
+func PaymentFlags() []cli.Flag {
 	return []cli.Flag{
 		cli.StringFlag{
 			Name:  "pay_req",
@@ -190,7 +190,7 @@ func paymentFlags() []cli.Flag {
 	}
 }
 
-var sendPaymentCommand = cli.Command{
+var SendPaymentCommand = cli.Command{
 	Name:     "sendpayment",
 	Category: "Payments",
 	Usage:    "Send a payment over lightning.",
@@ -214,7 +214,7 @@ var sendPaymentCommand = cli.Command{
 	`,
 	ArgsUsage: "dest amt payment_hash final_cltv_delta pay_addr | " +
 		"--pay_req=R [--pay_addr=H]",
-	Flags: append(paymentFlags(),
+	Flags: append(PaymentFlags(),
 		cli.StringFlag{
 			Name: "dest, d",
 			Usage: "the compressed identity pubkey of the " +
@@ -241,7 +241,7 @@ var sendPaymentCommand = cli.Command{
 			Usage: "will generate a pre-image and encode it in the sphinx packet, a dest must be set [experimental]",
 		},
 	),
-	Action: sendPayment,
+	Action: SendPayment,
 }
 
 // retrieveFeeLimit retrieves the fee limit based on the different fee limit
@@ -312,7 +312,7 @@ func parsePayAddr(ctx *cli.Context, args cli.Args) ([]byte, error) {
 	return payAddr, nil
 }
 
-func sendPayment(ctx *cli.Context) error {
+func SendPayment(ctx *cli.Context) error {
 	// Show command help if no arguments provided
 	if ctx.NArg() == 0 && ctx.NumFlags() == 0 {
 		_ = cli.ShowCommandHelp(ctx, "sendpayment")
@@ -325,7 +325,7 @@ func sendPayment(ctx *cli.Context) error {
 	// details of the payment are encoded within the request.
 	if ctx.IsSet("pay_req") {
 		req := &routerrpc.SendPaymentRequest{
-			PaymentRequest:    stripPrefix(ctx.String("pay_req")),
+			PaymentRequest:    StripPrefix(ctx.String("pay_req")),
 			Amt:               ctx.Int64("amt"),
 			DestCustomRecords: make(map[uint64][]byte),
 		}
@@ -343,7 +343,7 @@ func sendPayment(ctx *cli.Context) error {
 
 		req.PaymentAddr = payAddr
 
-		return sendPaymentRequest(ctx, req)
+		return SendPaymentRequest(ctx, req)
 	}
 
 	var (
@@ -451,10 +451,10 @@ func sendPayment(ctx *cli.Context) error {
 
 	req.PaymentAddr = payAddr
 
-	return sendPaymentRequest(ctx, req)
+	return SendPaymentRequest(ctx, req)
 }
 
-func sendPaymentRequest(ctx *cli.Context,
+func SendPaymentRequest(ctx *cli.Context,
 	req *routerrpc.SendPaymentRequest) error {
 
 	ctxc := getContext()
@@ -592,7 +592,7 @@ func sendPaymentRequest(ctx *cli.Context,
 		return err
 	}
 
-	finalState, err := printLivePayment(
+	finalState, err := PrintLivePayment(
 		ctxc, stream, client, printJSON,
 	)
 	if err != nil {
@@ -652,15 +652,15 @@ func trackPayment(ctx *cli.Context) error {
 	}
 
 	client := lnrpc.NewLightningClient(conn)
-	_, err = printLivePayment(ctxc, stream, client, ctx.Bool(jsonFlag.Name))
+	_, err = PrintLivePayment(ctxc, stream, client, ctx.Bool(jsonFlag.Name))
 	return err
 }
 
-// printLivePayment receives payment updates from the given stream and either
+// PrintLivePayment receives payment updates from the given stream and either
 // outputs them as json or as a more user-friendly formatted table. The table
 // option uses terminal control codes to rewrite the output. This call
 // terminates when the payment reaches a final state.
-func printLivePayment(ctxc context.Context,
+func PrintLivePayment(ctxc context.Context,
 	stream routerrpc.Router_TrackPaymentV2Client,
 	client lnrpc.LightningClient, json bool) (*lnrpc.Payment, error) {
 
@@ -859,7 +859,7 @@ var payInvoiceCommand = cli.Command{
 	This command is a shortcut for 'sendpayment --pay_req='.
 	`,
 	ArgsUsage: "pay_req",
-	Flags: append(paymentFlags(),
+	Flags: append(PaymentFlags(),
 		cli.Int64Flag{
 			Name: "amt",
 			Usage: "(optional) number of satoshis to fulfill the " +
@@ -883,12 +883,12 @@ func payInvoice(ctx *cli.Context) error {
 	}
 
 	req := &routerrpc.SendPaymentRequest{
-		PaymentRequest:    stripPrefix(payReq),
+		PaymentRequest:    StripPrefix(payReq),
 		Amt:               ctx.Int64("amt"),
 		DestCustomRecords: make(map[uint64][]byte),
 	}
 
-	return sendPaymentRequest(ctx, req)
+	return SendPaymentRequest(ctx, req)
 }
 
 var sendToRouteCommand = cli.Command{
@@ -1870,7 +1870,7 @@ func estimateRouteFee(ctx *cli.Context) error {
 		req.AmtSat = amtSat
 
 	case ctx.IsSet("pay_req"):
-		req.PaymentRequest = stripPrefix(ctx.String("pay_req"))
+		req.PaymentRequest = StripPrefix(ctx.String("pay_req"))
 		if ctx.IsSet("timeout") {
 			req.Timeout = uint32(ctx.Duration("timeout").Seconds())
 		}
diff --git a/cmd/lncli/cmd_profile.go b/cmd/commands/cmd_profile.go
similarity index 99%
rename from cmd/lncli/cmd_profile.go
rename to cmd/commands/cmd_profile.go
index 21666be96..45bd69041 100644
--- a/cmd/lncli/cmd_profile.go
+++ b/cmd/commands/cmd_profile.go
@@ -1,4 +1,4 @@
-package main
+package commands
 
 import (
 	"fmt"
diff --git a/cmd/lncli/cmd_state.go b/cmd/commands/cmd_state.go
similarity index 98%
rename from cmd/lncli/cmd_state.go
rename to cmd/commands/cmd_state.go
index afca13e9d..c2522b721 100644
--- a/cmd/lncli/cmd_state.go
+++ b/cmd/commands/cmd_state.go
@@ -1,4 +1,4 @@
-package main
+package commands
 
 import (
 	"context"
diff --git a/cmd/lncli/cmd_update_chan_status.go b/cmd/commands/cmd_update_chan_status.go
similarity index 99%
rename from cmd/lncli/cmd_update_chan_status.go
rename to cmd/commands/cmd_update_chan_status.go
index 23c22f0b1..3525f7c5c 100644
--- a/cmd/lncli/cmd_update_chan_status.go
+++ b/cmd/commands/cmd_update_chan_status.go
@@ -1,4 +1,4 @@
-package main
+package commands
 
 import (
 	"errors"
diff --git a/cmd/lncli/cmd_version.go b/cmd/commands/cmd_version.go
similarity index 98%
rename from cmd/lncli/cmd_version.go
rename to cmd/commands/cmd_version.go
index 99cc72995..9e7a2b077 100644
--- a/cmd/lncli/cmd_version.go
+++ b/cmd/commands/cmd_version.go
@@ -1,4 +1,4 @@
-package main
+package commands
 
 import (
 	"fmt"
diff --git a/cmd/lncli/cmd_walletunlocker.go b/cmd/commands/cmd_walletunlocker.go
similarity index 99%
rename from cmd/lncli/cmd_walletunlocker.go
rename to cmd/commands/cmd_walletunlocker.go
index 9227d10d4..8a9393adc 100644
--- a/cmd/lncli/cmd_walletunlocker.go
+++ b/cmd/commands/cmd_walletunlocker.go
@@ -1,4 +1,4 @@
-package main
+package commands
 
 import (
 	"bufio"
diff --git a/cmd/lncli/commands.go b/cmd/commands/commands.go
similarity index 97%
rename from cmd/lncli/commands.go
rename to cmd/commands/commands.go
index 5ffca5c73..5fbe1056c 100644
--- a/cmd/lncli/commands.go
+++ b/cmd/commands/commands.go
@@ -1,4 +1,4 @@
-package main
+package commands
 
 import (
 	"bufio"
@@ -12,6 +12,7 @@ import (
 	"io/ioutil"
 	"math"
 	"os"
+	"regexp"
 	"strconv"
 	"strings"
 	"sync"
@@ -42,8 +43,46 @@ const (
 	defaultUtxoMinConf = 1
 )
 
-var errBadChanPoint = errors.New("expecting chan_point to be in format of: " +
-	"txid:index")
+var (
+	errBadChanPoint = errors.New(
+		"expecting chan_point to be in format of: txid:index",
+	)
+
+	customDataPattern = regexp.MustCompile(
+		`"custom_channel_data":\s*"([0-9a-z]+)"`,
+	)
+)
+
+// replaceCustomData replaces the custom channel data hex string with the
+// decoded custom channel data in the JSON response.
+func replaceCustomData(jsonBytes []byte) ([]byte, error) {
+	if customDataPattern.Match(jsonBytes) {
+		jsonBytes = customDataPattern.ReplaceAllFunc(
+			jsonBytes, func(match []byte) []byte {
+				encoded := customDataPattern.FindStringSubmatch(
+					string(match),
+				)[1]
+				decoded, err := hex.DecodeString(encoded)
+				if err != nil {
+					return match
+				}
+
+				return []byte("\"custom_channel_data\":" +
+					string(decoded))
+			},
+		)
+
+		var buf bytes.Buffer
+		err := json.Indent(&buf, jsonBytes, "", "    ")
+		if err != nil {
+			return nil, err
+		}
+
+		jsonBytes = buf.Bytes()
+	}
+
+	return jsonBytes, nil
+}
 
 func getContext() context.Context {
 	shutdownInterceptor, err := signal.Intercept()
@@ -67,9 +106,9 @@ func printJSON(resp interface{}) {
 	}
 
 	var out bytes.Buffer
-	json.Indent(&out, b, "", "\t")
-	out.WriteString("\n")
-	out.WriteTo(os.Stdout)
+	_ = json.Indent(&out, b, "", "    ")
+	_, _ = out.WriteString("\n")
+	_, _ = out.WriteTo(os.Stdout)
 }
 
 func printRespJSON(resp proto.Message) {
@@ -79,7 +118,13 @@ func printRespJSON(resp proto.Message) {
 		return
 	}
 
-	fmt.Printf("%s\n", jsonBytes)
+	jsonBytesReplaced, err := replaceCustomData(jsonBytes)
+	if err != nil {
+		fmt.Println("unable to replace custom data: ", err)
+		jsonBytesReplaced = jsonBytes
+	}
+
+	fmt.Printf("%s\n", jsonBytesReplaced)
 }
 
 // actionDecorator is used to add additional information and error handling
@@ -1420,15 +1465,15 @@ func walletBalance(ctx *cli.Context) error {
 	return nil
 }
 
-var channelBalanceCommand = cli.Command{
+var ChannelBalanceCommand = cli.Command{
 	Name:     "channelbalance",
 	Category: "Channels",
 	Usage: "Returns the sum of the total available channel balance across " +
 		"all open channels.",
-	Action: actionDecorator(channelBalance),
+	Action: actionDecorator(ChannelBalance),
 }
 
-func channelBalance(ctx *cli.Context) error {
+func ChannelBalance(ctx *cli.Context) error {
 	ctxc := getContext()
 	client, cleanUp := getClient(ctx)
 	defer cleanUp()
@@ -1553,7 +1598,7 @@ func pendingChannels(ctx *cli.Context) error {
 	return nil
 }
 
-var listChannelsCommand = cli.Command{
+var ListChannelsCommand = cli.Command{
 	Name:     "listchannels",
 	Category: "Channels",
 	Usage:    "List all open channels.",
@@ -1586,7 +1631,7 @@ var listChannelsCommand = cli.Command{
 				"order to improve performance",
 		},
 	},
-	Action: actionDecorator(listChannels),
+	Action: actionDecorator(ListChannels),
 }
 
 var listAliasesCommand = cli.Command{
@@ -1594,10 +1639,10 @@ var listAliasesCommand = cli.Command{
 	Category: "Channels",
 	Usage:    "List all aliases.",
 	Flags:    []cli.Flag{},
-	Action:   actionDecorator(listaliases),
+	Action:   actionDecorator(listAliases),
 }
 
-func listaliases(ctx *cli.Context) error {
+func listAliases(ctx *cli.Context) error {
 	ctxc := getContext()
 	client, cleanUp := getClient(ctx)
 	defer cleanUp()
@@ -1614,7 +1659,7 @@ func listaliases(ctx *cli.Context) error {
 	return nil
 }
 
-func listChannels(ctx *cli.Context) error {
+func ListChannels(ctx *cli.Context) error {
 	ctxc := getContext()
 	client, cleanUp := getClient(ctx)
 	defer cleanUp()
diff --git a/cmd/lncli/devrpc_active.go b/cmd/commands/devrpc_active.go
similarity index 98%
rename from cmd/lncli/devrpc_active.go
rename to cmd/commands/devrpc_active.go
index da3f08a97..8d1960e46 100644
--- a/cmd/lncli/devrpc_active.go
+++ b/cmd/commands/devrpc_active.go
@@ -1,7 +1,7 @@
 //go:build dev
 // +build dev
 
-package main
+package commands
 
 import (
 	"fmt"
diff --git a/cmd/lncli/devrpc_default.go b/cmd/commands/devrpc_default.go
similarity index 90%
rename from cmd/lncli/devrpc_default.go
rename to cmd/commands/devrpc_default.go
index b9362cb42..1c5b482c3 100644
--- a/cmd/lncli/devrpc_default.go
+++ b/cmd/commands/devrpc_default.go
@@ -1,7 +1,7 @@
 //go:build !dev
 // +build !dev
 
-package main
+package commands
 
 import "github.com/urfave/cli"
 
diff --git a/cmd/lncli/invoicesrpc_active.go b/cmd/commands/invoicesrpc_active.go
similarity index 99%
rename from cmd/lncli/invoicesrpc_active.go
rename to cmd/commands/invoicesrpc_active.go
index 2ce900695..d0dbc011b 100644
--- a/cmd/lncli/invoicesrpc_active.go
+++ b/cmd/commands/invoicesrpc_active.go
@@ -1,7 +1,7 @@
 //go:build invoicesrpc
 // +build invoicesrpc
 
-package main
+package commands
 
 import (
 	"encoding/hex"
diff --git a/cmd/lncli/invoicesrpc_default.go b/cmd/commands/invoicesrpc_default.go
similarity index 92%
rename from cmd/lncli/invoicesrpc_default.go
rename to cmd/commands/invoicesrpc_default.go
index cca3c14e9..e925e55d6 100644
--- a/cmd/lncli/invoicesrpc_default.go
+++ b/cmd/commands/invoicesrpc_default.go
@@ -1,7 +1,7 @@
 //go:build !invoicesrpc
 // +build !invoicesrpc
 
-package main
+package commands
 
 import "github.com/urfave/cli"
 
diff --git a/cmd/lncli/macaroon_jar.go b/cmd/commands/macaroon_jar.go
similarity index 99%
rename from cmd/lncli/macaroon_jar.go
rename to cmd/commands/macaroon_jar.go
index f54f29a26..d3a4345b0 100644
--- a/cmd/lncli/macaroon_jar.go
+++ b/cmd/commands/macaroon_jar.go
@@ -1,4 +1,4 @@
-package main
+package commands
 
 import (
 	"encoding/base64"
diff --git a/cmd/commands/main.go b/cmd/commands/main.go
new file mode 100644
index 000000000..13ed130ed
--- /dev/null
+++ b/cmd/commands/main.go
@@ -0,0 +1,601 @@
+// Copyright (c) 2013-2017 The btcsuite developers
+// Copyright (c) 2015-2016 The Decred developers
+// Copyright (C) 2015-2022 The Lightning Network Developers
+
+package commands
+
+import (
+	"context"
+	"crypto/tls"
+	"fmt"
+	"net"
+	"os"
+	"path/filepath"
+	"strings"
+	"syscall"
+
+	"github.com/btcsuite/btcd/btcutil"
+	"github.com/btcsuite/btcd/chaincfg"
+	"github.com/lightningnetwork/lnd"
+	"github.com/lightningnetwork/lnd/build"
+	"github.com/lightningnetwork/lnd/lncfg"
+	"github.com/lightningnetwork/lnd/lnrpc"
+	"github.com/lightningnetwork/lnd/macaroons"
+	"github.com/lightningnetwork/lnd/tor"
+	"github.com/urfave/cli"
+	"golang.org/x/term"
+	"google.golang.org/grpc"
+	"google.golang.org/grpc/credentials"
+	"google.golang.org/grpc/metadata"
+)
+
+const (
+	defaultDataDir          = "data"
+	defaultChainSubDir      = "chain"
+	defaultTLSCertFilename  = "tls.cert"
+	defaultMacaroonFilename = "admin.macaroon"
+	defaultRPCPort          = "10009"
+	defaultRPCHostPort      = "localhost:" + defaultRPCPort
+
+	envVarRPCServer       = "LNCLI_RPCSERVER"
+	envVarLNDDir          = "LNCLI_LNDDIR"
+	envVarSOCKSProxy      = "LNCLI_SOCKSPROXY"
+	envVarTLSCertPath     = "LNCLI_TLSCERTPATH"
+	envVarChain           = "LNCLI_CHAIN"
+	envVarNetwork         = "LNCLI_NETWORK"
+	envVarMacaroonPath    = "LNCLI_MACAROONPATH"
+	envVarMacaroonTimeout = "LNCLI_MACAROONTIMEOUT"
+	envVarMacaroonIP      = "LNCLI_MACAROONIP"
+	envVarProfile         = "LNCLI_PROFILE"
+	envVarMacFromJar      = "LNCLI_MACFROMJAR"
+)
+
+var (
+	DefaultLndDir      = btcutil.AppDataDir("lnd", false)
+	defaultTLSCertPath = filepath.Join(
+		DefaultLndDir, defaultTLSCertFilename,
+	)
+
+	// maxMsgRecvSize is the largest message our client will receive. We
+	// set this to 200MiB atm.
+	maxMsgRecvSize = grpc.MaxCallRecvMsgSize(lnrpc.MaxGrpcMsgSize)
+)
+
+func fatal(err error) {
+	fmt.Fprintf(os.Stderr, "[lncli] %v\n", err)
+	os.Exit(1)
+}
+
+func getWalletUnlockerClient(ctx *cli.Context) (lnrpc.WalletUnlockerClient,
+	func()) {
+
+	conn := getClientConn(ctx, true)
+
+	cleanUp := func() {
+		conn.Close()
+	}
+
+	return lnrpc.NewWalletUnlockerClient(conn), cleanUp
+}
+
+func getStateServiceClient(ctx *cli.Context) (lnrpc.StateClient, func()) {
+	conn := getClientConn(ctx, true)
+
+	cleanUp := func() {
+		conn.Close()
+	}
+
+	return lnrpc.NewStateClient(conn), cleanUp
+}
+
+func getClient(ctx *cli.Context) (lnrpc.LightningClient, func()) {
+	conn := getClientConn(ctx, false)
+
+	cleanUp := func() {
+		conn.Close()
+	}
+
+	return lnrpc.NewLightningClient(conn), cleanUp
+}
+
+func getClientConn(ctx *cli.Context, skipMacaroons bool) *grpc.ClientConn {
+	// First, we'll get the selected stored profile or an ephemeral one
+	// created from the global options in the CLI context.
+	profile, err := getGlobalOptions(ctx, skipMacaroons)
+	if err != nil {
+		fatal(fmt.Errorf("could not load global options: %w", err))
+	}
+
+	// Create a dial options array.
+	opts := []grpc.DialOption{
+		grpc.WithUnaryInterceptor(
+			addMetadataUnaryInterceptor(profile.Metadata),
+		),
+		grpc.WithStreamInterceptor(
+			addMetaDataStreamInterceptor(profile.Metadata),
+		),
+	}
+
+	if profile.Insecure {
+		opts = append(opts, grpc.WithInsecure())
+	} else {
+		// Load the specified TLS certificate.
+		certPool, err := profile.cert()
+		if err != nil {
+			fatal(fmt.Errorf("could not create cert pool: %w", err))
+		}
+
+		// Build transport credentials from the certificate pool. If
+		// there is no certificate pool, we expect the server to use a
+		// non-self-signed certificate such as a certificate obtained
+		// from Let's Encrypt.
+		var creds credentials.TransportCredentials
+		if certPool != nil {
+			creds = credentials.NewClientTLSFromCert(certPool, "")
+		} else {
+			// Fallback to the system pool. Using an empty tls
+			// config is an alternative to x509.SystemCertPool().
+			// That call is not supported on Windows.
+			creds = credentials.NewTLS(&tls.Config{})
+		}
+
+		opts = append(opts, grpc.WithTransportCredentials(creds))
+	}
+
+	// Only process macaroon credentials if --no-macaroons isn't set and
+	// if we're not skipping macaroon processing.
+	if !profile.NoMacaroons && !skipMacaroons {
+		// Find out which macaroon to load.
+		macName := profile.Macaroons.Default
+		if ctx.GlobalIsSet("macfromjar") {
+			macName = ctx.GlobalString("macfromjar")
+		}
+		var macEntry *macaroonEntry
+		for _, entry := range profile.Macaroons.Jar {
+			if entry.Name == macName {
+				macEntry = entry
+				break
+			}
+		}
+		if macEntry == nil {
+			fatal(fmt.Errorf("macaroon with name '%s' not found "+
+				"in profile", macName))
+		}
+
+		// Get and possibly decrypt the specified macaroon.
+		//
+		// TODO(guggero): Make it possible to cache the password so we
+		// don't need to ask for it every time.
+		mac, err := macEntry.loadMacaroon(readPassword)
+		if err != nil {
+			fatal(fmt.Errorf("could not load macaroon: %w", err))
+		}
+
+		macConstraints := []macaroons.Constraint{
+			// We add a time-based constraint to prevent replay of
+			// the macaroon. It's good for 60 seconds by default to
+			// make up for any discrepancy between client and server
+			// clocks, but leaking the macaroon before it becomes
+			// invalid makes it possible for an attacker to reuse
+			// the macaroon. In addition, the validity time of the
+			// macaroon is extended by the time the server clock is
+			// behind the client clock, or shortened by the time the
+			// server clock is ahead of the client clock (or invalid
+			// altogether if, in the latter case, this time is more
+			// than 60 seconds).
+			// TODO(aakselrod): add better anti-replay protection.
+			macaroons.TimeoutConstraint(profile.Macaroons.Timeout),
+
+			// Lock macaroon down to a specific IP address.
+			macaroons.IPLockConstraint(profile.Macaroons.IP),
+
+			// ... Add more constraints if needed.
+		}
+
+		// Apply constraints to the macaroon.
+		constrainedMac, err := macaroons.AddConstraints(
+			mac, macConstraints...,
+		)
+		if err != nil {
+			fatal(err)
+		}
+
+		// Now we append the macaroon credentials to the dial options.
+		cred, err := macaroons.NewMacaroonCredential(constrainedMac)
+		if err != nil {
+			fatal(fmt.Errorf("error cloning mac: %w", err))
+		}
+		opts = append(opts, grpc.WithPerRPCCredentials(cred))
+	}
+
+	// If a socksproxy server is specified we use a tor dialer
+	// to connect to the grpc server.
+	if ctx.GlobalIsSet("socksproxy") {
+		socksProxy := ctx.GlobalString("socksproxy")
+		torDialer := func(_ context.Context, addr string) (net.Conn,
+			error) {
+
+			return tor.Dial(
+				addr, socksProxy, false, false,
+				tor.DefaultConnTimeout,
+			)
+		}
+		opts = append(opts, grpc.WithContextDialer(torDialer))
+	} else {
+		// We need to use a custom dialer so we can also connect to
+		// unix sockets and not just TCP addresses.
+		genericDialer := lncfg.ClientAddressDialer(defaultRPCPort)
+		opts = append(opts, grpc.WithContextDialer(genericDialer))
+	}
+
+	opts = append(opts, grpc.WithDefaultCallOptions(maxMsgRecvSize))
+
+	conn, err := grpc.Dial(profile.RPCServer, opts...)
+	if err != nil {
+		fatal(fmt.Errorf("unable to connect to RPC server: %w", err))
+	}
+
+	return conn
+}
+
+// addMetadataUnaryInterceptor returns a grpc client side interceptor that
+// appends any key-value metadata strings to the outgoing context of a grpc
+// unary call.
+func addMetadataUnaryInterceptor(
+	md map[string]string) grpc.UnaryClientInterceptor {
+
+	return func(ctx context.Context, method string, req, reply interface{},
+		cc *grpc.ClientConn, invoker grpc.UnaryInvoker,
+		opts ...grpc.CallOption) error {
+
+		outCtx := contextWithMetadata(ctx, md)
+		return invoker(outCtx, method, req, reply, cc, opts...)
+	}
+}
+
+// addMetaDataStreamInterceptor returns a grpc client side interceptor that
+// appends any key-value metadata strings to the outgoing context of a grpc
+// stream call.
+func addMetaDataStreamInterceptor(
+	md map[string]string) grpc.StreamClientInterceptor {
+
+	return func(ctx context.Context, desc *grpc.StreamDesc,
+		cc *grpc.ClientConn, method string, streamer grpc.Streamer,
+		opts ...grpc.CallOption) (grpc.ClientStream, error) {
+
+		outCtx := contextWithMetadata(ctx, md)
+		return streamer(outCtx, desc, cc, method, opts...)
+	}
+}
+
+// contextWithMetaData appends the given metadata key-value pairs to the given
+// context.
+func contextWithMetadata(ctx context.Context,
+	md map[string]string) context.Context {
+
+	kvPairs := make([]string, 0, 2*len(md))
+	for k, v := range md {
+		kvPairs = append(kvPairs, k, v)
+	}
+
+	return metadata.AppendToOutgoingContext(ctx, kvPairs...)
+}
+
+// extractPathArgs parses the TLS certificate and macaroon paths from the
+// command.
+func extractPathArgs(ctx *cli.Context) (string, string, error) {
+	network := strings.ToLower(ctx.GlobalString("network"))
+	switch network {
+	case "mainnet", "testnet", "regtest", "simnet", "signet":
+	default:
+		return "", "", fmt.Errorf("unknown network: %v", network)
+	}
+
+	// We'll now fetch the lnddir so we can make a decision  on how to
+	// properly read the macaroons (if needed) and also the cert. This will
+	// either be the default, or will have been overwritten by the end
+	// user.
+	lndDir := lncfg.CleanAndExpandPath(ctx.GlobalString("lnddir"))
+
+	// If the macaroon path as been manually provided, then we'll only
+	// target the specified file.
+	var macPath string
+	if ctx.GlobalString("macaroonpath") != "" {
+		macPath = lncfg.CleanAndExpandPath(ctx.GlobalString(
+			"macaroonpath",
+		))
+	} else {
+		// Otherwise, we'll go into the path:
+		// lnddir/data/chain/<chain>/<network> in order to fetch the
+		// macaroon that we need.
+		macPath = filepath.Join(
+			lndDir, defaultDataDir, defaultChainSubDir,
+			lnd.BitcoinChainName, network, defaultMacaroonFilename,
+		)
+	}
+
+	tlsCertPath := lncfg.CleanAndExpandPath(ctx.GlobalString("tlscertpath"))
+
+	// If a custom lnd directory was set, we'll also check if custom paths
+	// for the TLS cert and macaroon file were set as well. If not, we'll
+	// override their paths so they can be found within the custom lnd
+	// directory set. This allows us to set a custom lnd directory, along
+	// with custom paths to the TLS cert and macaroon file.
+	if lndDir != DefaultLndDir {
+		tlsCertPath = filepath.Join(lndDir, defaultTLSCertFilename)
+	}
+
+	return tlsCertPath, macPath, nil
+}
+
+// checkNotBothSet accepts two flag names, a and b, and checks that only flag a
+// or flag b can be set, but not both. It returns the name of the flag or an
+// error.
+func checkNotBothSet(ctx *cli.Context, a, b string) (string, error) {
+	if ctx.IsSet(a) && ctx.IsSet(b) {
+		return "", fmt.Errorf(
+			"either %s or %s should be set, but not both", a, b,
+		)
+	}
+
+	if ctx.IsSet(a) {
+		return a, nil
+	}
+
+	return b, nil
+}
+
+func Main() {
+	app := cli.NewApp()
+	app.Name = "lncli"
+	app.Version = build.Version() + " commit=" + build.Commit
+	app.Usage = "control plane for your Lightning Network Daemon (lnd)"
+	app.Flags = []cli.Flag{
+		cli.StringFlag{
+			Name:   "rpcserver",
+			Value:  defaultRPCHostPort,
+			Usage:  "The host:port of LN daemon.",
+			EnvVar: envVarRPCServer,
+		},
+		cli.StringFlag{
+			Name:      "lnddir",
+			Value:     DefaultLndDir,
+			Usage:     "The path to lnd's base directory.",
+			TakesFile: true,
+			EnvVar:    envVarLNDDir,
+		},
+		cli.StringFlag{
+			Name: "socksproxy",
+			Usage: "The host:port of a SOCKS proxy through " +
+				"which all connections to the LN " +
+				"daemon will be established over.",
+			EnvVar: envVarSOCKSProxy,
+		},
+		cli.StringFlag{
+			Name:      "tlscertpath",
+			Value:     defaultTLSCertPath,
+			Usage:     "The path to lnd's TLS certificate.",
+			TakesFile: true,
+			EnvVar:    envVarTLSCertPath,
+		},
+		cli.StringFlag{
+			Name:   "chain, c",
+			Usage:  "The chain lnd is running on, e.g. bitcoin.",
+			Value:  "bitcoin",
+			EnvVar: envVarChain,
+		},
+		cli.StringFlag{
+			Name: "network, n",
+			Usage: "The network lnd is running on, e.g. mainnet, " +
+				"testnet, etc.",
+			Value:  "mainnet",
+			EnvVar: envVarNetwork,
+		},
+		cli.BoolFlag{
+			Name:  "no-macaroons",
+			Usage: "Disable macaroon authentication.",
+		},
+		cli.StringFlag{
+			Name:      "macaroonpath",
+			Usage:     "The path to macaroon file.",
+			TakesFile: true,
+			EnvVar:    envVarMacaroonPath,
+		},
+		cli.Int64Flag{
+			Name:  "macaroontimeout",
+			Value: 60,
+			Usage: "Anti-replay macaroon validity time in " +
+				"seconds.",
+			EnvVar: envVarMacaroonTimeout,
+		},
+		cli.StringFlag{
+			Name:   "macaroonip",
+			Usage:  "If set, lock macaroon to specific IP address.",
+			EnvVar: envVarMacaroonIP,
+		},
+		cli.StringFlag{
+			Name: "profile, p",
+			Usage: "Instead of reading settings from command " +
+				"line parameters or using the default " +
+				"profile, use a specific profile. If " +
+				"a default profile is set, this flag can be " +
+				"set to an empty string to disable reading " +
+				"values from the profiles file.",
+			EnvVar: envVarProfile,
+		},
+		cli.StringFlag{
+			Name: "macfromjar",
+			Usage: "Use this macaroon from the profile's " +
+				"macaroon jar instead of the default one. " +
+				"Can only be used if profiles are defined.",
+			EnvVar: envVarMacFromJar,
+		},
+		cli.StringSliceFlag{
+			Name: "metadata",
+			Usage: "This flag can be used to specify a key-value " +
+				"pair that should be appended to the " +
+				"outgoing context before the request is sent " +
+				"to lnd. This flag may be specified multiple " +
+				"times. The format is: \"key:value\".",
+		},
+		cli.BoolFlag{
+			Name: "insecure",
+			Usage: "Connect to the rpc server without TLS " +
+				"authentication",
+			Hidden: true,
+		},
+	}
+	app.Commands = []cli.Command{
+		createCommand,
+		createWatchOnlyCommand,
+		unlockCommand,
+		changePasswordCommand,
+		newAddressCommand,
+		estimateFeeCommand,
+		sendManyCommand,
+		sendCoinsCommand,
+		listUnspentCommand,
+		connectCommand,
+		disconnectCommand,
+		openChannelCommand,
+		batchOpenChannelCommand,
+		closeChannelCommand,
+		closeAllChannelsCommand,
+		abandonChannelCommand,
+		listPeersCommand,
+		walletBalanceCommand,
+		ChannelBalanceCommand,
+		getInfoCommand,
+		getDebugInfoCommand,
+		encryptDebugPackageCommand,
+		decryptDebugPackageCommand,
+		getRecoveryInfoCommand,
+		pendingChannelsCommand,
+		SendPaymentCommand,
+		payInvoiceCommand,
+		sendToRouteCommand,
+		AddInvoiceCommand,
+		lookupInvoiceCommand,
+		listInvoicesCommand,
+		ListChannelsCommand,
+		closedChannelsCommand,
+		listPaymentsCommand,
+		describeGraphCommand,
+		getNodeMetricsCommand,
+		getChanInfoCommand,
+		getNodeInfoCommand,
+		queryRoutesCommand,
+		getNetworkInfoCommand,
+		debugLevelCommand,
+		decodePayReqCommand,
+		listChainTxnsCommand,
+		stopCommand,
+		signMessageCommand,
+		verifyMessageCommand,
+		feeReportCommand,
+		updateChannelPolicyCommand,
+		forwardingHistoryCommand,
+		exportChanBackupCommand,
+		verifyChanBackupCommand,
+		restoreChanBackupCommand,
+		bakeMacaroonCommand,
+		listMacaroonIDsCommand,
+		deleteMacaroonIDCommand,
+		listPermissionsCommand,
+		printMacaroonCommand,
+		constrainMacaroonCommand,
+		trackPaymentCommand,
+		versionCommand,
+		profileSubCommand,
+		getStateCommand,
+		deletePaymentsCommand,
+		sendCustomCommand,
+		subscribeCustomCommand,
+		fishCompletionCommand,
+		listAliasesCommand,
+		estimateRouteFeeCommand,
+		generateManPageCommand,
+	}
+
+	// Add any extra commands determined by build flags.
+	app.Commands = append(app.Commands, autopilotCommands()...)
+	app.Commands = append(app.Commands, invoicesCommands()...)
+	app.Commands = append(app.Commands, neutrinoCommands()...)
+	app.Commands = append(app.Commands, routerCommands()...)
+	app.Commands = append(app.Commands, walletCommands()...)
+	app.Commands = append(app.Commands, watchtowerCommands()...)
+	app.Commands = append(app.Commands, wtclientCommands()...)
+	app.Commands = append(app.Commands, devCommands()...)
+	app.Commands = append(app.Commands, peersCommands()...)
+	app.Commands = append(app.Commands, chainCommands()...)
+
+	if err := app.Run(os.Args); err != nil {
+		fatal(err)
+	}
+}
+
+// readPassword reads a password from the terminal. This requires there to be an
+// actual TTY so passing in a password from stdin won't work.
+func readPassword(text string) ([]byte, error) {
+	fmt.Print(text)
+
+	// The variable syscall.Stdin is of a different type in the Windows API
+	// that's why we need the explicit cast. And of course the linter
+	// doesn't like it either.
+	pw, err := term.ReadPassword(int(syscall.Stdin)) //nolint:unconvert
+	fmt.Println()
+
+	return pw, err
+}
+
+// networkParams parses the global network flag into a chaincfg.Params.
+func networkParams(ctx *cli.Context) (*chaincfg.Params, error) {
+	network := strings.ToLower(ctx.GlobalString("network"))
+	switch network {
+	case "mainnet":
+		return &chaincfg.MainNetParams, nil
+
+	case "testnet":
+		return &chaincfg.TestNet3Params, nil
+
+	case "regtest":
+		return &chaincfg.RegressionNetParams, nil
+
+	case "simnet":
+		return &chaincfg.SimNetParams, nil
+
+	case "signet":
+		return &chaincfg.SigNetParams, nil
+
+	default:
+		return nil, fmt.Errorf("unknown network: %v", network)
+	}
+}
+
+// parseCoinSelectionStrategy parses a coin selection strategy string
+// from the CLI to its lnrpc.CoinSelectionStrategy counterpart proto type.
+func parseCoinSelectionStrategy(ctx *cli.Context) (
+	lnrpc.CoinSelectionStrategy, error) {
+
+	strategy := ctx.String(coinSelectionStrategyFlag.Name)
+	if !ctx.IsSet(coinSelectionStrategyFlag.Name) {
+		return lnrpc.CoinSelectionStrategy_STRATEGY_USE_GLOBAL_CONFIG,
+			nil
+	}
+
+	switch strategy {
+	case "global-config":
+		return lnrpc.CoinSelectionStrategy_STRATEGY_USE_GLOBAL_CONFIG,
+			nil
+
+	case "largest":
+		return lnrpc.CoinSelectionStrategy_STRATEGY_LARGEST, nil
+
+	case "random":
+		return lnrpc.CoinSelectionStrategy_STRATEGY_RANDOM, nil
+
+	default:
+		return 0, fmt.Errorf("unknown coin selection strategy "+
+			"%v", strategy)
+	}
+}
diff --git a/cmd/lncli/neutrino_active.go b/cmd/commands/neutrino_active.go
similarity index 99%
rename from cmd/lncli/neutrino_active.go
rename to cmd/commands/neutrino_active.go
index 099da46c6..f34c7cc0e 100644
--- a/cmd/lncli/neutrino_active.go
+++ b/cmd/commands/neutrino_active.go
@@ -1,7 +1,7 @@
 //go:build neutrinorpc
 // +build neutrinorpc
 
-package main
+package commands
 
 import (
 	"github.com/lightningnetwork/lnd/lnrpc/neutrinorpc"
diff --git a/cmd/lncli/neutrino_default.go b/cmd/commands/neutrino_default.go
similarity index 92%
rename from cmd/lncli/neutrino_default.go
rename to cmd/commands/neutrino_default.go
index f1f1de404..b269e1238 100644
--- a/cmd/lncli/neutrino_default.go
+++ b/cmd/commands/neutrino_default.go
@@ -1,7 +1,7 @@
 //go:build !neutrinorpc
 // +build !neutrinorpc
 
-package main
+package commands
 
 import "github.com/urfave/cli"
 
diff --git a/cmd/lncli/peersrpc_active.go b/cmd/commands/peersrpc_active.go
similarity index 99%
rename from cmd/lncli/peersrpc_active.go
rename to cmd/commands/peersrpc_active.go
index c044166d3..0736750c7 100644
--- a/cmd/lncli/peersrpc_active.go
+++ b/cmd/commands/peersrpc_active.go
@@ -1,7 +1,7 @@
 //go:build peersrpc
 // +build peersrpc
 
-package main
+package commands
 
 import (
 	"fmt"
diff --git a/cmd/lncli/peersrpc_default.go b/cmd/commands/peersrpc_default.go
similarity index 91%
rename from cmd/lncli/peersrpc_default.go
rename to cmd/commands/peersrpc_default.go
index 24cb2b813..57c8aa7a9 100644
--- a/cmd/lncli/peersrpc_default.go
+++ b/cmd/commands/peersrpc_default.go
@@ -1,7 +1,7 @@
 //go:build !peersrpc
 // +build !peersrpc
 
-package main
+package commands
 
 import "github.com/urfave/cli"
 
diff --git a/cmd/lncli/profile.go b/cmd/commands/profile.go
similarity index 99%
rename from cmd/lncli/profile.go
rename to cmd/commands/profile.go
index 90ac69c0e..e9f6369a2 100644
--- a/cmd/lncli/profile.go
+++ b/cmd/commands/profile.go
@@ -1,4 +1,4 @@
-package main
+package commands
 
 import (
 	"bytes"
diff --git a/cmd/lncli/routerrpc.go b/cmd/commands/routerrpc.go
similarity index 95%
rename from cmd/lncli/routerrpc.go
rename to cmd/commands/routerrpc.go
index 30b892224..82211affd 100644
--- a/cmd/lncli/routerrpc.go
+++ b/cmd/commands/routerrpc.go
@@ -1,4 +1,4 @@
-package main
+package commands
 
 import "github.com/urfave/cli"
 
diff --git a/cmd/lncli/types.go b/cmd/commands/types.go
similarity index 99%
rename from cmd/lncli/types.go
rename to cmd/commands/types.go
index b878811a0..1faed7f9b 100644
--- a/cmd/lncli/types.go
+++ b/cmd/commands/types.go
@@ -1,4 +1,4 @@
-package main
+package commands
 
 import (
 	"encoding/hex"
diff --git a/cmd/lncli/walletrpc_active.go b/cmd/commands/walletrpc_active.go
similarity index 99%
rename from cmd/lncli/walletrpc_active.go
rename to cmd/commands/walletrpc_active.go
index e9e5cc575..2be509a4b 100644
--- a/cmd/lncli/walletrpc_active.go
+++ b/cmd/commands/walletrpc_active.go
@@ -1,7 +1,7 @@
 //go:build walletrpc
 // +build walletrpc
 
-package main
+package commands
 
 import (
 	"bytes"
diff --git a/cmd/lncli/walletrpc_default.go b/cmd/commands/walletrpc_default.go
similarity index 91%
rename from cmd/lncli/walletrpc_default.go
rename to cmd/commands/walletrpc_default.go
index d6670e449..90c627c2a 100644
--- a/cmd/lncli/walletrpc_default.go
+++ b/cmd/commands/walletrpc_default.go
@@ -1,7 +1,7 @@
 //go:build !walletrpc
 // +build !walletrpc
 
-package main
+package commands
 
 import "github.com/urfave/cli"
 
diff --git a/cmd/lncli/walletrpc_types.go b/cmd/commands/walletrpc_types.go
similarity index 98%
rename from cmd/lncli/walletrpc_types.go
rename to cmd/commands/walletrpc_types.go
index b6680a6ed..8edc31d48 100644
--- a/cmd/lncli/walletrpc_types.go
+++ b/cmd/commands/walletrpc_types.go
@@ -1,4 +1,4 @@
-package main
+package commands
 
 import "github.com/lightningnetwork/lnd/lnrpc/walletrpc"
 
diff --git a/cmd/lncli/watchtower_active.go b/cmd/commands/watchtower_active.go
similarity index 98%
rename from cmd/lncli/watchtower_active.go
rename to cmd/commands/watchtower_active.go
index 9c31c6ec4..bc5cd1969 100644
--- a/cmd/lncli/watchtower_active.go
+++ b/cmd/commands/watchtower_active.go
@@ -1,7 +1,7 @@
 //go:build watchtowerrpc
 // +build watchtowerrpc
 
-package main
+package commands
 
 import (
 	"github.com/lightningnetwork/lnd/lnrpc/watchtowerrpc"
diff --git a/cmd/lncli/watchtower_default.go b/cmd/commands/watchtower_default.go
similarity index 92%
rename from cmd/lncli/watchtower_default.go
rename to cmd/commands/watchtower_default.go
index e3db3ccf3..c958a66bd 100644
--- a/cmd/lncli/watchtower_default.go
+++ b/cmd/commands/watchtower_default.go
@@ -1,7 +1,7 @@
 //go:build !watchtowerrpc
 // +build !watchtowerrpc
 
-package main
+package commands
 
 import "github.com/urfave/cli"
 
diff --git a/cmd/lncli/wtclient.go b/cmd/commands/wtclient.go
similarity index 99%
rename from cmd/lncli/wtclient.go
rename to cmd/commands/wtclient.go
index d73f6ca61..075861b98 100644
--- a/cmd/lncli/wtclient.go
+++ b/cmd/commands/wtclient.go
@@ -1,4 +1,4 @@
-package main
+package commands
 
 import (
 	"encoding/hex"
diff --git a/cmd/lncli/main.go b/cmd/lncli/main.go
index b1554fb07..52d682eab 100644
--- a/cmd/lncli/main.go
+++ b/cmd/lncli/main.go
@@ -4,591 +4,8 @@
 
 package main
 
-import (
-	"context"
-	"crypto/tls"
-	"fmt"
-	"net"
-	"os"
-	"path/filepath"
-	"strings"
-	"syscall"
-
-	"github.com/btcsuite/btcd/btcutil"
-	"github.com/btcsuite/btcd/chaincfg"
-	"github.com/lightningnetwork/lnd"
-	"github.com/lightningnetwork/lnd/build"
-	"github.com/lightningnetwork/lnd/lncfg"
-	"github.com/lightningnetwork/lnd/lnrpc"
-	"github.com/lightningnetwork/lnd/macaroons"
-	"github.com/lightningnetwork/lnd/tor"
-	"github.com/urfave/cli"
-	"golang.org/x/term"
-	"google.golang.org/grpc"
-	"google.golang.org/grpc/credentials"
-	"google.golang.org/grpc/metadata"
-)
-
-const (
-	defaultDataDir          = "data"
-	defaultChainSubDir      = "chain"
-	defaultTLSCertFilename  = "tls.cert"
-	defaultMacaroonFilename = "admin.macaroon"
-	defaultRPCPort          = "10009"
-	defaultRPCHostPort      = "localhost:" + defaultRPCPort
-
-	envVarRPCServer       = "LNCLI_RPCSERVER"
-	envVarLNDDir          = "LNCLI_LNDDIR"
-	envVarSOCKSProxy      = "LNCLI_SOCKSPROXY"
-	envVarTLSCertPath     = "LNCLI_TLSCERTPATH"
-	envVarChain           = "LNCLI_CHAIN"
-	envVarNetwork         = "LNCLI_NETWORK"
-	envVarMacaroonPath    = "LNCLI_MACAROONPATH"
-	envVarMacaroonTimeout = "LNCLI_MACAROONTIMEOUT"
-	envVarMacaroonIP      = "LNCLI_MACAROONIP"
-	envVarProfile         = "LNCLI_PROFILE"
-	envVarMacFromJar      = "LNCLI_MACFROMJAR"
-)
-
-var (
-	defaultLndDir      = btcutil.AppDataDir("lnd", false)
-	defaultTLSCertPath = filepath.Join(defaultLndDir, defaultTLSCertFilename)
-
-	// maxMsgRecvSize is the largest message our client will receive. We
-	// set this to 200MiB atm.
-	maxMsgRecvSize = grpc.MaxCallRecvMsgSize(lnrpc.MaxGrpcMsgSize)
-)
-
-func fatal(err error) {
-	fmt.Fprintf(os.Stderr, "[lncli] %v\n", err)
-	os.Exit(1)
-}
-
-func getWalletUnlockerClient(ctx *cli.Context) (lnrpc.WalletUnlockerClient, func()) {
-	conn := getClientConn(ctx, true)
-
-	cleanUp := func() {
-		conn.Close()
-	}
-
-	return lnrpc.NewWalletUnlockerClient(conn), cleanUp
-}
-
-func getStateServiceClient(ctx *cli.Context) (lnrpc.StateClient, func()) {
-	conn := getClientConn(ctx, true)
-
-	cleanUp := func() {
-		conn.Close()
-	}
-
-	return lnrpc.NewStateClient(conn), cleanUp
-}
-
-func getClient(ctx *cli.Context) (lnrpc.LightningClient, func()) {
-	conn := getClientConn(ctx, false)
-
-	cleanUp := func() {
-		conn.Close()
-	}
-
-	return lnrpc.NewLightningClient(conn), cleanUp
-}
-
-func getClientConn(ctx *cli.Context, skipMacaroons bool) *grpc.ClientConn {
-	// First, we'll get the selected stored profile or an ephemeral one
-	// created from the global options in the CLI context.
-	profile, err := getGlobalOptions(ctx, skipMacaroons)
-	if err != nil {
-		fatal(fmt.Errorf("could not load global options: %w", err))
-	}
-
-	// Create a dial options array.
-	opts := []grpc.DialOption{
-		grpc.WithUnaryInterceptor(
-			addMetadataUnaryInterceptor(profile.Metadata),
-		),
-		grpc.WithStreamInterceptor(
-			addMetaDataStreamInterceptor(profile.Metadata),
-		),
-	}
-
-	if profile.Insecure {
-		opts = append(opts, grpc.WithInsecure())
-	} else {
-		// Load the specified TLS certificate.
-		certPool, err := profile.cert()
-		if err != nil {
-			fatal(fmt.Errorf("could not create cert pool: %w", err))
-		}
-
-		// Build transport credentials from the certificate pool. If
-		// there is no certificate pool, we expect the server to use a
-		// non-self-signed certificate such as a certificate obtained
-		// from Let's Encrypt.
-		var creds credentials.TransportCredentials
-		if certPool != nil {
-			creds = credentials.NewClientTLSFromCert(certPool, "")
-		} else {
-			// Fallback to the system pool. Using an empty tls
-			// config is an alternative to x509.SystemCertPool().
-			// That call is not supported on Windows.
-			creds = credentials.NewTLS(&tls.Config{})
-		}
-
-		opts = append(opts, grpc.WithTransportCredentials(creds))
-	}
-
-	// Only process macaroon credentials if --no-macaroons isn't set and
-	// if we're not skipping macaroon processing.
-	if !profile.NoMacaroons && !skipMacaroons {
-		// Find out which macaroon to load.
-		macName := profile.Macaroons.Default
-		if ctx.GlobalIsSet("macfromjar") {
-			macName = ctx.GlobalString("macfromjar")
-		}
-		var macEntry *macaroonEntry
-		for _, entry := range profile.Macaroons.Jar {
-			if entry.Name == macName {
-				macEntry = entry
-				break
-			}
-		}
-		if macEntry == nil {
-			fatal(fmt.Errorf("macaroon with name '%s' not found "+
-				"in profile", macName))
-		}
-
-		// Get and possibly decrypt the specified macaroon.
-		//
-		// TODO(guggero): Make it possible to cache the password so we
-		// don't need to ask for it every time.
-		mac, err := macEntry.loadMacaroon(readPassword)
-		if err != nil {
-			fatal(fmt.Errorf("could not load macaroon: %w", err))
-		}
-
-		macConstraints := []macaroons.Constraint{
-			// We add a time-based constraint to prevent replay of
-			// the macaroon. It's good for 60 seconds by default to
-			// make up for any discrepancy between client and server
-			// clocks, but leaking the macaroon before it becomes
-			// invalid makes it possible for an attacker to reuse
-			// the macaroon. In addition, the validity time of the
-			// macaroon is extended by the time the server clock is
-			// behind the client clock, or shortened by the time the
-			// server clock is ahead of the client clock (or invalid
-			// altogether if, in the latter case, this time is more
-			// than 60 seconds).
-			// TODO(aakselrod): add better anti-replay protection.
-			macaroons.TimeoutConstraint(profile.Macaroons.Timeout),
-
-			// Lock macaroon down to a specific IP address.
-			macaroons.IPLockConstraint(profile.Macaroons.IP),
-
-			// ... Add more constraints if needed.
-		}
-
-		// Apply constraints to the macaroon.
-		constrainedMac, err := macaroons.AddConstraints(
-			mac, macConstraints...,
-		)
-		if err != nil {
-			fatal(err)
-		}
-
-		// Now we append the macaroon credentials to the dial options.
-		cred, err := macaroons.NewMacaroonCredential(constrainedMac)
-		if err != nil {
-			fatal(fmt.Errorf("error cloning mac: %w", err))
-		}
-		opts = append(opts, grpc.WithPerRPCCredentials(cred))
-	}
-
-	// If a socksproxy server is specified we use a tor dialer
-	// to connect to the grpc server.
-	if ctx.GlobalIsSet("socksproxy") {
-		socksProxy := ctx.GlobalString("socksproxy")
-		torDialer := func(_ context.Context, addr string) (net.Conn,
-			error) {
-
-			return tor.Dial(
-				addr, socksProxy, false, false,
-				tor.DefaultConnTimeout,
-			)
-		}
-		opts = append(opts, grpc.WithContextDialer(torDialer))
-	} else {
-		// We need to use a custom dialer so we can also connect to
-		// unix sockets and not just TCP addresses.
-		genericDialer := lncfg.ClientAddressDialer(defaultRPCPort)
-		opts = append(opts, grpc.WithContextDialer(genericDialer))
-	}
-
-	opts = append(opts, grpc.WithDefaultCallOptions(maxMsgRecvSize))
-
-	conn, err := grpc.Dial(profile.RPCServer, opts...)
-	if err != nil {
-		fatal(fmt.Errorf("unable to connect to RPC server: %w", err))
-	}
-
-	return conn
-}
-
-// addMetadataUnaryInterceptor returns a grpc client side interceptor that
-// appends any key-value metadata strings to the outgoing context of a grpc
-// unary call.
-func addMetadataUnaryInterceptor(
-	md map[string]string) grpc.UnaryClientInterceptor {
-
-	return func(ctx context.Context, method string, req, reply interface{},
-		cc *grpc.ClientConn, invoker grpc.UnaryInvoker,
-		opts ...grpc.CallOption) error {
-
-		outCtx := contextWithMetadata(ctx, md)
-		return invoker(outCtx, method, req, reply, cc, opts...)
-	}
-}
-
-// addMetaDataStreamInterceptor returns a grpc client side interceptor that
-// appends any key-value metadata strings to the outgoing context of a grpc
-// stream call.
-func addMetaDataStreamInterceptor(
-	md map[string]string) grpc.StreamClientInterceptor {
-
-	return func(ctx context.Context, desc *grpc.StreamDesc,
-		cc *grpc.ClientConn, method string, streamer grpc.Streamer,
-		opts ...grpc.CallOption) (grpc.ClientStream, error) {
-
-		outCtx := contextWithMetadata(ctx, md)
-		return streamer(outCtx, desc, cc, method, opts...)
-	}
-}
-
-// contextWithMetaData appends the given metadata key-value pairs to the given
-// context.
-func contextWithMetadata(ctx context.Context,
-	md map[string]string) context.Context {
-
-	kvPairs := make([]string, 0, 2*len(md))
-	for k, v := range md {
-		kvPairs = append(kvPairs, k, v)
-	}
-
-	return metadata.AppendToOutgoingContext(ctx, kvPairs...)
-}
-
-// extractPathArgs parses the TLS certificate and macaroon paths from the
-// command.
-func extractPathArgs(ctx *cli.Context) (string, string, error) {
-	network := strings.ToLower(ctx.GlobalString("network"))
-	switch network {
-	case "mainnet", "testnet", "regtest", "simnet", "signet":
-	default:
-		return "", "", fmt.Errorf("unknown network: %v", network)
-	}
-
-	// We'll now fetch the lnddir so we can make a decision  on how to
-	// properly read the macaroons (if needed) and also the cert. This will
-	// either be the default, or will have been overwritten by the end
-	// user.
-	lndDir := lncfg.CleanAndExpandPath(ctx.GlobalString("lnddir"))
-
-	// If the macaroon path as been manually provided, then we'll only
-	// target the specified file.
-	var macPath string
-	if ctx.GlobalString("macaroonpath") != "" {
-		macPath = lncfg.CleanAndExpandPath(ctx.GlobalString("macaroonpath"))
-	} else {
-		// Otherwise, we'll go into the path:
-		// lnddir/data/chain/<chain>/<network> in order to fetch the
-		// macaroon that we need.
-		macPath = filepath.Join(
-			lndDir, defaultDataDir, defaultChainSubDir,
-			lnd.BitcoinChainName, network, defaultMacaroonFilename,
-		)
-	}
-
-	tlsCertPath := lncfg.CleanAndExpandPath(ctx.GlobalString("tlscertpath"))
-
-	// If a custom lnd directory was set, we'll also check if custom paths
-	// for the TLS cert and macaroon file were set as well. If not, we'll
-	// override their paths so they can be found within the custom lnd
-	// directory set. This allows us to set a custom lnd directory, along
-	// with custom paths to the TLS cert and macaroon file.
-	if lndDir != defaultLndDir {
-		tlsCertPath = filepath.Join(lndDir, defaultTLSCertFilename)
-	}
-
-	return tlsCertPath, macPath, nil
-}
-
-// checkNotBothSet accepts two flag names, a and b, and checks that only flag a
-// or flag b can be set, but not both. It returns the name of the flag or an
-// error.
-func checkNotBothSet(ctx *cli.Context, a, b string) (string, error) {
-	if ctx.IsSet(a) && ctx.IsSet(b) {
-		return "", fmt.Errorf(
-			"either %s or %s should be set, but not both", a, b,
-		)
-	}
-
-	if ctx.IsSet(a) {
-		return a, nil
-	}
-
-	return b, nil
-}
+import "github.com/lightningnetwork/lnd/cmd/commands"
 
 func main() {
-	app := cli.NewApp()
-	app.Name = "lncli"
-	app.Version = build.Version() + " commit=" + build.Commit
-	app.Usage = "control plane for your Lightning Network Daemon (lnd)"
-	app.Flags = []cli.Flag{
-		cli.StringFlag{
-			Name:   "rpcserver",
-			Value:  defaultRPCHostPort,
-			Usage:  "The host:port of LN daemon.",
-			EnvVar: envVarRPCServer,
-		},
-		cli.StringFlag{
-			Name:      "lnddir",
-			Value:     defaultLndDir,
-			Usage:     "The path to lnd's base directory.",
-			TakesFile: true,
-			EnvVar:    envVarLNDDir,
-		},
-		cli.StringFlag{
-			Name: "socksproxy",
-			Usage: "The host:port of a SOCKS proxy through " +
-				"which all connections to the LN " +
-				"daemon will be established over.",
-			EnvVar: envVarSOCKSProxy,
-		},
-		cli.StringFlag{
-			Name:      "tlscertpath",
-			Value:     defaultTLSCertPath,
-			Usage:     "The path to lnd's TLS certificate.",
-			TakesFile: true,
-			EnvVar:    envVarTLSCertPath,
-		},
-		cli.StringFlag{
-			Name:   "chain, c",
-			Usage:  "The chain lnd is running on, e.g. bitcoin.",
-			Value:  "bitcoin",
-			EnvVar: envVarChain,
-		},
-		cli.StringFlag{
-			Name: "network, n",
-			Usage: "The network lnd is running on, e.g. mainnet, " +
-				"testnet, etc.",
-			Value:  "mainnet",
-			EnvVar: envVarNetwork,
-		},
-		cli.BoolFlag{
-			Name:  "no-macaroons",
-			Usage: "Disable macaroon authentication.",
-		},
-		cli.StringFlag{
-			Name:      "macaroonpath",
-			Usage:     "The path to macaroon file.",
-			TakesFile: true,
-			EnvVar:    envVarMacaroonPath,
-		},
-		cli.Int64Flag{
-			Name:  "macaroontimeout",
-			Value: 60,
-			Usage: "Anti-replay macaroon validity time in " +
-				"seconds.",
-			EnvVar: envVarMacaroonTimeout,
-		},
-		cli.StringFlag{
-			Name:   "macaroonip",
-			Usage:  "If set, lock macaroon to specific IP address.",
-			EnvVar: envVarMacaroonIP,
-		},
-		cli.StringFlag{
-			Name: "profile, p",
-			Usage: "Instead of reading settings from command " +
-				"line parameters or using the default " +
-				"profile, use a specific profile. If " +
-				"a default profile is set, this flag can be " +
-				"set to an empty string to disable reading " +
-				"values from the profiles file.",
-			EnvVar: envVarProfile,
-		},
-		cli.StringFlag{
-			Name: "macfromjar",
-			Usage: "Use this macaroon from the profile's " +
-				"macaroon jar instead of the default one. " +
-				"Can only be used if profiles are defined.",
-			EnvVar: envVarMacFromJar,
-		},
-		cli.StringSliceFlag{
-			Name: "metadata",
-			Usage: "This flag can be used to specify a key-value " +
-				"pair that should be appended to the " +
-				"outgoing context before the request is sent " +
-				"to lnd. This flag may be specified multiple " +
-				"times. The format is: \"key:value\".",
-		},
-		cli.BoolFlag{
-			Name: "insecure",
-			Usage: "Connect to the rpc server without TLS " +
-				"authentication",
-			Hidden: true,
-		},
-	}
-	app.Commands = []cli.Command{
-		createCommand,
-		createWatchOnlyCommand,
-		unlockCommand,
-		changePasswordCommand,
-		newAddressCommand,
-		estimateFeeCommand,
-		sendManyCommand,
-		sendCoinsCommand,
-		listUnspentCommand,
-		connectCommand,
-		disconnectCommand,
-		openChannelCommand,
-		batchOpenChannelCommand,
-		closeChannelCommand,
-		closeAllChannelsCommand,
-		abandonChannelCommand,
-		listPeersCommand,
-		walletBalanceCommand,
-		channelBalanceCommand,
-		getInfoCommand,
-		getDebugInfoCommand,
-		encryptDebugPackageCommand,
-		decryptDebugPackageCommand,
-		getRecoveryInfoCommand,
-		pendingChannelsCommand,
-		sendPaymentCommand,
-		payInvoiceCommand,
-		sendToRouteCommand,
-		addInvoiceCommand,
-		lookupInvoiceCommand,
-		listInvoicesCommand,
-		listChannelsCommand,
-		closedChannelsCommand,
-		listPaymentsCommand,
-		describeGraphCommand,
-		getNodeMetricsCommand,
-		getChanInfoCommand,
-		getNodeInfoCommand,
-		queryRoutesCommand,
-		getNetworkInfoCommand,
-		debugLevelCommand,
-		decodePayReqCommand,
-		listChainTxnsCommand,
-		stopCommand,
-		signMessageCommand,
-		verifyMessageCommand,
-		feeReportCommand,
-		updateChannelPolicyCommand,
-		forwardingHistoryCommand,
-		exportChanBackupCommand,
-		verifyChanBackupCommand,
-		restoreChanBackupCommand,
-		bakeMacaroonCommand,
-		listMacaroonIDsCommand,
-		deleteMacaroonIDCommand,
-		listPermissionsCommand,
-		printMacaroonCommand,
-		constrainMacaroonCommand,
-		trackPaymentCommand,
-		versionCommand,
-		profileSubCommand,
-		getStateCommand,
-		deletePaymentsCommand,
-		sendCustomCommand,
-		subscribeCustomCommand,
-		fishCompletionCommand,
-		listAliasesCommand,
-		estimateRouteFeeCommand,
-		generateManPageCommand,
-	}
-
-	// Add any extra commands determined by build flags.
-	app.Commands = append(app.Commands, autopilotCommands()...)
-	app.Commands = append(app.Commands, invoicesCommands()...)
-	app.Commands = append(app.Commands, neutrinoCommands()...)
-	app.Commands = append(app.Commands, routerCommands()...)
-	app.Commands = append(app.Commands, walletCommands()...)
-	app.Commands = append(app.Commands, watchtowerCommands()...)
-	app.Commands = append(app.Commands, wtclientCommands()...)
-	app.Commands = append(app.Commands, devCommands()...)
-	app.Commands = append(app.Commands, peersCommands()...)
-	app.Commands = append(app.Commands, chainCommands()...)
-
-	if err := app.Run(os.Args); err != nil {
-		fatal(err)
-	}
-}
-
-// readPassword reads a password from the terminal. This requires there to be an
-// actual TTY so passing in a password from stdin won't work.
-func readPassword(text string) ([]byte, error) {
-	fmt.Print(text)
-
-	// The variable syscall.Stdin is of a different type in the Windows API
-	// that's why we need the explicit cast. And of course the linter
-	// doesn't like it either.
-	pw, err := term.ReadPassword(int(syscall.Stdin)) // nolint:unconvert
-	fmt.Println()
-	return pw, err
-}
-
-// networkParams parses the global network flag into a chaincfg.Params.
-func networkParams(ctx *cli.Context) (*chaincfg.Params, error) {
-	network := strings.ToLower(ctx.GlobalString("network"))
-	switch network {
-	case "mainnet":
-		return &chaincfg.MainNetParams, nil
-
-	case "testnet":
-		return &chaincfg.TestNet3Params, nil
-
-	case "regtest":
-		return &chaincfg.RegressionNetParams, nil
-
-	case "simnet":
-		return &chaincfg.SimNetParams, nil
-
-	case "signet":
-		return &chaincfg.SigNetParams, nil
-
-	default:
-		return nil, fmt.Errorf("unknown network: %v", network)
-	}
-}
-
-// parseCoinSelectionStrategy parses a coin selection strategy string
-// from the CLI to its lnrpc.CoinSelectionStrategy counterpart proto type.
-func parseCoinSelectionStrategy(ctx *cli.Context) (
-	lnrpc.CoinSelectionStrategy, error) {
-
-	strategy := ctx.String(coinSelectionStrategyFlag.Name)
-	if !ctx.IsSet(coinSelectionStrategyFlag.Name) {
-		return lnrpc.CoinSelectionStrategy_STRATEGY_USE_GLOBAL_CONFIG,
-			nil
-	}
-
-	switch strategy {
-	case "global-config":
-		return lnrpc.CoinSelectionStrategy_STRATEGY_USE_GLOBAL_CONFIG,
-			nil
-
-	case "largest":
-		return lnrpc.CoinSelectionStrategy_STRATEGY_LARGEST, nil
-
-	case "random":
-		return lnrpc.CoinSelectionStrategy_STRATEGY_RANDOM, nil
-
-	default:
-		return 0, fmt.Errorf("unknown coin selection strategy "+
-			"%v", strategy)
-	}
+	commands.Main()
 }
diff --git a/config_builder.go b/config_builder.go
index 28cb05f9a..f1f59e916 100644
--- a/config_builder.go
+++ b/config_builder.go
@@ -178,6 +178,10 @@ type AuxComponents struct {
 	// AuxSigner is an optional signer that can be used to sign auxiliary
 	// leaves for certain custom channel types.
 	AuxSigner fn.Option[lnwallet.AuxSigner]
+
+	// AuxDataParser is an optional data parser that can be used to parse
+	// auxiliary data for certain custom channel types.
+	AuxDataParser fn.Option[AuxDataParser]
 }
 
 // DefaultWalletImpl is the default implementation of our normal, btcwallet
diff --git a/htlcswitch/link.go b/htlcswitch/link.go
index f92fcaadc..10f715c64 100644
--- a/htlcswitch/link.go
+++ b/htlcswitch/link.go
@@ -3358,27 +3358,6 @@ func (l *channelLink) processRemoteAdds(fwdPkg *channeldb.FwdPkg,
 				continue
 			}
 
-			var customRecords record.CustomSet
-			err = fn.MapOptionZ(
-				pd.CustomRecords, func(b tlv.Blob) error {
-					r, err := lnwire.ParseCustomRecords(b)
-					if err != nil {
-						return err
-					}
-
-					customRecords = record.CustomSet(r)
-
-					return nil
-				},
-			)
-			if err != nil {
-				l.fail(LinkFailureError{
-					code: ErrInternalError,
-				}, err.Error())
-
-				return
-			}
-
 			switch fwdPkg.State {
 			case channeldb.FwdStateProcessed:
 				// This add was not forwarded on the previous
@@ -3412,21 +3391,22 @@ func (l *channelLink) processRemoteAdds(fwdPkg *channeldb.FwdPkg,
 
 				inboundFee := l.cfg.FwrdingPolicy.InboundFee
 
-				//nolint:lll
 				updatePacket := &htlcPacket{
-					incomingChanID:        l.ShortChanID(),
-					incomingHTLCID:        pd.HtlcIndex,
-					outgoingChanID:        fwdInfo.NextHop,
-					sourceRef:             pd.SourceRef,
-					incomingAmount:        pd.Amount,
-					amount:                addMsg.Amount,
-					htlc:                  addMsg,
-					obfuscator:            obfuscator,
-					incomingTimeout:       pd.Timeout,
-					outgoingTimeout:       fwdInfo.OutgoingCTLV,
-					customRecords:         pld.CustomRecords(),
-					inboundFee:            inboundFee,
-					incomingCustomRecords: customRecords,
+					incomingChanID:  l.ShortChanID(),
+					incomingHTLCID:  pd.HtlcIndex,
+					outgoingChanID:  fwdInfo.NextHop,
+					sourceRef:       pd.SourceRef,
+					incomingAmount:  pd.Amount,
+					amount:          addMsg.Amount,
+					htlc:            addMsg,
+					obfuscator:      obfuscator,
+					incomingTimeout: pd.Timeout,
+					outgoingTimeout: fwdInfo.OutgoingCTLV,
+					customRecords:   pld.CustomRecords(),
+					inboundFee:      inboundFee,
+					incomingCustomRecords: record.CustomSet(
+						pd.CustomRecords,
+					),
 				}
 				switchPackets = append(
 					switchPackets, updatePacket,
@@ -3482,21 +3462,22 @@ func (l *channelLink) processRemoteAdds(fwdPkg *channeldb.FwdPkg,
 			if fwdPkg.State == channeldb.FwdStateLockedIn {
 				inboundFee := l.cfg.FwrdingPolicy.InboundFee
 
-				//nolint:lll
 				updatePacket := &htlcPacket{
-					incomingChanID:        l.ShortChanID(),
-					incomingHTLCID:        pd.HtlcIndex,
-					outgoingChanID:        fwdInfo.NextHop,
-					sourceRef:             pd.SourceRef,
-					incomingAmount:        pd.Amount,
-					amount:                addMsg.Amount,
-					htlc:                  addMsg,
-					obfuscator:            obfuscator,
-					incomingTimeout:       pd.Timeout,
-					outgoingTimeout:       fwdInfo.OutgoingCTLV,
-					customRecords:         pld.CustomRecords(),
-					inboundFee:            inboundFee,
-					incomingCustomRecords: customRecords,
+					incomingChanID:  l.ShortChanID(),
+					incomingHTLCID:  pd.HtlcIndex,
+					outgoingChanID:  fwdInfo.NextHop,
+					sourceRef:       pd.SourceRef,
+					incomingAmount:  pd.Amount,
+					amount:          addMsg.Amount,
+					htlc:            addMsg,
+					obfuscator:      obfuscator,
+					incomingTimeout: pd.Timeout,
+					outgoingTimeout: fwdInfo.OutgoingCTLV,
+					customRecords:   pld.CustomRecords(),
+					inboundFee:      inboundFee,
+					incomingCustomRecords: record.CustomSet(
+						pd.CustomRecords,
+					),
 				}
 
 				fwdPkg.FwdFilter.Set(idx)
diff --git a/input/script_desc.go b/input/script_desc.go
index a32ae6631..17b58b721 100644
--- a/input/script_desc.go
+++ b/input/script_desc.go
@@ -33,17 +33,17 @@ const (
 	ScriptPathDelay
 )
 
-// ScriptDesciptor is an interface that abstracts over the various ways a
+// ScriptDescriptor is an interface that abstracts over the various ways a
 // pkScript can be spent from an output. This supports both normal p2wsh
-// (witness script, etc), and also tapscript paths which have distinct
+// (witness script, etc.), and also tapscript paths which have distinct
 // tapscript leaves.
 type ScriptDescriptor interface {
 	// PkScript is the public key script that commits to the final
 	// contract.
 	PkScript() []byte
 
-	// WitnessScript returns the witness script that we'll use when signing
-	// for the remote party, and also verifying signatures on our
+	// WitnessScriptToSign returns the witness script that we'll use when
+	// signing for the remote party, and also verifying signatures on our
 	// transactions. As an example, when we create an outgoing HTLC for the
 	// remote party, we want to sign their success path.
 	//
@@ -73,6 +73,9 @@ type TapscriptDescriptor interface {
 
 	// TapScriptTree returns the underlying tapscript tree.
 	TapScriptTree() *txscript.IndexedTapScriptTree
+
+	// Tree returns the underlying ScriptTree.
+	Tree() ScriptTree
 }
 
 // ScriptTree holds the contents needed to spend a script within a tapscript
diff --git a/input/script_utils.go b/input/script_utils.go
index 5ad0a0e90..7633f673c 100644
--- a/input/script_utils.go
+++ b/input/script_utils.go
@@ -729,8 +729,8 @@ func (h *HtlcScriptTree) WitnessScriptForPath(path ScriptPath) ([]byte, error) {
 
 // CtrlBlockForPath returns the control block for the given spending path. For
 // script types that don't have a control block, nil is returned.
-func (h *HtlcScriptTree) CtrlBlockForPath(path ScriptPath,
-) (*txscript.ControlBlock, error) {
+func (h *HtlcScriptTree) CtrlBlockForPath(
+	path ScriptPath) (*txscript.ControlBlock, error) {
 
 	switch path {
 	case ScriptPathSuccess:
@@ -748,6 +748,11 @@ func (h *HtlcScriptTree) CtrlBlockForPath(path ScriptPath,
 	}
 }
 
+// Tree returns the underlying ScriptTree of the HtlcScriptTree.
+func (h *HtlcScriptTree) Tree() ScriptTree {
+	return h.ScriptTree
+}
+
 // A compile time check to ensure HtlcScriptTree implements the
 // TapscriptMultiplexer interface.
 var _ TapscriptDescriptor = (*HtlcScriptTree)(nil)
@@ -1748,9 +1753,9 @@ func TaprootSecondLevelScriptTree(revokeKey, delayKey *btcec.PublicKey,
 	}, nil
 }
 
-// WitnessScript returns the witness script that we'll use when signing for the
-// remote party, and also verifying signatures on our transactions. As an
-// example, when we create an outgoing HTLC for the remote party, we want to
+// WitnessScriptToSign returns the witness script that we'll use when signing
+// for the remote party, and also verifying signatures on our transactions. As
+// an example, when we create an outgoing HTLC for the remote party, we want to
 // sign their success path.
 func (s *SecondLevelScriptTree) WitnessScriptToSign() []byte {
 	return s.SuccessTapLeaf.Script
@@ -1758,8 +1763,8 @@ func (s *SecondLevelScriptTree) WitnessScriptToSign() []byte {
 
 // WitnessScriptForPath returns the witness script for the given spending path.
 // An error is returned if the path is unknown.
-func (s *SecondLevelScriptTree) WitnessScriptForPath(path ScriptPath,
-) ([]byte, error) {
+func (s *SecondLevelScriptTree) WitnessScriptForPath(
+	path ScriptPath) ([]byte, error) {
 
 	switch path {
 	case ScriptPathDelay:
@@ -1774,8 +1779,8 @@ func (s *SecondLevelScriptTree) WitnessScriptForPath(path ScriptPath,
 
 // CtrlBlockForPath returns the control block for the given spending path. For
 // script types that don't have a control block, nil is returned.
-func (s *SecondLevelScriptTree) CtrlBlockForPath(path ScriptPath,
-) (*txscript.ControlBlock, error) {
+func (s *SecondLevelScriptTree) CtrlBlockForPath(
+	path ScriptPath) (*txscript.ControlBlock, error) {
 
 	switch path {
 	case ScriptPathDelay:
@@ -1791,6 +1796,11 @@ func (s *SecondLevelScriptTree) CtrlBlockForPath(path ScriptPath,
 	}
 }
 
+// Tree returns the underlying ScriptTree of the SecondLevelScriptTree.
+func (s *SecondLevelScriptTree) Tree() ScriptTree {
+	return s.ScriptTree
+}
+
 // A compile time check to ensure SecondLevelScriptTree implements the
 // TapscriptDescriptor interface.
 var _ TapscriptDescriptor = (*SecondLevelScriptTree)(nil)
@@ -2133,9 +2143,9 @@ type CommitScriptTree struct {
 // TapscriptDescriptor interface.
 var _ TapscriptDescriptor = (*CommitScriptTree)(nil)
 
-// WitnessScript returns the witness script that we'll use when signing for the
-// remote party, and also verifying signatures on our transactions. As an
-// example, when we create an outgoing HTLC for the remote party, we want to
+// WitnessScriptToSign returns the witness script that we'll use when signing
+// for the remote party, and also verifying signatures on our transactions. As
+// an example, when we create an outgoing HTLC for the remote party, we want to
 // sign their success path.
 func (c *CommitScriptTree) WitnessScriptToSign() []byte {
 	// TODO(roasbeef): abstraction leak here? always dependent
@@ -2144,8 +2154,8 @@ func (c *CommitScriptTree) WitnessScriptToSign() []byte {
 
 // WitnessScriptForPath returns the witness script for the given spending path.
 // An error is returned if the path is unknown.
-func (c *CommitScriptTree) WitnessScriptForPath(path ScriptPath,
-) ([]byte, error) {
+func (c *CommitScriptTree) WitnessScriptForPath(
+	path ScriptPath) ([]byte, error) {
 
 	switch path {
 	// For the commitment output, the delay and success path are the same,
@@ -2163,8 +2173,8 @@ func (c *CommitScriptTree) WitnessScriptForPath(path ScriptPath,
 
 // CtrlBlockForPath returns the control block for the given spending path. For
 // script types that don't have a control block, nil is returned.
-func (c *CommitScriptTree) CtrlBlockForPath(path ScriptPath,
-) (*txscript.ControlBlock, error) {
+func (c *CommitScriptTree) CtrlBlockForPath(
+	path ScriptPath) (*txscript.ControlBlock, error) {
 
 	switch path {
 	case ScriptPathDelay:
@@ -2184,6 +2194,11 @@ func (c *CommitScriptTree) CtrlBlockForPath(path ScriptPath,
 	}
 }
 
+// Tree returns the underlying ScriptTree of the CommitScriptTree.
+func (c *CommitScriptTree) Tree() ScriptTree {
+	return c.ScriptTree
+}
+
 // NewLocalCommitScriptTree returns a new CommitScript tree that can be used to
 // create and spend the commitment output for the local party.
 func NewLocalCommitScriptTree(csvTimeout uint32, selfKey,
@@ -2311,7 +2326,7 @@ func TaprootCommitScriptToSelf(csvTimeout uint32,
 	return commitScriptTree.TaprootKey, nil
 }
 
-// MakeTaprootSCtrlBlock takes a leaf script, the internal key (usually the
+// MakeTaprootCtrlBlock takes a leaf script, the internal key (usually the
 // revoke key), and a script tree and creates a valid control block for a spend
 // of the leaf.
 func MakeTaprootCtrlBlock(leafScript []byte, internalKey *btcec.PublicKey,
@@ -2366,9 +2381,6 @@ func TaprootCommitSpendSuccess(signer Signer, signDesc *SignDescriptor,
 	witnessStack[0] = maybeAppendSighash(sweepSig, signDesc.HashType)
 	witnessStack[1] = signDesc.WitnessScript
 	witnessStack[2] = ctrlBlockBytes
-	if err != nil {
-		return nil, err
-	}
 
 	return witnessStack, nil
 }
@@ -2850,8 +2862,8 @@ type AnchorScriptTree struct {
 
 // NewAnchorScriptTree makes a new script tree for an anchor output with the
 // passed anchor key.
-func NewAnchorScriptTree(anchorKey *btcec.PublicKey,
-) (*AnchorScriptTree, error) {
+func NewAnchorScriptTree(
+	anchorKey *btcec.PublicKey) (*AnchorScriptTree, error) {
 
 	// The main script used is just a OP_16 CSV (anyone can sweep after 16
 	// blocks).
@@ -2887,9 +2899,9 @@ func NewAnchorScriptTree(anchorKey *btcec.PublicKey,
 	}, nil
 }
 
-// WitnessScript returns the witness script that we'll use when signing for the
-// remote party, and also verifying signatures on our transactions. As an
-// example, when we create an outgoing HTLC for the remote party, we want to
+// WitnessScriptToSign returns the witness script that we'll use when signing
+// for the remote party, and also verifying signatures on our transactions. As
+// an example, when we create an outgoing HTLC for the remote party, we want to
 // sign their success path.
 func (a *AnchorScriptTree) WitnessScriptToSign() []byte {
 	return a.SweepLeaf.Script
@@ -2897,8 +2909,8 @@ func (a *AnchorScriptTree) WitnessScriptToSign() []byte {
 
 // WitnessScriptForPath returns the witness script for the given spending path.
 // An error is returned if the path is unknown.
-func (a *AnchorScriptTree) WitnessScriptForPath(path ScriptPath,
-) ([]byte, error) {
+func (a *AnchorScriptTree) WitnessScriptForPath(
+	path ScriptPath) ([]byte, error) {
 
 	switch path {
 	case ScriptPathDelay:
@@ -2913,8 +2925,8 @@ func (a *AnchorScriptTree) WitnessScriptForPath(path ScriptPath,
 
 // CtrlBlockForPath returns the control block for the given spending path. For
 // script types that don't have a control block, nil is returned.
-func (a *AnchorScriptTree) CtrlBlockForPath(path ScriptPath,
-) (*txscript.ControlBlock, error) {
+func (a *AnchorScriptTree) CtrlBlockForPath(
+	path ScriptPath) (*txscript.ControlBlock, error) {
 
 	switch path {
 	case ScriptPathDelay:
@@ -2930,6 +2942,11 @@ func (a *AnchorScriptTree) CtrlBlockForPath(path ScriptPath,
 	}
 }
 
+// Tree returns the underlying ScriptTree of the AnchorScriptTree.
+func (a *AnchorScriptTree) Tree() ScriptTree {
+	return a.ScriptTree
+}
+
 // A compile time check to ensure AnchorScriptTree implements the
 // TapscriptDescriptor interface.
 var _ TapscriptDescriptor = (*AnchorScriptTree)(nil)
diff --git a/lnrpc/lightning.pb.go b/lnrpc/lightning.pb.go
index 46badbe84..e79d7f5ac 100644
--- a/lnrpc/lightning.pb.go
+++ b/lnrpc/lightning.pb.go
@@ -4685,6 +4685,8 @@ type Channel struct {
 	// useful information. This is only ever stored locally and in no way impacts
 	// the channel's operation.
 	Memo string `protobuf:"bytes,36,opt,name=memo,proto3" json:"memo,omitempty"`
+	// Custom channel data that might be populated in custom channels.
+	CustomChannelData []byte `protobuf:"bytes,37,opt,name=custom_channel_data,json=customChannelData,proto3" json:"custom_channel_data,omitempty"`
 }
 
 func (x *Channel) Reset() {
@@ -4975,6 +4977,13 @@ func (x *Channel) GetMemo() string {
 	return ""
 }
 
+func (x *Channel) GetCustomChannelData() []byte {
+	if x != nil {
+		return x.CustomChannelData
+	}
+	return nil
+}
+
 type ListChannelsRequest struct {
 	state         protoimpl.MessageState
 	sizeCache     protoimpl.SizeCache
@@ -9487,6 +9496,9 @@ type ChannelBalanceResponse struct {
 	PendingOpenLocalBalance *Amount `protobuf:"bytes,7,opt,name=pending_open_local_balance,json=pendingOpenLocalBalance,proto3" json:"pending_open_local_balance,omitempty"`
 	// Sum of channels pending remote balances.
 	PendingOpenRemoteBalance *Amount `protobuf:"bytes,8,opt,name=pending_open_remote_balance,json=pendingOpenRemoteBalance,proto3" json:"pending_open_remote_balance,omitempty"`
+	// Custom channel data that might be populated if there are custom channels
+	// present.
+	CustomChannelData []byte `protobuf:"bytes,9,opt,name=custom_channel_data,json=customChannelData,proto3" json:"custom_channel_data,omitempty"`
 }
 
 func (x *ChannelBalanceResponse) Reset() {
@@ -9579,6 +9591,13 @@ func (x *ChannelBalanceResponse) GetPendingOpenRemoteBalance() *Amount {
 	return nil
 }
 
+func (x *ChannelBalanceResponse) GetCustomChannelData() []byte {
+	if x != nil {
+		return x.CustomChannelData
+	}
+	return nil
+}
+
 type QueryRoutesRequest struct {
 	state         protoimpl.MessageState
 	sizeCache     protoimpl.SizeCache
@@ -17408,6 +17427,8 @@ type PendingChannelsResponse_PendingChannel struct {
 	// useful information. This is only ever stored locally and in no way
 	// impacts the channel's operation.
 	Memo string `protobuf:"bytes,13,opt,name=memo,proto3" json:"memo,omitempty"`
+	// Custom channel data that might be populated in custom channels.
+	CustomChannelData []byte `protobuf:"bytes,34,opt,name=custom_channel_data,json=customChannelData,proto3" json:"custom_channel_data,omitempty"`
 }
 
 func (x *PendingChannelsResponse_PendingChannel) Reset() {
@@ -17533,6 +17554,13 @@ func (x *PendingChannelsResponse_PendingChannel) GetMemo() string {
 	return ""
 }
 
+func (x *PendingChannelsResponse_PendingChannel) GetCustomChannelData() []byte {
+	if x != nil {
+		return x.CustomChannelData
+	}
+	return nil
+}
+
 type PendingChannelsResponse_PendingOpenChannel struct {
 	state         protoimpl.MessageState
 	sizeCache     protoimpl.SizeCache
@@ -18415,7 +18443,7 @@ var file_lightning_proto_rawDesc = []byte{
 	0x73, 0x61, 0x74, 0x12, 0x2c, 0x0a, 0x12, 0x6d, 0x61, 0x78, 0x5f, 0x61, 0x63, 0x63, 0x65, 0x70,
 	0x74, 0x65, 0x64, 0x5f, 0x68, 0x74, 0x6c, 0x63, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0d, 0x52,
 	0x10, 0x6d, 0x61, 0x78, 0x41, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x48, 0x74, 0x6c, 0x63,
-	0x73, 0x22, 0xad, 0x0b, 0x0a, 0x07, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x12, 0x16, 0x0a,
+	0x73, 0x22, 0xdd, 0x0b, 0x0a, 0x07, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x12, 0x16, 0x0a,
 	0x06, 0x61, 0x63, 0x74, 0x69, 0x76, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x61,
 	0x63, 0x74, 0x69, 0x76, 0x65, 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x5f,
 	0x70, 0x75, 0x62, 0x6b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x72, 0x65,
@@ -18506,7 +18534,10 @@ var file_lightning_proto_rawDesc = []byte{
 	0x6c, 0x69, 0x61, 0x73, 0x18, 0x23, 0x20, 0x01, 0x28, 0x04, 0x42, 0x02, 0x30, 0x01, 0x52, 0x0d,
 	0x70, 0x65, 0x65, 0x72, 0x53, 0x63, 0x69, 0x64, 0x41, 0x6c, 0x69, 0x61, 0x73, 0x12, 0x12, 0x0a,
 	0x04, 0x6d, 0x65, 0x6d, 0x6f, 0x18, 0x24, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6d, 0x65, 0x6d,
-	0x6f, 0x22, 0xdf, 0x01, 0x0a, 0x13, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65,
+	0x6f, 0x12, 0x2e, 0x0a, 0x13, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5f, 0x63, 0x68, 0x61, 0x6e,
+	0x6e, 0x65, 0x6c, 0x5f, 0x64, 0x61, 0x74, 0x61, 0x18, 0x25, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x11,
+	0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x44, 0x61, 0x74,
+	0x61, 0x22, 0xdf, 0x01, 0x0a, 0x13, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65,
 	0x6c, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x61, 0x63, 0x74,
 	0x69, 0x76, 0x65, 0x5f, 0x6f, 0x6e, 0x6c, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a,
 	0x61, 0x63, 0x74, 0x69, 0x76, 0x65, 0x4f, 0x6e, 0x6c, 0x79, 0x12, 0x23, 0x0a, 0x0d, 0x69, 0x6e,
@@ -19089,7 +19120,7 @@ var file_lightning_proto_rawDesc = []byte{
 	0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x24,
 	0x0a, 0x0e, 0x69, 0x6e, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x5f, 0x72, 0x61, 0x77, 0x5f, 0x74, 0x78,
 	0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x69, 0x6e, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x52,
-	0x61, 0x77, 0x54, 0x78, 0x22, 0xe1, 0x13, 0x0a, 0x17, 0x50, 0x65, 0x6e, 0x64, 0x69, 0x6e, 0x67,
+	0x61, 0x77, 0x54, 0x78, 0x22, 0x91, 0x14, 0x0a, 0x17, 0x50, 0x65, 0x6e, 0x64, 0x69, 0x6e, 0x67,
 	0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
 	0x12, 0x2e, 0x0a, 0x13, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x6c, 0x69, 0x6d, 0x62, 0x6f, 0x5f,
 	0x62, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x11, 0x74,
@@ -19121,7 +19152,7 @@ var file_lightning_proto_rawDesc = []byte{
 	0x65, 0x6c, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x57, 0x61, 0x69, 0x74,
 	0x69, 0x6e, 0x67, 0x43, 0x6c, 0x6f, 0x73, 0x65, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x52,
 	0x14, 0x77, 0x61, 0x69, 0x74, 0x69, 0x6e, 0x67, 0x43, 0x6c, 0x6f, 0x73, 0x65, 0x43, 0x68, 0x61,
-	0x6e, 0x6e, 0x65, 0x6c, 0x73, 0x1a, 0xb3, 0x04, 0x0a, 0x0e, 0x50, 0x65, 0x6e, 0x64, 0x69, 0x6e,
+	0x6e, 0x6e, 0x65, 0x6c, 0x73, 0x1a, 0xe3, 0x04, 0x0a, 0x0e, 0x50, 0x65, 0x6e, 0x64, 0x69, 0x6e,
 	0x67, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x12, 0x26, 0x0a, 0x0f, 0x72, 0x65, 0x6d, 0x6f,
 	0x74, 0x65, 0x5f, 0x6e, 0x6f, 0x64, 0x65, 0x5f, 0x70, 0x75, 0x62, 0x18, 0x01, 0x20, 0x01, 0x28,
 	0x09, 0x52, 0x0d, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x4e, 0x6f, 0x64, 0x65, 0x50, 0x75, 0x62,
@@ -19156,7 +19187,10 @@ var file_lightning_proto_rawDesc = []byte{
 	0x61, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x46, 0x6c, 0x61, 0x67, 0x73, 0x12, 0x18, 0x0a,
 	0x07, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07,
 	0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6d, 0x65, 0x6d, 0x6f, 0x18,
-	0x0d, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6d, 0x65, 0x6d, 0x6f, 0x1a, 0xf9, 0x01, 0x0a, 0x12,
+	0x0d, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6d, 0x65, 0x6d, 0x6f, 0x12, 0x2e, 0x0a, 0x13, 0x63,
+	0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5f, 0x63, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x5f, 0x64, 0x61,
+	0x74, 0x61, 0x18, 0x22, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x11, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d,
+	0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x44, 0x61, 0x74, 0x61, 0x1a, 0xf9, 0x01, 0x0a, 0x12,
 	0x50, 0x65, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x4f, 0x70, 0x65, 0x6e, 0x43, 0x68, 0x61, 0x6e, 0x6e,
 	0x65, 0x6c, 0x12, 0x47, 0x0a, 0x07, 0x63, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x18, 0x01, 0x20,
 	0x01, 0x28, 0x0b, 0x32, 0x2d, 0x2e, 0x6c, 0x6e, 0x72, 0x70, 0x63, 0x2e, 0x50, 0x65, 0x6e, 0x64,
@@ -19334,7 +19368,7 @@ var file_lightning_proto_rawDesc = []byte{
 	0x28, 0x04, 0x52, 0x03, 0x73, 0x61, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x6d, 0x73, 0x61, 0x74, 0x18,
 	0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x04, 0x6d, 0x73, 0x61, 0x74, 0x22, 0x17, 0x0a, 0x15, 0x43,
 	0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x52, 0x65, 0x71,
-	0x75, 0x65, 0x73, 0x74, 0x22, 0x80, 0x04, 0x0a, 0x16, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c,
+	0x75, 0x65, 0x73, 0x74, 0x22, 0xb0, 0x04, 0x0a, 0x16, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c,
 	0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
 	0x1c, 0x0a, 0x07, 0x62, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03,
 	0x42, 0x02, 0x18, 0x01, 0x52, 0x07, 0x62, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x12, 0x34, 0x0a,
@@ -19366,7 +19400,10 @@ var file_lightning_proto_rawDesc = []byte{
 	0x5f, 0x62, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0d,
 	0x2e, 0x6c, 0x6e, 0x72, 0x70, 0x63, 0x2e, 0x41, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x52, 0x18, 0x70,
 	0x65, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x4f, 0x70, 0x65, 0x6e, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65,
-	0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x22, 0x9a, 0x07, 0x0a, 0x12, 0x51, 0x75, 0x65, 0x72,
+	0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x12, 0x2e, 0x0a, 0x13, 0x63, 0x75, 0x73, 0x74, 0x6f,
+	0x6d, 0x5f, 0x63, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x5f, 0x64, 0x61, 0x74, 0x61, 0x18, 0x09,
+	0x20, 0x01, 0x28, 0x0c, 0x52, 0x11, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x43, 0x68, 0x61, 0x6e,
+	0x6e, 0x65, 0x6c, 0x44, 0x61, 0x74, 0x61, 0x22, 0x9a, 0x07, 0x0a, 0x12, 0x51, 0x75, 0x65, 0x72,
 	0x79, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x17,
 	0x0a, 0x07, 0x70, 0x75, 0x62, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
 	0x06, 0x70, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x61, 0x6d, 0x74, 0x18, 0x02,
diff --git a/lnrpc/lightning.proto b/lnrpc/lightning.proto
index 7a343b491..2b2a5320f 100644
--- a/lnrpc/lightning.proto
+++ b/lnrpc/lightning.proto
@@ -1590,6 +1590,11 @@ message Channel {
     the channel's operation.
     */
     string memo = 36;
+
+    /*
+    Custom channel data that might be populated in custom channels.
+    */
+    bytes custom_channel_data = 37;
 }
 
 message ListChannelsRequest {
@@ -2707,6 +2712,11 @@ message PendingChannelsResponse {
         impacts the channel's operation.
         */
         string memo = 13;
+
+        /*
+        Custom channel data that might be populated in custom channels.
+        */
+        bytes custom_channel_data = 34;
     }
 
     message PendingOpenChannel {
@@ -2966,6 +2976,12 @@ message ChannelBalanceResponse {
 
     // Sum of channels pending remote balances.
     Amount pending_open_remote_balance = 8;
+
+    /*
+    Custom channel data that might be populated if there are custom channels
+    present.
+    */
+    bytes custom_channel_data = 9;
 }
 
 message QueryRoutesRequest {
diff --git a/lnrpc/lightning.swagger.json b/lnrpc/lightning.swagger.json
index 3c1b2a810..cee0858da 100644
--- a/lnrpc/lightning.swagger.json
+++ b/lnrpc/lightning.swagger.json
@@ -3111,6 +3111,11 @@
         "memo": {
           "type": "string",
           "description": "An optional note-to-self to go along with the channel containing some\nuseful information. This is only ever stored locally and in no way\nimpacts the channel's operation."
+        },
+        "custom_channel_data": {
+          "type": "string",
+          "format": "byte",
+          "description": "Custom channel data that might be populated in custom channels."
         }
       }
     },
@@ -3805,6 +3810,11 @@
         "memo": {
           "type": "string",
           "description": "An optional note-to-self to go along with the channel containing some\nuseful information. This is only ever stored locally and in no way impacts\nthe channel's operation."
+        },
+        "custom_channel_data": {
+          "type": "string",
+          "format": "byte",
+          "description": "Custom channel data that might be populated in custom channels."
         }
       }
     },
@@ -4008,6 +4018,11 @@
         "pending_open_remote_balance": {
           "$ref": "#/definitions/lnrpcAmount",
           "description": "Sum of channels pending remote balances."
+        },
+        "custom_channel_data": {
+          "type": "string",
+          "format": "byte",
+          "description": "Custom channel data that might be populated if there are custom channels\npresent."
         }
       }
     },
diff --git a/lnwallet/channel.go b/lnwallet/channel.go
index 1fbfeee89..fae68ff6c 100644
--- a/lnwallet/channel.go
+++ b/lnwallet/channel.go
@@ -337,7 +337,7 @@ type PaymentDescriptor struct {
 
 	// CustomRecords also stores the set of optional custom records that
 	// may have been attached to a sent HTLC.
-	CustomRecords fn.Option[tlv.Blob]
+	CustomRecords lnwire.CustomRecords
 
 	// ShaOnionBlob is a sha of the onion blob.
 	//
@@ -385,6 +385,30 @@ type PaymentDescriptor struct {
 	BlindingPoint lnwire.BlindingPointRecord
 }
 
+// AddHeight returns a pointer to the height at which the HTLC was added to the
+// commitment chain. The height is returned based on the chain the HTLC is
+// being added to (local or remote chain). It is returned as a pointer, which
+// allows the caller to mutate the underlying value if necessary.
+func AddHeight(htlc *PaymentDescriptor, remoteChain bool) *uint64 {
+	if remoteChain {
+		return &htlc.addCommitHeightRemote
+	}
+
+	return &htlc.addCommitHeightLocal
+}
+
+// RemoveHeight returns a pointer to the height at which the HTLC was removed
+// from the commitment chain. The height is returned based on the chain the HTLC
+// is being removed from (local or remote chain). It is returned as a pointer,
+// which allows the caller to mutate the underlying value if necessary.
+func RemoveHeight(htlc *PaymentDescriptor, remoteChain bool) *uint64 {
+	if remoteChain {
+		return &htlc.removeCommitHeightRemote
+	}
+
+	return &htlc.removeCommitHeightLocal
+}
+
 // PayDescsFromRemoteLogUpdates converts a slice of LogUpdates received from the
 // remote peer into PaymentDescriptors to inform a link's forwarding decisions.
 //
@@ -424,21 +448,11 @@ func PayDescsFromRemoteLogUpdates(chanID lnwire.ShortChannelID, height uint64,
 					Index:  uint16(i),
 				},
 				BlindingPoint: pd.BlindingPoint,
+				CustomRecords: wireMsg.CustomRecords,
 			}
 			pd.OnionBlob = make([]byte, len(wireMsg.OnionBlob))
 			copy(pd.OnionBlob[:], wireMsg.OnionBlob[:])
 
-			if len(wireMsg.CustomRecords) > 0 {
-				b, err := wireMsg.CustomRecords.Serialize()
-				if err != nil {
-					return nil, fmt.Errorf("error "+
-						"serializing custom records: "+
-						"%w", err)
-				}
-
-				pd.CustomRecords = fn.Some[tlv.Blob](b)
-			}
-
 		case *lnwire.UpdateFulfillHTLC:
 			pd = PaymentDescriptor{
 				RPreimage:   wireMsg.PaymentPreimage,
@@ -766,27 +780,10 @@ func (c *commitment) toDiskCommit(ourCommit bool) (*channeldb.ChannelCommitment,
 			LogIndex:      htlc.LogIndex,
 			Incoming:      false,
 			BlindingPoint: htlc.BlindingPoint,
+			CustomRecords: htlc.CustomRecords,
 		}
 		copy(h.OnionBlob[:], htlc.OnionBlob)
 
-		// If the HTLC had custom records, then we'll copy that over, so
-		// we restore with the same information.
-		err := fn.MapOptionZ(
-			htlc.CustomRecords, func(b tlv.Blob) error {
-				r, err := lnwire.ParseCustomRecords(b)
-				if err != nil {
-					return err
-				}
-
-				h.CustomRecords = r
-
-				return nil
-			},
-		)
-		if err != nil {
-			return nil, err
-		}
-
 		if ourCommit && htlc.sig != nil {
 			h.Signature = htlc.sig.Serialize()
 		}
@@ -809,27 +806,10 @@ func (c *commitment) toDiskCommit(ourCommit bool) (*channeldb.ChannelCommitment,
 			LogIndex:      htlc.LogIndex,
 			Incoming:      true,
 			BlindingPoint: htlc.BlindingPoint,
+			CustomRecords: htlc.CustomRecords,
 		}
 		copy(h.OnionBlob[:], htlc.OnionBlob)
 
-		// If the HTLC had custom records, then we'll copy that over, so
-		// we restore with the same information.
-		err := fn.MapOptionZ(
-			htlc.CustomRecords, func(b tlv.Blob) error {
-				r, err := lnwire.ParseCustomRecords(b)
-				if err != nil {
-					return err
-				}
-
-				h.CustomRecords = r
-
-				return nil
-			},
-		)
-		if err != nil {
-			return nil, err
-		}
-
 		if ourCommit && htlc.sig != nil {
 			h.Signature = htlc.sig.Serialize()
 		}
@@ -927,16 +907,7 @@ func (lc *LightningChannel) diskHtlcToPayDesc(feeRate chainfee.SatPerKWeight,
 		theirPkScript:      theirP2WSH,
 		theirWitnessScript: theirWitnessScript,
 		BlindingPoint:      htlc.BlindingPoint,
-	}
-
-	// Ensure that we'll restore any custom records which were stored as
-	// extra data on disk.
-	if len(htlc.CustomRecords) != 0 {
-		b, err := htlc.CustomRecords.Serialize()
-		if err != nil {
-			return pd, err
-		}
-		pd.CustomRecords = fn.Some[tlv.Blob](b)
+		CustomRecords:      htlc.CustomRecords,
 	}
 
 	return pd, nil
@@ -1691,20 +1662,11 @@ func (lc *LightningChannel) logUpdateToPayDesc(logUpdate *channeldb.LogUpdate,
 			LogIndex:              logUpdate.LogIndex,
 			addCommitHeightRemote: commitHeight,
 			BlindingPoint:         wireMsg.BlindingPoint,
+			CustomRecords:         wireMsg.CustomRecords,
 		}
 		pd.OnionBlob = make([]byte, len(wireMsg.OnionBlob))
 		copy(pd.OnionBlob[:], wireMsg.OnionBlob[:])
 
-		if len(wireMsg.CustomRecords) > 0 {
-			b, err := wireMsg.CustomRecords.Serialize()
-			if err != nil {
-				return nil, fmt.Errorf("error serializing "+
-					"custom records: %w", err)
-			}
-
-			pd.CustomRecords = fn.Some[tlv.Blob](b)
-		}
-
 		isDustRemote := HtlcIsDust(
 			lc.channelState.ChanType, false, false, feeRate,
 			wireMsg.Amount.ToSatoshis(), remoteDustLimit,
@@ -1906,20 +1868,11 @@ func (lc *LightningChannel) remoteLogUpdateToPayDesc(logUpdate *channeldb.LogUpd
 			LogIndex:             logUpdate.LogIndex,
 			addCommitHeightLocal: commitHeight,
 			BlindingPoint:        wireMsg.BlindingPoint,
+			CustomRecords:        wireMsg.CustomRecords,
 		}
 		pd.OnionBlob = make([]byte, len(wireMsg.OnionBlob))
 		copy(pd.OnionBlob, wireMsg.OnionBlob[:])
 
-		if len(wireMsg.CustomRecords) > 0 {
-			b, err := wireMsg.CustomRecords.Serialize()
-			if err != nil {
-				return nil, fmt.Errorf("error serializing "+
-					"custom records: %w", err)
-			}
-
-			pd.CustomRecords = fn.Some[tlv.Blob](b)
-		}
-
 		// We don't need to generate an htlc script yet. This will be
 		// done once we sign our remote commitment.
 
@@ -3530,13 +3483,7 @@ func processAddEntry(htlc *PaymentDescriptor, ourBalance, theirBalance *lnwire.M
 	// a new commitment), then we'll may be updating the height this entry
 	// was added to the chain. Otherwise, we may be updating the entry's
 	// height w.r.t the local chain.
-	var addHeight *uint64
-	if remoteChain {
-		addHeight = &htlc.addCommitHeightRemote
-	} else {
-		addHeight = &htlc.addCommitHeightLocal
-	}
-
+	addHeight := AddHeight(htlc, remoteChain)
 	if *addHeight != 0 {
 		return
 	}
@@ -3567,14 +3514,8 @@ func processRemoveEntry(htlc *PaymentDescriptor, ourBalance,
 	theirBalance *lnwire.MilliSatoshi, nextHeight uint64,
 	remoteChain bool, isIncoming, mutateState bool) {
 
-	var removeHeight *uint64
-	if remoteChain {
-		removeHeight = &htlc.removeCommitHeightRemote
-	} else {
-		removeHeight = &htlc.removeCommitHeightLocal
-	}
-
 	// Ignore any removal entries which have already been processed.
+	removeHeight := RemoveHeight(htlc, remoteChain)
 	if *removeHeight != 0 {
 		return
 	}
@@ -3618,15 +3559,8 @@ func processFeeUpdate(feeUpdate *PaymentDescriptor, nextHeight uint64,
 	// Fee updates are applied for all commitments after they are
 	// sent/received, so we consider them being added and removed at the
 	// same height.
-	var addHeight *uint64
-	var removeHeight *uint64
-	if remoteChain {
-		addHeight = &feeUpdate.addCommitHeightRemote
-		removeHeight = &feeUpdate.removeCommitHeightRemote
-	} else {
-		addHeight = &feeUpdate.addCommitHeightLocal
-		removeHeight = &feeUpdate.removeCommitHeightLocal
-	}
+	addHeight := AddHeight(feeUpdate, remoteChain)
+	removeHeight := RemoveHeight(feeUpdate, remoteChain)
 
 	if *addHeight != 0 {
 		return
@@ -3928,29 +3862,11 @@ func (lc *LightningChannel) createCommitDiff(newCommit *commitment,
 				Expiry:        pd.Timeout,
 				PaymentHash:   pd.RHash,
 				BlindingPoint: pd.BlindingPoint,
+				CustomRecords: pd.CustomRecords,
 			}
 			copy(htlc.OnionBlob[:], pd.OnionBlob)
 			logUpdate.UpdateMsg = htlc
 
-			// Copy over any custom records as extra data that we
-			// may not explicitly know about.
-			err := fn.MapOptionZ(
-				pd.CustomRecords, func(b tlv.Blob) error {
-					r, err := lnwire.ParseCustomRecords(b)
-					if err != nil {
-						return err
-					}
-
-					htlc.CustomRecords = r
-
-					return nil
-				},
-			)
-			if err != nil {
-				return nil, fmt.Errorf("error mapping custom "+
-					"records: %w", err)
-			}
-
 			// Gather any references for circuits opened by this Add
 			// HTLC.
 			if pd.OpenCircuitKey != nil {
@@ -4047,8 +3963,7 @@ func (lc *LightningChannel) createCommitDiff(newCommit *commitment,
 
 // getUnsignedAckedUpdates returns all remote log updates that we haven't
 // signed for yet ourselves.
-func (lc *LightningChannel) getUnsignedAckedUpdates() ([]channeldb.LogUpdate,
-	error) {
+func (lc *LightningChannel) getUnsignedAckedUpdates() []channeldb.LogUpdate {
 
 	// First, we need to convert the funding outpoint into the ID that's
 	// used on the wire to identify this channel.
@@ -4097,28 +4012,10 @@ func (lc *LightningChannel) getUnsignedAckedUpdates() ([]channeldb.LogUpdate,
 				Expiry:        pd.Timeout,
 				PaymentHash:   pd.RHash,
 				BlindingPoint: pd.BlindingPoint,
+				CustomRecords: pd.CustomRecords,
 			}
 			copy(htlc.OnionBlob[:], pd.OnionBlob)
 
-			// Copy over any custom records as extra data that we
-			// may not explicitly know about.
-			err := fn.MapOptionZ(
-				pd.CustomRecords, func(b tlv.Blob) error {
-					r, err := lnwire.ParseCustomRecords(b)
-					if err != nil {
-						return err
-					}
-
-					htlc.CustomRecords = r
-
-					return nil
-				},
-			)
-			if err != nil {
-				return nil, fmt.Errorf("error mapping custom "+
-					"records: %w", err)
-			}
-
 			logUpdate.UpdateMsg = htlc
 
 		case Settle:
@@ -4156,7 +4053,7 @@ func (lc *LightningChannel) getUnsignedAckedUpdates() ([]channeldb.LogUpdate,
 		logUpdates = append(logUpdates, logUpdate)
 	}
 
-	return logUpdates, nil
+	return logUpdates
 }
 
 // CalcFeeBuffer calculates a FeeBuffer in accordance with the recommended
@@ -5156,10 +5053,12 @@ func (lc *LightningChannel) computeView(view *HtlcView, remoteChain bool,
 	// number of outstanding HTLCs has changed.
 	if lc.channelState.IsInitiator {
 		ourBalance += lnwire.NewMSatFromSatoshis(
-			commitChain.tip().fee)
+			commitChain.tip().fee,
+		)
 	} else if !lc.channelState.IsInitiator {
 		theirBalance += lnwire.NewMSatFromSatoshis(
-			commitChain.tip().fee)
+			commitChain.tip().fee,
+		)
 	}
 	nextHeight := commitChain.tip().height + 1
 
@@ -5167,9 +5066,7 @@ func (lc *LightningChannel) computeView(view *HtlcView, remoteChain bool,
 	// need this to determine which HTLCs are dust, and also the final fee
 	// rate.
 	view.FeePerKw = commitChain.tip().feePerKw
-
-	// TODO(roasbeef): also need to pass blob here as well for final
-	// balances?
+	view.NextHeight = nextHeight
 
 	// We evaluate the view at this stage, meaning settled and failed HTLCs
 	// will remove their corresponding added HTLCs.  The resulting filtered
@@ -6059,10 +5956,7 @@ func (lc *LightningChannel) RevokeCurrentCommitment() (*lnwire.RevokeAndAck,
 	// Get the unsigned acked remotes updates that are currently in memory.
 	// We need them after a restart to sync our remote commitment with what
 	// is committed locally.
-	unsignedAckedUpdates, err := lc.getUnsignedAckedUpdates()
-	if err != nil {
-		return nil, nil, nil, err
-	}
+	unsignedAckedUpdates := lc.getUnsignedAckedUpdates()
 
 	finalHtlcs, err := lc.channelState.UpdateCommitment(
 		newCommitment, unsignedAckedUpdates,
@@ -6249,28 +6143,10 @@ func (lc *LightningChannel) ReceiveRevocation(revMsg *lnwire.RevokeAndAck) (
 				Expiry:        pd.Timeout,
 				PaymentHash:   pd.RHash,
 				BlindingPoint: pd.BlindingPoint,
+				CustomRecords: pd.CustomRecords,
 			}
 			copy(htlc.OnionBlob[:], pd.OnionBlob)
 
-			// Copy over any custom records as extra data that we
-			// may not explicitly know about.
-			err := fn.MapOptionZ(
-				pd.CustomRecords, func(b tlv.Blob) error {
-					r, err := lnwire.ParseCustomRecords(b)
-					if err != nil {
-						return err
-					}
-
-					htlc.CustomRecords = r
-
-					return nil
-				},
-			)
-			if err != nil {
-				return nil, nil, nil, nil, fmt.Errorf("error "+
-					"mapping custom records: %w", err)
-			}
-
 			logUpdate.UpdateMsg = htlc
 			addUpdates = append(addUpdates, logUpdate)
 
@@ -6472,10 +6348,7 @@ func (lc *LightningChannel) addHTLC(htlc *lnwire.UpdateAddHTLC,
 	lc.Lock()
 	defer lc.Unlock()
 
-	pd, err := lc.htlcAddDescriptor(htlc, openKey)
-	if err != nil {
-		return 0, err
-	}
+	pd := lc.htlcAddDescriptor(htlc, openKey)
 
 	if err := lc.validateAddHtlc(pd, buffer); err != nil {
 		return 0, err
@@ -6580,16 +6453,11 @@ func (lc *LightningChannel) MayAddOutgoingHtlc(amt lnwire.MilliSatoshi) error {
 	// to the commitment so that we validate commitment slots rather than
 	// available balance, since our actual htlc amount is unknown at this
 	// stage.
-	pd, err := lc.htlcAddDescriptor(
+	pd := lc.htlcAddDescriptor(
 		&lnwire.UpdateAddHTLC{
 			Amount: mockHtlcAmt,
-		},
-		&models.CircuitKey{},
+		}, &models.CircuitKey{},
 	)
-	if err != nil {
-		lc.log.Errorf("Error adding htlc descriptor: %v", err)
-		return err
-	}
 
 	// Enforce the FeeBuffer because we are evaluating whether we can add
 	// another htlc to the channel state.
@@ -6604,7 +6472,7 @@ func (lc *LightningChannel) MayAddOutgoingHtlc(amt lnwire.MilliSatoshi) error {
 // htlcAddDescriptor returns a payment descriptor for the htlc and open key
 // provided to add to our local update log.
 func (lc *LightningChannel) htlcAddDescriptor(htlc *lnwire.UpdateAddHTLC,
-	openKey *models.CircuitKey) (*PaymentDescriptor, error) {
+	openKey *models.CircuitKey) *PaymentDescriptor {
 
 	pd := &PaymentDescriptor{
 		EntryType:      Add,
@@ -6616,21 +6484,10 @@ func (lc *LightningChannel) htlcAddDescriptor(htlc *lnwire.UpdateAddHTLC,
 		OnionBlob:      htlc.OnionBlob[:],
 		OpenCircuitKey: openKey,
 		BlindingPoint:  htlc.BlindingPoint,
+		CustomRecords:  htlc.CustomRecords,
 	}
 
-	// Copy over any extra data included to ensure we can forward and
-	// process this HTLC properly.
-	if len(htlc.CustomRecords) > 0 {
-		b, err := htlc.CustomRecords.Serialize()
-		if err != nil {
-			return nil, fmt.Errorf("error serializing custom "+
-				"records: %w", err)
-		}
-
-		pd.CustomRecords = fn.Some[tlv.Blob](b)
-	}
-
-	return pd, nil
+	return pd
 }
 
 // validateAddHtlc validates the addition of an outgoing htlc to our local and
@@ -6670,13 +6527,16 @@ func (lc *LightningChannel) validateAddHtlc(pd *PaymentDescriptor,
 // ReceiveHTLC adds an HTLC to the state machine's remote update log. This
 // method should be called in response to receiving a new HTLC from the remote
 // party.
-func (lc *LightningChannel) ReceiveHTLC(htlc *lnwire.UpdateAddHTLC) (uint64, error) {
+func (lc *LightningChannel) ReceiveHTLC(htlc *lnwire.UpdateAddHTLC) (uint64,
+	error) {
+
 	lc.Lock()
 	defer lc.Unlock()
 
 	if htlc.ID != lc.remoteUpdateLog.htlcCounter {
-		return 0, fmt.Errorf("ID %d on HTLC add does not match expected next "+
-			"ID %d", htlc.ID, lc.remoteUpdateLog.htlcCounter)
+		return 0, fmt.Errorf("ID %d on HTLC add does not match "+
+			"expected next ID %d", htlc.ID,
+			lc.remoteUpdateLog.htlcCounter)
 	}
 
 	pd := &PaymentDescriptor{
@@ -6688,27 +6548,7 @@ func (lc *LightningChannel) ReceiveHTLC(htlc *lnwire.UpdateAddHTLC) (uint64, err
 		HtlcIndex:     lc.remoteUpdateLog.htlcCounter,
 		OnionBlob:     htlc.OnionBlob[:],
 		BlindingPoint: htlc.BlindingPoint,
-	}
-
-	if len(htlc.CustomRecords) > 0 {
-		b, err := htlc.CustomRecords.Serialize()
-		if err != nil {
-			return 0, fmt.Errorf("error serializing custom "+
-				"records: %w", err)
-		}
-
-		pd.CustomRecords = fn.Some[tlv.Blob](b)
-	}
-
-	// Copy over any extra data included to ensure we can forward and
-	// process this HTLC properly.
-	if htlc.ExtraData != nil {
-		var cr []byte
-		pd.CustomRecords.WhenSome(func(b tlv.Blob) {
-			cr = b
-		})
-
-		pd.CustomRecords = fn.Some(append(cr, htlc.ExtraData...))
+		CustomRecords: htlc.CustomRecords,
 	}
 
 	localACKedIndex := lc.remoteCommitChain.tail().ourMessageIndex
@@ -8749,7 +8589,7 @@ func NewAnchorResolution(chanState *channeldb.OpenChannel,
 		WitnessScript: anchorWitnessScript,
 		Output: &wire.TxOut{
 			PkScript: localAnchor.PkScript(),
-			Value:    int64(anchorSize),
+			Value:    int64(AnchorSize),
 		},
 		HashType: sweepSigHash(chanState.ChanType),
 	}
@@ -8761,7 +8601,7 @@ func NewAnchorResolution(chanState *channeldb.OpenChannel,
 
 		//nolint:lll
 		signDesc.PrevOutputFetcher = txscript.NewCannedPrevOutputFetcher(
-			localAnchor.PkScript(), int64(anchorSize),
+			localAnchor.PkScript(), int64(AnchorSize),
 		)
 
 		// For anchor outputs with taproot channels, the key desc is
@@ -9254,7 +9094,7 @@ func (lc *LightningChannel) LocalBalanceDust() bool {
 	// regain the stats allocated to the anchor outputs with the co-op
 	// close transaction.
 	if chanState.ChanType.HasAnchors() && chanState.IsInitiator {
-		localBalance += 2 * anchorSize
+		localBalance += 2 * AnchorSize
 	}
 
 	return localBalance <= chanState.LocalChanCfg.DustLimit
@@ -9274,7 +9114,7 @@ func (lc *LightningChannel) RemoteBalanceDust() bool {
 	// regain the stats allocated to the anchor outputs with the co-op
 	// close transaction.
 	if chanState.ChanType.HasAnchors() && !chanState.IsInitiator {
-		remoteBalance += 2 * anchorSize
+		remoteBalance += 2 * AnchorSize
 	}
 
 	return remoteBalance <= chanState.RemoteChanCfg.DustLimit
diff --git a/lnwallet/commitment.go b/lnwallet/commitment.go
index 61c09d983..082aca631 100644
--- a/lnwallet/commitment.go
+++ b/lnwallet/commitment.go
@@ -18,8 +18,8 @@ import (
 	"github.com/lightningnetwork/lnd/tlv"
 )
 
-// anchorSize is the constant anchor output size.
-const anchorSize = btcutil.Amount(330)
+// AnchorSize is the constant anchor output size.
+const AnchorSize = btcutil.Amount(330)
 
 // DefaultAnchorsCommitMaxFeeRateSatPerVByte is the default max fee rate in
 // sat/vbyte the initiator will use for anchor channels. This should be enough
@@ -201,9 +201,9 @@ func (w *WitnessScriptDesc) PkScript() []byte {
 	return w.OutputScript
 }
 
-// WitnessScript returns the witness script that we'll use when signing for the
-// remote party, and also verifying signatures on our transactions. As an
-// example, when we create an outgoing HTLC for the remote party, we want to
+// WitnessScriptToSign returns the witness script that we'll use when signing
+// for the remote party, and also verifying signatures on our transactions. As
+// an example, when we create an outgoing HTLC for the remote party, we want to
 // sign their success path.
 func (w *WitnessScriptDesc) WitnessScriptToSign() []byte {
 	return w.WitnessScript
@@ -211,10 +211,10 @@ func (w *WitnessScriptDesc) WitnessScriptToSign() []byte {
 
 // WitnessScriptForPath returns the witness script for the given spending path.
 // An error is returned if the path is unknown. This is useful as when
-// constructing a contrl block for a given path, one also needs witness script
+// constructing a control block for a given path, one also needs witness script
 // being signed.
-func (w *WitnessScriptDesc) WitnessScriptForPath(_ input.ScriptPath,
-) ([]byte, error) {
+func (w *WitnessScriptDesc) WitnessScriptForPath(
+	_ input.ScriptPath) ([]byte, error) {
 
 	return w.WitnessScript, nil
 }
@@ -532,8 +532,8 @@ func CommitScriptAnchors(chanType channeldb.ChannelType,
 	input.ScriptDescriptor, input.ScriptDescriptor, error) {
 
 	var (
-		anchorScript func(key *btcec.PublicKey) (
-			input.ScriptDescriptor, error)
+		anchorScript func(
+			key *btcec.PublicKey) (input.ScriptDescriptor, error)
 
 		keySelector func(*channeldb.ChannelConfig,
 			bool) *btcec.PublicKey
@@ -544,12 +544,10 @@ func CommitScriptAnchors(chanType channeldb.ChannelType,
 	// level key is now the (relative) local delay and remote public key,
 	// since these are fully revealed once the commitment hits the chain.
 	case chanType.IsTaproot():
-		anchorScript = func(key *btcec.PublicKey,
-		) (input.ScriptDescriptor, error) {
+		anchorScript = func(
+			key *btcec.PublicKey) (input.ScriptDescriptor, error) {
 
-			return input.NewAnchorScriptTree(
-				key,
-			)
+			return input.NewAnchorScriptTree(key)
 		}
 
 		keySelector = func(cfg *channeldb.ChannelConfig,
@@ -567,8 +565,8 @@ func CommitScriptAnchors(chanType channeldb.ChannelType,
 	default:
 		// For normal channels, we'll create a p2wsh script based on
 		// the target key.
-		anchorScript = func(key *btcec.PublicKey,
-		) (input.ScriptDescriptor, error) {
+		anchorScript = func(
+			key *btcec.PublicKey) (input.ScriptDescriptor, error) {
 
 			script, err := input.CommitScriptAnchor(key)
 			if err != nil {
@@ -615,7 +613,7 @@ func CommitScriptAnchors(chanType channeldb.ChannelType,
 // commitment transaction outputs. The second parameter is a list of CLTV
 // timeouts that must correspond to the number of transaction outputs, with the
 // value of 0 for non-HTLC outputs.
-type CommitSortFunc func(*wire.MsgTx, []uint32) error
+type CommitSortFunc func(*wire.MsgTx, []uint32, []input.HtlcIndex) error
 
 // CommitAuxLeaves stores two potential auxiliary leaves for the remote and
 // local output that may be used to argument the final tapscript trees of the
@@ -1005,6 +1003,7 @@ func (cb *CommitmentBuilder) createUnsignedCommitmentTx(ourBalance,
 	// commitment outputs and should correspond to zero values for the
 	// purposes of sorting.
 	cltvs := make([]uint32, len(commitTx.TxOut))
+	htlcIndexes := make([]input.HtlcIndex, len(commitTx.TxOut))
 	for _, htlc := range filteredHTLCView.OurUpdates {
 		if HtlcIsDust(
 			cb.chanState.ChanType, false, isOurs, feePerKw,
@@ -1027,7 +1026,11 @@ func (cb *CommitmentBuilder) createUnsignedCommitmentTx(ourBalance,
 		if err != nil {
 			return nil, err
 		}
-		cltvs = append(cltvs, htlc.Timeout) // nolint:makezero
+
+		// We want to add the CLTV and HTLC index to their respective
+		// slices, even if we already pre-allocated them.
+		cltvs = append(cltvs, htlc.Timeout)               //nolint
+		htlcIndexes = append(htlcIndexes, htlc.HtlcIndex) //nolint
 	}
 	for _, htlc := range filteredHTLCView.TheirUpdates {
 		if HtlcIsDust(
@@ -1051,7 +1054,11 @@ func (cb *CommitmentBuilder) createUnsignedCommitmentTx(ourBalance,
 		if err != nil {
 			return nil, err
 		}
-		cltvs = append(cltvs, htlc.Timeout) // nolint:makezero
+
+		// We want to add the CLTV and HTLC index to their respective
+		// slices, even if we already pre-allocated them.
+		cltvs = append(cltvs, htlc.Timeout)               //nolint
+		htlcIndexes = append(htlcIndexes, htlc.HtlcIndex) //nolint
 	}
 
 	// Set the state hint of the commitment transaction to facilitate
@@ -1073,7 +1080,7 @@ func (cb *CommitmentBuilder) createUnsignedCommitmentTx(ourBalance,
 				"sorting function")
 		}
 
-		err = customCommitSort(commitTx, cltvs)
+		err = customCommitSort(commitTx, cltvs, htlcIndexes)
 		if err != nil {
 			return nil, fmt.Errorf("unable to sort commitment "+
 				"transaction by custom order: %w", err)
@@ -1191,7 +1198,7 @@ func CreateCommitTx(chanType channeldb.ChannelType,
 		if localOutput || numHTLCs > 0 {
 			commitTx.AddTxOut(&wire.TxOut{
 				PkScript: localAnchor.PkScript(),
-				Value:    int64(anchorSize),
+				Value:    int64(AnchorSize),
 			})
 		}
 
@@ -1200,7 +1207,7 @@ func CreateCommitTx(chanType channeldb.ChannelType,
 		if remoteOutput || numHTLCs > 0 {
 			commitTx.AddTxOut(&wire.TxOut{
 				PkScript: remoteAnchor.PkScript(),
-				Value:    int64(anchorSize),
+				Value:    int64(AnchorSize),
 			})
 		}
 	}
@@ -1225,7 +1232,7 @@ func CoopCloseBalance(chanType channeldb.ChannelType, isInitiator bool,
 	// Since the initiator's balance also is stored after subtracting the
 	// anchor values, add that back in case this was an anchor commitment.
 	if chanType.HasAnchors() {
-		initiatorDelta += 2 * anchorSize
+		initiatorDelta += 2 * AnchorSize
 	}
 
 	// The initiator will pay the full coop close fee, subtract that value
@@ -1323,9 +1330,9 @@ func genSegwitV0HtlcScript(chanType channeldb.ChannelType,
 	}, nil
 }
 
-// genTaprootHtlcScript generates the HTLC scripts for a taproot+musig2
+// GenTaprootHtlcScript generates the HTLC scripts for a taproot+musig2
 // channel.
-func genTaprootHtlcScript(isIncoming, ourCommit bool, timeout uint32,
+func GenTaprootHtlcScript(isIncoming, ourCommit bool, timeout uint32,
 	rHash [32]byte, keyRing *CommitmentKeyRing,
 	auxLeaf input.AuxTapLeaf) (*input.HtlcScriptTree, error) {
 
@@ -1395,7 +1402,7 @@ func genHtlcScript(chanType channeldb.ChannelType, isIncoming, ourCommit bool,
 		)
 	}
 
-	return genTaprootHtlcScript(
+	return GenTaprootHtlcScript(
 		isIncoming, ourCommit, timeout, rHash, keyRing, auxLeaf,
 	)
 }
diff --git a/lnwallet/reservation.go b/lnwallet/reservation.go
index 91cbc4dcd..d6c6da77b 100644
--- a/lnwallet/reservation.go
+++ b/lnwallet/reservation.go
@@ -259,7 +259,7 @@ func NewChannelReservation(capacity, localFundingAmt btcutil.Amount,
 	// addition to the two anchor outputs.
 	feeMSat := lnwire.NewMSatFromSatoshis(commitFee)
 	if req.CommitType.HasAnchors() {
-		feeMSat += 2 * lnwire.NewMSatFromSatoshis(anchorSize)
+		feeMSat += 2 * lnwire.NewMSatFromSatoshis(AnchorSize)
 	}
 
 	// Used to cut down on verbosity.
diff --git a/rpcserver.go b/rpcserver.go
index 7f3364077..344250501 100644
--- a/rpcserver.go
+++ b/rpcserver.go
@@ -46,6 +46,7 @@ import (
 	"github.com/lightningnetwork/lnd/contractcourt"
 	"github.com/lightningnetwork/lnd/discovery"
 	"github.com/lightningnetwork/lnd/feature"
+	"github.com/lightningnetwork/lnd/fn"
 	"github.com/lightningnetwork/lnd/funding"
 	"github.com/lightningnetwork/lnd/htlcswitch"
 	"github.com/lightningnetwork/lnd/htlcswitch/hop"
@@ -82,6 +83,7 @@ import (
 	"google.golang.org/grpc"
 	"google.golang.org/grpc/codes"
 	"google.golang.org/grpc/status"
+	"google.golang.org/protobuf/proto"
 	"gopkg.in/macaroon-bakery.v2/bakery"
 )
 
@@ -567,6 +569,17 @@ func MainRPCServerPermissions() map[string][]bakery.Op {
 	}
 }
 
+// AuxDataParser is an interface that is used to parse auxiliary custom data
+// within RPC messages. This is used to transform binary blobs to human-readable
+// JSON representations.
+type AuxDataParser interface {
+	// InlineParseCustomData replaces any custom data binary blob in the
+	// given RPC message with its corresponding JSON formatted data. This
+	// transforms the binary (likely TLV encoded) data to a human-readable
+	// JSON representation (still as byte slice).
+	InlineParseCustomData(msg proto.Message) error
+}
+
 // rpcServer is a gRPC, RPC front end to the lnd daemon.
 // TODO(roasbeef): pagination support for the list-style calls
 type rpcServer struct {
@@ -3477,6 +3490,7 @@ func (r *rpcServer) ChannelBalance(ctx context.Context,
 		unsettledRemoteBalance   lnwire.MilliSatoshi
 		pendingOpenLocalBalance  lnwire.MilliSatoshi
 		pendingOpenRemoteBalance lnwire.MilliSatoshi
+		customDataBuf            bytes.Buffer
 	)
 
 	openChannels, err := r.server.chanStateDB.FetchAllOpenChannels()
@@ -3484,6 +3498,12 @@ func (r *rpcServer) ChannelBalance(ctx context.Context,
 		return nil, err
 	}
 
+	// Encode the number of open channels to the custom data buffer.
+	err = wire.WriteVarInt(&customDataBuf, 0, uint64(len(openChannels)))
+	if err != nil {
+		return nil, err
+	}
+
 	for _, channel := range openChannels {
 		c := channel.LocalCommitment
 		localBalance += c.LocalBalance
@@ -3497,6 +3517,13 @@ func (r *rpcServer) ChannelBalance(ctx context.Context,
 				unsettledRemoteBalance += htlc.Amt
 			}
 		}
+
+		// Encode the custom data for this open channel.
+		openChanData := channel.LocalCommitment.CustomBlob.UnwrapOr(nil)
+		err = wire.WriteVarBytes(&customDataBuf, 0, openChanData)
+		if err != nil {
+			return nil, err
+		}
 	}
 
 	pendingChannels, err := r.server.chanStateDB.FetchPendingChannels()
@@ -3504,10 +3531,23 @@ func (r *rpcServer) ChannelBalance(ctx context.Context,
 		return nil, err
 	}
 
+	// Encode the number of pending channels to the custom data buffer.
+	err = wire.WriteVarInt(&customDataBuf, 0, uint64(len(pendingChannels)))
+	if err != nil {
+		return nil, err
+	}
+
 	for _, channel := range pendingChannels {
 		c := channel.LocalCommitment
 		pendingOpenLocalBalance += c.LocalBalance
 		pendingOpenRemoteBalance += c.RemoteBalance
+
+		// Encode the custom data for this pending channel.
+		openChanData := channel.LocalCommitment.CustomBlob.UnwrapOr(nil)
+		err = wire.WriteVarBytes(&customDataBuf, 0, openChanData)
+		if err != nil {
+			return nil, err
+		}
 	}
 
 	rpcsLog.Debugf("[channelbalance] local_balance=%v remote_balance=%v "+
@@ -3517,7 +3557,7 @@ func (r *rpcServer) ChannelBalance(ctx context.Context,
 		unsettledRemoteBalance, pendingOpenLocalBalance,
 		pendingOpenRemoteBalance)
 
-	return &lnrpc.ChannelBalanceResponse{
+	resp := &lnrpc.ChannelBalanceResponse{
 		LocalBalance: &lnrpc.Amount{
 			Sat:  uint64(localBalance.ToSatoshis()),
 			Msat: uint64(localBalance),
@@ -3542,11 +3582,24 @@ func (r *rpcServer) ChannelBalance(ctx context.Context,
 			Sat:  uint64(pendingOpenRemoteBalance.ToSatoshis()),
 			Msat: uint64(pendingOpenRemoteBalance),
 		},
+		CustomChannelData: customDataBuf.Bytes(),
 
 		// Deprecated fields.
 		Balance:            int64(localBalance.ToSatoshis()),
 		PendingOpenBalance: int64(pendingOpenLocalBalance.ToSatoshis()),
-	}, nil
+	}
+
+	err = fn.MapOptionZ(
+		r.server.implCfg.AuxDataParser,
+		func(parser AuxDataParser) error {
+			return parser.InlineParseCustomData(resp)
+		},
+	)
+	if err != nil {
+		return nil, fmt.Errorf("error parsing custom data: %w", err)
+	}
+
+	return resp, nil
 }
 
 type (
@@ -3602,6 +3655,12 @@ func (r *rpcServer) fetchPendingOpenChannels() (pendingOpenChannels, error) {
 			pendingChan.BroadcastHeight()
 		fundingExpiryBlocks := int32(maxFundingHeight) - currentHeight
 
+		customChanBytes, err := encodeCustomChanData(pendingChan)
+		if err != nil {
+			return nil, fmt.Errorf("unable to encode open chan "+
+				"data: %w", err)
+		}
+
 		result[i] = &lnrpc.PendingChannelsResponse_PendingOpenChannel{
 			Channel: &lnrpc.PendingChannelsResponse_PendingChannel{
 				RemoteNodePub:        hex.EncodeToString(pub),
@@ -3615,6 +3674,7 @@ func (r *rpcServer) fetchPendingOpenChannels() (pendingOpenChannels, error) {
 				CommitmentType:       rpcCommitmentType(pendingChan.ChanType),
 				Private:              isPrivate(pendingChan),
 				Memo:                 string(pendingChan.Memo),
+				CustomChannelData:    customChanBytes,
 			},
 			CommitWeight:        commitWeight,
 			CommitFee:           int64(localCommitment.CommitFee),
@@ -4295,6 +4355,16 @@ func (r *rpcServer) ListChannels(ctx context.Context,
 		resp.Channels = append(resp.Channels, channel)
 	}
 
+	err = fn.MapOptionZ(
+		r.server.implCfg.AuxDataParser,
+		func(parser AuxDataParser) error {
+			return parser.InlineParseCustomData(resp)
+		},
+	)
+	if err != nil {
+		return nil, fmt.Errorf("error parsing custom data: %w", err)
+	}
+
 	return resp, nil
 }
 
@@ -4345,6 +4415,30 @@ func isPrivate(dbChannel *channeldb.OpenChannel) bool {
 	return dbChannel.ChannelFlags&lnwire.FFAnnounceChannel != 1
 }
 
+// encodeCustomChanData encodes the custom channel data for the open channel.
+// It encodes that data as a pair of var bytes blobs.
+func encodeCustomChanData(lnChan *channeldb.OpenChannel) ([]byte, error) {
+	customOpenChanData := lnChan.CustomBlob.UnwrapOr(nil)
+	customLocalCommitData := lnChan.LocalCommitment.CustomBlob.UnwrapOr(nil)
+
+	// We'll encode our custom channel data as two blobs. The first is a
+	// set of var bytes encoding of the open chan data, the second is an
+	// encoding of the local commitment data.
+	var customChanDataBuf bytes.Buffer
+	err := wire.WriteVarBytes(&customChanDataBuf, 0, customOpenChanData)
+	if err != nil {
+		return nil, fmt.Errorf("unable to encode open chan "+
+			"data: %w", err)
+	}
+	err = wire.WriteVarBytes(&customChanDataBuf, 0, customLocalCommitData)
+	if err != nil {
+		return nil, fmt.Errorf("unable to encode local commit "+
+			"data: %w", err)
+	}
+
+	return customChanDataBuf.Bytes(), nil
+}
+
 // createRPCOpenChannel creates an *lnrpc.Channel from the *channeldb.Channel.
 func createRPCOpenChannel(r *rpcServer, dbChannel *channeldb.OpenChannel,
 	isActive, peerAliasLookup bool) (*lnrpc.Channel, error) {
@@ -4399,6 +4493,14 @@ func createRPCOpenChannel(r *rpcServer, dbChannel *channeldb.OpenChannel,
 	// is returned and peerScidAlias will be an empty ShortChannelID.
 	peerScidAlias, _ := r.server.aliasMgr.GetPeerAlias(chanID)
 
+	// Finally we'll attempt to encode the custom channel data if any
+	// exists.
+	customChanBytes, err := encodeCustomChanData(dbChannel)
+	if err != nil {
+		return nil, fmt.Errorf("unable to encode open chan data: %w",
+			err)
+	}
+
 	channel := &lnrpc.Channel{
 		Active:                isActive,
 		Private:               isPrivate(dbChannel),
@@ -4431,6 +4533,7 @@ func createRPCOpenChannel(r *rpcServer, dbChannel *channeldb.OpenChannel,
 		ZeroConf:              dbChannel.IsZeroConf(),
 		ZeroConfConfirmedScid: dbChannel.ZeroConfRealScid().ToUint64(),
 		Memo:                  string(dbChannel.Memo),
+		CustomChannelData:     customChanBytes,
 		// TODO: remove the following deprecated fields
 		CsvDelay:             uint32(dbChannel.LocalChanCfg.CsvDelay),
 		LocalChanReserveSat:  int64(dbChannel.LocalChanCfg.ChanReserve),
diff --git a/server.go b/server.go
index f0cae5aa1..dd6cb4dac 100644
--- a/server.go
+++ b/server.go
@@ -4020,11 +4020,12 @@ func (s *server) peerInitializer(p *peer.Brontide) {
 	s.wg.Add(1)
 	go s.peerTerminationWatcher(p, ready)
 
+	pubBytes := p.IdentityKey().SerializeCompressed()
+
 	// Start the peer! If an error occurs, we Disconnect the peer, which
 	// will unblock the peerTerminationWatcher.
 	if err := p.Start(); err != nil {
-		srvrLog.Warnf("Starting peer=%v got error: %v",
-			p.IdentityKey(), err)
+		srvrLog.Warnf("Starting peer=%x got error: %v", pubBytes, err)
 
 		p.Disconnect(fmt.Errorf("unable to start peer: %w", err))
 		return
@@ -4034,13 +4035,15 @@ func (s *server) peerInitializer(p *peer.Brontide) {
 	// was successful, and to begin watching the peer's wait group.
 	close(ready)
 
-	pubStr := string(p.IdentityKey().SerializeCompressed())
-
 	s.mu.Lock()
 	defer s.mu.Unlock()
 
 	// Check if there are listeners waiting for this peer to come online.
 	srvrLog.Debugf("Notifying that peer %v is online", p)
+
+	// TODO(guggero): Do a proper conversion to a string everywhere, or use
+	// route.Vertex as the key type of peerConnectedListeners.
+	pubStr := string(pubBytes)
 	for _, peerChan := range s.peerConnectedListeners[pubStr] {
 		select {
 		case peerChan <- p:


Test patch:
diff --git a/cmd/lncli/arg_parse_test.go b/cmd/commands/arg_parse_test.go
similarity index 97%
rename from cmd/lncli/arg_parse_test.go
rename to cmd/commands/arg_parse_test.go
index 571292d2c..35751098e 100644
--- a/cmd/lncli/arg_parse_test.go
+++ b/cmd/commands/arg_parse_test.go
@@ -1,4 +1,4 @@
-package main
+package commands
 
 import (
 	"testing"
@@ -111,7 +111,7 @@ func TestStripPrefix(t *testing.T) {
 	t.Parallel()
 
 	for _, test := range stripPrefixTests {
-		actual := stripPrefix(test.in)
+		actual := StripPrefix(test.in)
 		require.Equal(t, test.expected, actual)
 	}
 }
diff --git a/cmd/lncli/commands_test.go b/cmd/commands/commands_test.go
similarity index 68%
rename from cmd/lncli/commands_test.go
rename to cmd/commands/commands_test.go
index a1f967561..1b07d3587 100644
--- a/cmd/lncli/commands_test.go
+++ b/cmd/commands/commands_test.go
@@ -1,4 +1,4 @@
-package main
+package commands
 
 import (
 	"encoding/hex"
@@ -120,3 +120,57 @@ func TestParseTimeLockDelta(t *testing.T) {
 		}
 	}
 }
+
+// TestReplaceCustomData tests that hex encoded custom data can be formatted as
+// JSON in the console output.
+func TestReplaceCustomData(t *testing.T) {
+	t.Parallel()
+
+	testCases := []struct {
+		name        string
+		data        string
+		replaceData string
+		expected    string
+	}{
+		{
+			name:     "no replacement necessary",
+			data:     "foo",
+			expected: "foo",
+		},
+		{
+			name: "valid json with replacement",
+			data: "{\"foo\":\"bar\",\"custom_channel_data\":\"" +
+				hex.EncodeToString([]byte(
+					"{\"bar\":\"baz\"}",
+				)) + "\"}",
+			expected: `{
+    "foo": "bar",
+    "custom_channel_data": {
+        "bar": "baz"
+    }
+}`,
+		},
+		{
+			name: "valid json with replacement and space",
+			data: "{\"foo\":\"bar\",\"custom_channel_data\": \"" +
+				hex.EncodeToString([]byte(
+					"{\"bar\":\"baz\"}",
+				)) + "\"}",
+			expected: `{
+    "foo": "bar",
+    "custom_channel_data": {
+        "bar": "baz"
+    }
+}`,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			result, err := replaceCustomData([]byte(tc.data))
+			require.NoError(t, err)
+
+			require.Equal(t, tc.expected, string(result))
+		})
+	}
+}
diff --git a/cmd/lncli/macaroon_jar_test.go b/cmd/commands/macaroon_jar_test.go
similarity index 99%
rename from cmd/lncli/macaroon_jar_test.go
rename to cmd/commands/macaroon_jar_test.go
index 8e1d1c6bd..6d76dce84 100644
--- a/cmd/lncli/macaroon_jar_test.go
+++ b/cmd/commands/macaroon_jar_test.go
@@ -1,4 +1,4 @@
-package main
+package commands
 
 import (
 	"encoding/hex"
diff --git a/lntest/harness_assertion.go b/lntest/harness_assertion.go
index f8c1c9716..12e1e05de 100644
--- a/lntest/harness_assertion.go
+++ b/lntest/harness_assertion.go
@@ -958,6 +958,11 @@ func (h *HarnessTest) AssertChannelBalanceResp(hn *node.HarnessNode,
 	expected *lnrpc.ChannelBalanceResponse) {
 
 	resp := hn.RPC.ChannelBalance()
+
+	// Ignore custom channel data of both expected and actual responses.
+	expected.CustomChannelData = nil
+	resp.CustomChannelData = nil
+
 	require.True(h, proto.Equal(expected, resp), "balance is incorrect "+
 		"got: %v, want: %v", resp, expected)
 }
diff --git a/lntest/rpc/router.go b/lntest/rpc/router.go
index 2f44b2047..539888058 100644
--- a/lntest/rpc/router.go
+++ b/lntest/rpc/router.go
@@ -193,16 +193,16 @@ func (h *HarnessRPC) HtlcInterceptor() (InterceptorClient, context.CancelFunc) {
 	return resp, cancel
 }
 
-// AddAliases adds a list of aliases to the node's alias map.
+// XAddLocalChanAliases adds a list of aliases to the node's alias map.
 func (h *HarnessRPC) XAddLocalChanAliases(req *routerrpc.AddAliasesRequest) {
 	ctxt, cancel := context.WithTimeout(h.runCtx, DefaultTimeout)
 	defer cancel()
 
 	_, err := h.Router.XAddLocalChanAliases(ctxt, req)
-	h.NoError(err, "AddAliases")
+	h.NoError(err, "XAddLocalChanAliases")
 }
 
-// DeleteAliases deleted a set of alias mappings.
+// XDeleteLocalChanAliases deleted a set of alias mappings.
 func (h *HarnessRPC) XDeleteLocalChanAliases(
 	req *routerrpc.DeleteAliasesRequest) {
 
@@ -210,7 +210,7 @@ func (h *HarnessRPC) XDeleteLocalChanAliases(
 	defer cancel()
 
 	_, err := h.Router.XDeleteLocalChanAliases(ctxt, req)
-	h.NoError(err, "AddAliases")
+	h.NoError(err, "XDeleteLocalChanAliases")
 }
 
 type TrackPaymentsClient routerrpc.Router_TrackPaymentsClient
diff --git a/lnwallet/channel_test.go b/lnwallet/channel_test.go
index 4914fa139..b3546b60a 100644
--- a/lnwallet/channel_test.go
+++ b/lnwallet/channel_test.go
@@ -713,7 +713,7 @@ func TestCooperativeChannelClosure(t *testing.T) {
 		testCoopClose(t, &coopCloseTestCase{
 			chanType: channeldb.SingleFunderTweaklessBit |
 				channeldb.AnchorOutputsBit,
-			anchorAmt: anchorSize * 2,
+			anchorAmt: AnchorSize * 2,
 		})
 	})
 }
@@ -823,7 +823,7 @@ func TestForceClose(t *testing.T) {
 			chanType: channeldb.SingleFunderTweaklessBit |
 				channeldb.AnchorOutputsBit,
 			expectedCommitWeight: input.AnchorCommitWeight,
-			anchorAmt:            anchorSize * 2,
+			anchorAmt:            AnchorSize * 2,
 		})
 	})
 	t.Run("taproot", func(t *testing.T) {
@@ -832,7 +832,7 @@ func TestForceClose(t *testing.T) {
 				channeldb.AnchorOutputsBit |
 				channeldb.SimpleTaprootFeatureBit,
 			expectedCommitWeight: input.TaprootCommitWeight,
-			anchorAmt:            anchorSize * 2,
+			anchorAmt:            AnchorSize * 2,
 		})
 	})
 	t.Run("taproot with tapscript root", func(t *testing.T) {
@@ -842,7 +842,7 @@ func TestForceClose(t *testing.T) {
 				channeldb.SimpleTaprootFeatureBit |
 				channeldb.TapscriptRootBit,
 			expectedCommitWeight: input.TaprootCommitWeight,
-			anchorAmt:            anchorSize * 2,
+			anchorAmt:            AnchorSize * 2,
 		})
 	})
 }
@@ -928,7 +928,7 @@ func testForceClose(t *testing.T, testCase *forceCloseTestCase) {
 			t.Fatal("commit tx not referenced by anchor res")
 		}
 		if anchorRes.AnchorSignDescriptor.Output.Value !=
-			int64(anchorSize) {
+			int64(AnchorSize) {
 
 			t.Fatal("unexpected anchor size")
 		}
diff --git a/lnwallet/test_utils.go b/lnwallet/test_utils.go
index 61ab72c20..488064c42 100644
--- a/lnwallet/test_utils.go
+++ b/lnwallet/test_utils.go
@@ -254,7 +254,7 @@ func CreateTestChannels(t *testing.T, chanType channeldb.ChannelType,
 	commitFee := calcStaticFee(chanType, 0)
 	var anchorAmt btcutil.Amount
 	if chanType.HasAnchors() {
-		anchorAmt += 2 * anchorSize
+		anchorAmt += 2 * AnchorSize
 	}
 
 	aliceBalance := lnwire.NewMSatFromSatoshis(
diff --git a/lnwallet/transactions_test.go b/lnwallet/transactions_test.go
index 14c11b7f7..fc7ba902e 100644
--- a/lnwallet/transactions_test.go
+++ b/lnwallet/transactions_test.go
@@ -923,7 +923,7 @@ func createTestChannelsForVectors(tc *testContext, chanType channeldb.ChannelTyp
 
 	var anchorAmt btcutil.Amount
 	if chanType.HasAnchors() {
-		anchorAmt = 2 * anchorSize
+		anchorAmt = 2 * AnchorSize
 	}
 
 	remoteCommitTx, localCommitTx, err := CreateCommitmentTxns(
diff --git a/rpcserver_test.go b/rpcserver_test.go
index ca70ad9df..9dd3c3f86 100644
--- a/rpcserver_test.go
+++ b/rpcserver_test.go
@@ -1,14 +1,79 @@
 package lnd
 
 import (
+	"fmt"
 	"testing"
 
+	"github.com/lightningnetwork/lnd/channeldb"
+	"github.com/lightningnetwork/lnd/fn"
+	"github.com/lightningnetwork/lnd/lnrpc"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"google.golang.org/protobuf/proto"
 )
 
 func TestGetAllPermissions(t *testing.T) {
 	perms := GetAllPermissions()
 
-	// Currently there are there are 16 entity:action pairs in use.
+	// Currently there are 16 entity:action pairs in use.
 	assert.Equal(t, len(perms), 16)
 }
+
+// mockDataParser is a mock implementation of the AuxDataParser interface.
+type mockDataParser struct {
+}
+
+// InlineParseCustomData replaces any custom data binary blob in the given RPC
+// message with its corresponding JSON formatted data. This transforms the
+// binary (likely TLV encoded) data to a human-readable JSON representation
+// (still as byte slice).
+func (m *mockDataParser) InlineParseCustomData(msg proto.Message) error {
+	switch m := msg.(type) {
+	case *lnrpc.ChannelBalanceResponse:
+		m.CustomChannelData = []byte(`{"foo": "bar"}`)
+
+		return nil
+
+	default:
+		return fmt.Errorf("mock only supports ChannelBalanceResponse")
+	}
+}
+
+func TestAuxDataParser(t *testing.T) {
+	// We create an empty channeldb, so we can fetch some channels.
+	cdb, err := channeldb.Open(t.TempDir())
+	require.NoError(t, err)
+	t.Cleanup(func() {
+		require.NoError(t, cdb.Close())
+	})
+
+	r := &rpcServer{
+		server: &server{
+			chanStateDB: cdb.ChannelStateDB(),
+			implCfg: &ImplementationCfg{
+				AuxComponents: AuxComponents{
+					AuxDataParser: fn.Some[AuxDataParser](
+						&mockDataParser{},
+					),
+				},
+			},
+		},
+	}
+
+	// With the aux data parser in place, we should get a formatted JSON
+	// in the custom channel data field.
+	resp, err := r.ChannelBalance(nil, &lnrpc.ChannelBalanceRequest{})
+	require.NoError(t, err)
+	require.NotNil(t, resp)
+	require.Equal(t, []byte(`{"foo": "bar"}`), resp.CustomChannelData)
+
+	// If we don't supply the aux data parser, we should get the raw binary
+	// data. Which in this case is just two VarInt fields (1 byte each) that
+	// represent the value of 0 (zero active and zero pending channels).
+	r.server.implCfg.AuxComponents.AuxDataParser = fn.None[AuxDataParser]()
+
+	resp, err = r.ChannelBalance(nil, &lnrpc.ChannelBalanceRequest{})
+	require.NoError(t, err)
+	require.NotNil(t, resp)
+	require.Equal(t, []byte{0x00, 0x00}, resp.CustomChannelData)
+}