Repository: lightningnetwork/lnd
Pull request #9025 (2024-08-22 11:43:27)

[custom channels]: extract some independent commits from mega staging branch
As requested in https://github.com/lightningnetwork/lnd/pull/8960, I'm pulling out some more or less independent commits out from the mega-PR.




Patch:
diff --git a/.golangci.yml b/.golangci.yml
index 5246c06c3..536c1b655 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -259,8 +259,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/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 420396322..ac15b2f1f 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 99%
rename from cmd/lncli/cmd_invoice.go
rename to cmd/commands/cmd_invoice.go
index a7ed2b8b2..5ea4c1e0b 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.",
@@ -408,7 +408,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 deea4e7ad..149c7db45 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 f0585ed47..b4fe83f20 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 95%
rename from cmd/lncli/cmd_payments.go
rename to cmd/commands/cmd_payments.go
index 228dd3415..c8787a6cc 100644
--- a/cmd/lncli/cmd_payments.go
+++ b/cmd/commands/cmd_payments.go
@@ -1,4 +1,4 @@
-package main
+package commands
 
 import (
 	"bytes"
@@ -25,6 +25,7 @@ import (
 	"github.com/lightningnetwork/lnd/record"
 	"github.com/lightningnetwork/lnd/routing/route"
 	"github.com/urfave/cli"
+	"google.golang.org/grpc"
 )
 
 const (
@@ -152,8 +153,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",
@@ -202,7 +203,7 @@ func paymentFlags() []cli.Flag {
 	}
 }
 
-var sendPaymentCommand = cli.Command{
+var SendPaymentCommand = cli.Command{
 	Name:     "sendpayment",
 	Category: "Payments",
 	Usage:    "Send a payment over lightning.",
@@ -226,7 +227,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 " +
@@ -253,7 +254,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
@@ -324,20 +325,23 @@ 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")
 		return nil
 	}
 
+	conn := getClientConn(ctx, false)
+	defer conn.Close()
+
 	args := ctx.Args()
 
 	// If a payment request was provided, we can exit early since all of the
 	// 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),
 			Amp:               ctx.Bool(ampFlag.Name),
@@ -357,7 +361,9 @@ func sendPayment(ctx *cli.Context) error {
 
 		req.PaymentAddr = payAddr
 
-		return sendPaymentRequest(ctx, req)
+		return SendPaymentRequest(
+			ctx, req, conn, conn, routerRPCSendPayment,
+		)
 	}
 
 	var (
@@ -466,19 +472,29 @@ func sendPayment(ctx *cli.Context) error {
 
 	req.PaymentAddr = payAddr
 
-	return sendPaymentRequest(ctx, req)
+	return SendPaymentRequest(ctx, req, conn, conn, routerRPCSendPayment)
 }
 
-func sendPaymentRequest(ctx *cli.Context,
-	req *routerrpc.SendPaymentRequest) error {
+// SendPaymentFn is a function type that abstracts the SendPaymentV2 call of the
+// router client.
+type SendPaymentFn func(ctx context.Context, payConn grpc.ClientConnInterface,
+	req *routerrpc.SendPaymentRequest) (PaymentResultStream, error)
 
-	ctxc := getContext()
+// routerRPCSendPayment is the default implementation of the SendPaymentFn type
+// that uses the lnd routerrpc.SendPaymentV2 call.
+func routerRPCSendPayment(ctx context.Context, payConn grpc.ClientConnInterface,
+	req *routerrpc.SendPaymentRequest) (PaymentResultStream, error) {
 
-	conn := getClientConn(ctx, false)
-	defer conn.Close()
+	return routerrpc.NewRouterClient(payConn).SendPaymentV2(ctx, req)
+}
 
-	client := lnrpc.NewLightningClient(conn)
-	routerClient := routerrpc.NewRouterClient(conn)
+func SendPaymentRequest(ctx *cli.Context, req *routerrpc.SendPaymentRequest,
+	lnConn, paymentConn grpc.ClientConnInterface,
+	callSendPayment SendPaymentFn) error {
+
+	ctxc := getContext()
+
+	lnClient := lnrpc.NewLightningClient(lnConn)
 
 	outChan := ctx.Int64Slice("outgoing_chan_id")
 	if len(outChan) != 0 {
@@ -558,7 +574,7 @@ func sendPaymentRequest(ctx *cli.Context,
 	if req.PaymentRequest != "" {
 		// Decode payment request to find out the amount.
 		decodeReq := &lnrpc.PayReqString{PayReq: req.PaymentRequest}
-		decodeResp, err := client.DecodePayReq(ctxc, decodeReq)
+		decodeResp, err := lnClient.DecodePayReq(ctxc, decodeReq)
 		if err != nil {
 			return err
 		}
@@ -602,14 +618,12 @@ func sendPaymentRequest(ctx *cli.Context,
 	printJSON := ctx.Bool(jsonFlag.Name)
 	req.NoInflightUpdates = !ctx.Bool(inflightUpdatesFlag.Name) && printJSON
 
-	stream, err := routerClient.SendPaymentV2(ctxc, req)
+	stream, err := callSendPayment(ctxc, paymentConn, req)
 	if err != nil {
 		return err
 	}
 
-	finalState, err := printLivePayment(
-		ctxc, stream, client, printJSON,
-	)
+	finalState, err := PrintLivePayment(ctxc, stream, lnClient, printJSON)
 	if err != nil {
 		return err
 	}
@@ -667,24 +681,29 @@ 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
+// PaymentResultStream is an interface that abstracts the Recv method of the
+// SendPaymentV2 or TrackPaymentV2 client stream.
+type PaymentResultStream interface {
+	Recv() (*lnrpc.Payment, error)
+}
+
+// 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,
-	stream routerrpc.Router_TrackPaymentV2Client,
-	client lnrpc.LightningClient, json bool) (*lnrpc.Payment, error) {
+func PrintLivePayment(ctxc context.Context, stream PaymentResultStream,
+	lnClient lnrpc.LightningClient, json bool) (*lnrpc.Payment, error) {
 
 	// Terminal escape codes aren't supported on Windows, fall back to json.
 	if !json && runtime.GOOS == "windows" {
 		json = true
 	}
 
-	aliases := newAliasCache(client)
+	aliases := newAliasCache(lnClient)
 
 	first := true
 	var lastLineCount int
@@ -706,17 +725,17 @@ func printLivePayment(ctxc context.Context,
 			// Write raw json to stdout.
 			printRespJSON(payment)
 		} else {
-			table := formatPayment(ctxc, payment, aliases)
+			resultTable := formatPayment(ctxc, payment, aliases)
 
 			// Clear all previously written lines and print the
 			// updated table.
 			clearLines(lastLineCount)
-			fmt.Print(table)
+			fmt.Print(resultTable)
 
 			// Store the number of lines written for the next update
 			// pass.
 			lastLineCount = 0
-			for _, b := range table {
+			for _, b := range resultTable {
 				if b == '\n' {
 					lastLineCount++
 				}
@@ -874,7 +893,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 " +
@@ -885,6 +904,9 @@ var payInvoiceCommand = cli.Command{
 }
 
 func payInvoice(ctx *cli.Context) error {
+	conn := getClientConn(ctx, false)
+	defer conn.Close()
+
 	args := ctx.Args()
 
 	var payReq string
@@ -898,14 +920,14 @@ func payInvoice(ctx *cli.Context) error {
 	}
 
 	req := &routerrpc.SendPaymentRequest{
-		PaymentRequest:    stripPrefix(payReq),
+		PaymentRequest:    StripPrefix(payReq),
 		Amt:               ctx.Int64("amt"),
 		DestCustomRecords: make(map[uint64][]byte),
 		Amp:               ctx.Bool(ampFlag.Name),
 		Cancelable:        ctx.Bool(cancelableFlag.Name),
 	}
 
-	return sendPaymentRequest(ctx, req)
+	return SendPaymentRequest(ctx, req, conn, conn, routerRPCSendPayment)
 }
 
 var sendToRouteCommand = cli.Command{
@@ -1900,7 +1922,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 3b1bf6487..6767964eb 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 650ef80ea..844b48f3d 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 fefef6ab2..2c1191ab2 100644
--- a/cmd/lncli/commands.go
+++ b/cmd/commands/commands.go
@@ -1,4 +1,4 @@
-package main
+package commands
 
 import (
 	"bufio"
@@ -11,6 +11,7 @@ import (
 	"io"
 	"math"
 	"os"
+	"regexp"
 	"strconv"
 	"strings"
 	"sync"
@@ -41,8 +42,47 @@ 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-f]+)"`,
+	)
+)
+
+// 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 there's nothing to replace, return the original JSON.
+	if !customDataPattern.Match(jsonBytes) {
+		return jsonBytes, nil
+	}
+
+	replacedBytes := 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, replacedBytes, "", "    ")
+	if err != nil {
+		return nil, err
+	}
+
+	return buf.Bytes(), nil
+}
 
 func getContext() context.Context {
 	shutdownInterceptor, err := signal.Intercept()
@@ -66,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) {
@@ -78,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
@@ -1442,15 +1488,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()
@@ -1575,7 +1621,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.",
@@ -1608,7 +1654,7 @@ var listChannelsCommand = cli.Command{
 				"order to improve performance",
 		},
 	},
-	Action: actionDecorator(listChannels),
+	Action: actionDecorator(ListChannels),
 }
 
 var listAliasesCommand = cli.Command{
@@ -1616,10 +1662,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()
@@ -1636,7 +1682,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 823af67bf..0ee767c8b 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..71a8531d9
--- /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-2024 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 efbdfc147..0d63ca93c 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 a93d3e2c6..2a82e7100 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 15c62755f..ff56adf4d 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 99%
rename from cmd/lncli/walletrpc_types.go
rename to cmd/commands/walletrpc_types.go
index ab251d18f..4369151fa 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..ea5195a0c 100644
--- a/cmd/lncli/main.go
+++ b/cmd/lncli/main.go
@@ -1,594 +1,11 @@
 // Copyright (c) 2013-2017 The btcsuite developers
 // Copyright (c) 2015-2016 The Decred developers
-// Copyright (C) 2015-2022 The Lightning Network Developers
+// Copyright (C) 2015-2024 The Lightning Network Developers
 
 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/funding/manager.go b/funding/manager.go
index 8ad16005c..61b03200e 100644
--- a/funding/manager.go
+++ b/funding/manager.go
@@ -6,6 +6,7 @@ import (
 	"fmt"
 	"io"
 	"sync"
+	"sync/atomic"
 	"time"
 
 	"github.com/btcsuite/btcd/blockchain"
@@ -287,7 +288,7 @@ type InitFundingMsg struct {
 	// PendingChanID is not all zeroes (the default value), then this will
 	// be the pending channel ID used for the funding flow within the wire
 	// protocol.
-	PendingChanID [32]byte
+	PendingChanID PendingChanID
 
 	// ChannelType allows the caller to use an explicit channel type for the
 	// funding negotiation. This type will only be observed if BOTH sides
@@ -317,7 +318,7 @@ type fundingMsg struct {
 // pendingChannels is a map instantiated per-peer which tracks all active
 // pending single funded channels indexed by their pending channel identifier,
 // which is a set of 32-bytes generated via a CSPRNG.
-type pendingChannels map[[32]byte]*reservationWithCtx
+type pendingChannels map[PendingChanID]*reservationWithCtx
 
 // serializedPubKey is used within the FundingManager's activeReservations list
 // to identify the nodes with which the FundingManager is actively working to
@@ -568,8 +569,10 @@ type Manager struct {
 
 	// chanIDNonce is a nonce that's incremented for each new funding
 	// reservation created.
-	nonceMtx    sync.RWMutex
-	chanIDNonce uint64
+	chanIDNonce atomic.Uint64
+
+	// nonceMtx is a mutex that guards the pendingMusigNonces.
+	nonceMtx sync.RWMutex
 
 	// pendingMusigNonces is used to store the musig2 nonce we generate to
 	// send funding locked until we receive a funding locked message from
@@ -591,7 +594,7 @@ type Manager struct {
 	// required as mid funding flow, we switch to referencing the channel
 	// by its full channel ID once the commitment transactions have been
 	// signed by both parties.
-	signedReservations map[lnwire.ChannelID][32]byte
+	signedReservations map[lnwire.ChannelID]PendingChanID
 
 	// resMtx guards both of the maps above to ensure that all access is
 	// goroutine safe.
@@ -798,24 +801,28 @@ func (f *Manager) rebroadcastFundingTx(c *channeldb.OpenChannel) {
 	}
 }
 
+// PendingChanID is a type that represents a pending channel ID. This might be
+// selected by the caller, but if not, will be automatically selected.
+type PendingChanID = [32]byte
+
 // nextPendingChanID returns the next free pending channel ID to be used to
 // identify a particular future channel funding workflow.
-func (f *Manager) nextPendingChanID() [32]byte {
-	// Obtain a fresh nonce. We do this by encoding the current nonce
-	// counter, then incrementing it by one.
-	f.nonceMtx.Lock()
-	var nonce [8]byte
-	binary.LittleEndian.PutUint64(nonce[:], f.chanIDNonce)
-	f.chanIDNonce++
-	f.nonceMtx.Unlock()
+func (f *Manager) nextPendingChanID() PendingChanID {
+	// Obtain a fresh nonce. We do this by encoding the incremented nonce.
+	nextNonce := f.chanIDNonce.Add(1)
+
+	var nonceBytes [8]byte
+	binary.LittleEndian.PutUint64(nonceBytes[:], nextNonce)
 
 	// We'll generate the next pending channelID by "encrypting" 32-bytes
 	// of zeroes which'll extract 32 random bytes from our stream cipher.
 	var (
-		nextChanID [32]byte
+		nextChanID PendingChanID
 		zeroes     [32]byte
 	)
-	salsa20.XORKeyStream(nextChanID[:], zeroes[:], nonce[:], &f.chanIDKey)
+	salsa20.XORKeyStream(
+		nextChanID[:], zeroes[:], nonceBytes[:], &f.chanIDKey,
+	)
 
 	return nextChanID
 }
@@ -1045,7 +1052,8 @@ func (f *Manager) reservationCoordinator() {
 //
 // NOTE: This MUST be run as a goroutine.
 func (f *Manager) advanceFundingState(channel *channeldb.OpenChannel,
-	pendingChanID [32]byte, updateChan chan<- *lnrpc.OpenStatusUpdate) {
+	pendingChanID PendingChanID,
+	updateChan chan<- *lnrpc.OpenStatusUpdate) {
 
 	defer f.wg.Done()
 
@@ -1115,7 +1123,7 @@ func (f *Manager) advanceFundingState(channel *channeldb.OpenChannel,
 // updateChan can be set non-nil to get OpenStatusUpdates.
 func (f *Manager) stateStep(channel *channeldb.OpenChannel,
 	lnChannel *lnwallet.LightningChannel,
-	shortChanID *lnwire.ShortChannelID, pendingChanID [32]byte,
+	shortChanID *lnwire.ShortChannelID, pendingChanID PendingChanID,
 	channelState channelOpeningState,
 	updateChan chan<- *lnrpc.OpenStatusUpdate) error {
 
@@ -1239,7 +1247,7 @@ func (f *Manager) stateStep(channel *channeldb.OpenChannel,
 // advancePendingChannelState waits for a pending channel's funding tx to
 // confirm, and marks it open in the database when that happens.
 func (f *Manager) advancePendingChannelState(
-	channel *channeldb.OpenChannel, pendingChanID [32]byte) error {
+	channel *channeldb.OpenChannel, pendingChanID PendingChanID) error {
 
 	if channel.IsZeroConf() {
 		// Persist the alias to the alias database.
@@ -2770,7 +2778,7 @@ type confirmedChannel struct {
 // channel as closed. The error is only returned for the responder of the
 // channel flow.
 func (f *Manager) fundingTimeout(c *channeldb.OpenChannel,
-	pendingID [32]byte) error {
+	pendingID PendingChanID) error {
 
 	// We'll get a timeout if the number of blocks mined since the channel
 	// was initiated reaches MaxWaitNumBlocksFundingConf and we are not the
@@ -3995,7 +4003,7 @@ func (f *Manager) handleChannelReady(peer lnpeer.Peer, //nolint:funlen
 // channel is now active, thus we change its state to `addedToGraph` to
 // let the channel start handling routing.
 func (f *Manager) handleChannelReadyReceived(channel *channeldb.OpenChannel,
-	scid *lnwire.ShortChannelID, pendingChanID [32]byte,
+	scid *lnwire.ShortChannelID, pendingChanID PendingChanID,
 	updateChan chan<- *lnrpc.OpenStatusUpdate) error {
 
 	chanID := lnwire.NewChanIDFromOutPoint(channel.FundingOutpoint)
@@ -4519,7 +4527,7 @@ func (f *Manager) handleInitFundingMsg(msg *InitFundingMsg) {
 	// If the caller specified their own channel ID, then we'll use that.
 	// Otherwise we'll generate a fresh one as normal.  This will be used
 	// to track this reservation throughout its lifetime.
-	var chanID [32]byte
+	var chanID PendingChanID
 	if msg.PendingChanID == zeroID {
 		chanID = f.nextPendingChanID()
 	} else {
@@ -4942,7 +4950,8 @@ func (f *Manager) pruneZombieReservations() {
 // cancelReservationCtx does all needed work in order to securely cancel the
 // reservation.
 func (f *Manager) cancelReservationCtx(peerKey *btcec.PublicKey,
-	pendingChanID [32]byte, byRemote bool) (*reservationWithCtx, error) {
+	pendingChanID PendingChanID,
+	byRemote bool) (*reservationWithCtx, error) {
 
 	log.Infof("Cancelling funding reservation for node_key=%x, "+
 		"chan_id=%x", peerKey.SerializeCompressed(), pendingChanID[:])
@@ -4990,7 +4999,7 @@ func (f *Manager) cancelReservationCtx(peerKey *btcec.PublicKey,
 // deleteReservationCtx deletes the reservation uniquely identified by the
 // target public key of the peer, and the specified pending channel ID.
 func (f *Manager) deleteReservationCtx(peerKey *btcec.PublicKey,
-	pendingChanID [32]byte) {
+	pendingChanID PendingChanID) {
 
 	peerIDKey := newSerializedKey(peerKey)
 	f.resMtx.Lock()
@@ -5013,7 +5022,7 @@ func (f *Manager) deleteReservationCtx(peerKey *btcec.PublicKey,
 // getReservationCtx returns the reservation context for a particular pending
 // channel ID for a target peer.
 func (f *Manager) getReservationCtx(peerKey *btcec.PublicKey,
-	pendingChanID [32]byte) (*reservationWithCtx, error) {
+	pendingChanID PendingChanID) (*reservationWithCtx, error) {
 
 	peerIDKey := newSerializedKey(peerKey)
 	f.resMtx.RLock()
@@ -5033,7 +5042,7 @@ func (f *Manager) getReservationCtx(peerKey *btcec.PublicKey,
 // of being funded. After the funding transaction has been confirmed, the
 // channel will receive a new, permanent channel ID, and will no longer be
 // considered pending.
-func (f *Manager) IsPendingChannel(pendingChanID [32]byte,
+func (f *Manager) IsPendingChannel(pendingChanID PendingChanID,
 	peer lnpeer.Peer) bool {
 
 	peerIDKey := newSerializedKey(peer.IdentityKey())
diff --git a/go.mod b/go.mod
index f82781766..bbe3c8845 100644
--- a/go.mod
+++ b/go.mod
@@ -41,7 +41,7 @@ require (
 	github.com/lightningnetwork/lnd/queue v1.1.1
 	github.com/lightningnetwork/lnd/sqldb v1.0.3
 	github.com/lightningnetwork/lnd/ticker v1.1.1
-	github.com/lightningnetwork/lnd/tlv v1.2.3
+	github.com/lightningnetwork/lnd/tlv v1.2.6
 	github.com/lightningnetwork/lnd/tor v1.1.2
 	github.com/ltcsuite/ltcd v0.0.0-20190101042124-f37f8bf35796
 	github.com/miekg/dns v1.1.43
diff --git a/go.sum b/go.sum
index d040d8cf8..a51e291b8 100644
--- a/go.sum
+++ b/go.sum
@@ -462,8 +462,8 @@ github.com/lightningnetwork/lnd/sqldb v1.0.3 h1:zLfAwOvM+6+3+hahYO9Q3h8pVV0TghAR
 github.com/lightningnetwork/lnd/sqldb v1.0.3/go.mod h1:4cQOkdymlZ1znnjuRNvMoatQGJkRneTj2CoPSPaQhWo=
 github.com/lightningnetwork/lnd/ticker v1.1.1 h1:J/b6N2hibFtC7JLV77ULQp++QLtCwT6ijJlbdiZFbSM=
 github.com/lightningnetwork/lnd/ticker v1.1.1/go.mod h1:waPTRAAcwtu7Ji3+3k+u/xH5GHovTsCoSVpho0KDvdA=
-github.com/lightningnetwork/lnd/tlv v1.2.3 h1:If5ibokA/UoCBGuCKaY6Vn2SJU0l9uAbehCnhTZjEP8=
-github.com/lightningnetwork/lnd/tlv v1.2.3/go.mod h1:zDkmqxOczP6LaLTvSFDQ1SJUfHcQRCMKFj93dn3eMB8=
+github.com/lightningnetwork/lnd/tlv v1.2.6 h1:icvQG2yDr6k3ZuZzfRdG3EJp6pHurcuh3R6dg0gv/Mw=
+github.com/lightningnetwork/lnd/tlv v1.2.6/go.mod h1:/CmY4VbItpOldksocmGT4lxiJqRP9oLxwSZOda2kzNQ=
 github.com/lightningnetwork/lnd/tor v1.1.2 h1:3zv9z/EivNFaMF89v3ciBjCS7kvCj4ZFG7XvD2Qq0/k=
 github.com/lightningnetwork/lnd/tor v1.1.2/go.mod h1:j7T9uJ2NLMaHwE7GiBGnpYLn4f7NRoTM6qj+ul6/ycA=
 github.com/ltcsuite/ltcd v0.0.0-20190101042124-f37f8bf35796 h1:sjOGyegMIhvgfq5oaue6Td+hxZuf3tDC8lAPrFldqFw=
diff --git a/htlcswitch/interceptable_switch.go b/htlcswitch/interceptable_switch.go
index 0ac19a36c..5517eb82d 100644
--- a/htlcswitch/interceptable_switch.go
+++ b/htlcswitch/interceptable_switch.go
@@ -387,6 +387,8 @@ func (s *InterceptableSwitch) setInterceptor(interceptor ForwardInterceptor) {
 	})
 }
 
+// resolve processes a HTLC given the resolution type specified by the
+// intercepting client.
 func (s *InterceptableSwitch) resolve(res *FwdResolution) error {
 	intercepted, err := s.heldHtlcSet.pop(res.Key)
 	if err != nil {
diff --git a/htlcswitch/interfaces.go b/htlcswitch/interfaces.go
index 1311373a1..246c76902 100644
--- a/htlcswitch/interfaces.go
+++ b/htlcswitch/interfaces.go
@@ -331,7 +331,7 @@ type InterceptableHtlcForwarder interface {
 type ForwardInterceptor func(InterceptedPacket) error
 
 // InterceptedPacket contains the relevant information for the interceptor about
-// an htlc.
+// an HTLC.
 type InterceptedPacket struct {
 	// IncomingCircuit contains the incoming channel and htlc id of the
 	// packet.
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 104c24251..d801846f8 100644
--- a/input/script_utils.go
+++ b/input/script_utils.go
@@ -689,8 +689,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:
@@ -708,6 +708,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)
@@ -1690,9 +1695,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
@@ -1700,8 +1705,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:
@@ -1716,8 +1721,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:
@@ -1733,6 +1738,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)
@@ -2069,9 +2079,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
@@ -2080,8 +2090,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,
@@ -2099,8 +2109,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:
@@ -2120,6 +2130,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,
@@ -2241,7 +2256,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,
@@ -2296,9 +2311,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
 }
@@ -2773,8 +2785,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).
@@ -2810,9 +2822,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
@@ -2820,8 +2832,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:
@@ -2836,8 +2848,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:
@@ -2853,6 +2865,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/routerrpc/forward_interceptor.go b/lnrpc/routerrpc/forward_interceptor.go
index 8af1a21f6..55c77d6d9 100644
--- a/lnrpc/routerrpc/forward_interceptor.go
+++ b/lnrpc/routerrpc/forward_interceptor.go
@@ -22,7 +22,7 @@ var (
 	ErrMissingPreimage = errors.New("missing preimage")
 )
 
-// forwardInterceptor is a helper struct that handles the lifecycle of an rpc
+// forwardInterceptor is a helper struct that handles the lifecycle of an RPC
 // interceptor streaming session.
 // It is created when the stream opens and disconnects when the stream closes.
 type forwardInterceptor struct {
@@ -43,7 +43,7 @@ func newForwardInterceptor(htlcSwitch htlcswitch.InterceptableHtlcForwarder,
 }
 
 // run sends the intercepted packets to the client and receives the
-// corersponding responses. On one hand it registered itself as an interceptor
+// corresponding responses. On one hand it registered itself as an interceptor
 // that receives the switch packets and on the other hand launches a go routine
 // to read from the client stream.
 // To coordinate all this and make sure it is safe for concurrent access all
diff --git a/lnrpc/routerrpc/router_server.go b/lnrpc/routerrpc/router_server.go
index a88bb4d0c..30dba2227 100644
--- a/lnrpc/routerrpc/router_server.go
+++ b/lnrpc/routerrpc/router_server.go
@@ -1313,7 +1313,7 @@ func (s *Server) trackPayment(subscription routing.ControlTowerSubscriber,
 
 	// Otherwise, we will log and return the error as the stream has
 	// received an error from the payment lifecycle.
-	log.Errorf("TrackPayment got error for payment %x: %v", identifier, err)
+	log.Errorf("TrackPayment got error for payment %v: %v", identifier, err)
 
 	return err
 }
@@ -1525,7 +1525,7 @@ func (s *Server) HtlcInterceptor(stream Router_HtlcInterceptorServer) error {
 	}
 	defer atomic.CompareAndSwapInt32(&s.forwardInterceptorActive, 1, 0)
 
-	// run the forward interceptor.
+	// Run the forward interceptor.
 	return newForwardInterceptor(
 		s.cfg.RouterBackend.InterceptableForwarder, stream,
 	).run()
diff --git a/lnwallet/chanfunding/assembler.go b/lnwallet/chanfunding/interface.go
similarity index 96%
rename from lnwallet/chanfunding/assembler.go
rename to lnwallet/chanfunding/interface.go
index 2fdb87b32..3512b32ff 100644
--- a/lnwallet/chanfunding/assembler.go
+++ b/lnwallet/chanfunding/interface.go
@@ -4,9 +4,11 @@ import (
 	"time"
 
 	"github.com/btcsuite/btcd/btcutil"
+	"github.com/btcsuite/btcd/chaincfg/chainhash"
 	"github.com/btcsuite/btcd/wire"
 	"github.com/btcsuite/btcwallet/wallet"
 	"github.com/btcsuite/btcwallet/wtxmgr"
+	"github.com/lightningnetwork/lnd/fn"
 	"github.com/lightningnetwork/lnd/lnwallet/chainfee"
 )
 
@@ -119,6 +121,11 @@ type Request struct {
 	// output. By definition, this'll also use segwit v1 (taproot) for the
 	// funding output.
 	Musig2 bool
+
+	// TapscriptRoot is the root of the tapscript tree that will be used to
+	// create the funding output. This field will only be utilized if the
+	// Musig2 flag above is set to true.
+	TapscriptRoot fn.Option[chainhash.Hash]
 }
 
 // Intent is returned by an Assembler and represents the base functionality the
diff --git a/lnwallet/channel.go b/lnwallet/channel.go
index 65ae01356..7a6278522 100644
--- a/lnwallet/channel.go
+++ b/lnwallet/channel.go
@@ -7694,7 +7694,7 @@ func NewAnchorResolution(chanState *channeldb.OpenChannel,
 		WitnessScript: anchorWitnessScript,
 		Output: &wire.TxOut{
 			PkScript: localAnchor.PkScript(),
-			Value:    int64(anchorSize),
+			Value:    int64(AnchorSize),
 		},
 		HashType: sweepSigHash(chanState.ChanType),
 	}
@@ -7706,7 +7706,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
@@ -8249,7 +8249,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
@@ -8269,7 +8269,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 2cf58f494..f32408814 100644
--- a/lnwallet/commitment.go
+++ b/lnwallet/commitment.go
@@ -17,8 +17,8 @@ import (
 	"github.com/lightningnetwork/lnd/lnwire"
 )
 
-// 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
@@ -200,9 +200,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
@@ -210,10 +210,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 {
@@ -942,7 +940,7 @@ func CreateCommitTx(chanType channeldb.ChannelType,
 		if localOutput || numHTLCs > 0 {
 			commitTx.AddTxOut(&wire.TxOut{
 				PkScript: localAnchor.PkScript(),
-				Value:    int64(anchorSize),
+				Value:    int64(AnchorSize),
 			})
 		}
 
@@ -951,7 +949,7 @@ func CreateCommitTx(chanType channeldb.ChannelType,
 		if remoteOutput || numHTLCs > 0 {
 			commitTx.AddTxOut(&wire.TxOut{
 				PkScript: remoteAnchor.PkScript(),
-				Value:    int64(anchorSize),
+				Value:    int64(AnchorSize),
 			})
 		}
 	}
@@ -976,7 +974,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
@@ -1075,9 +1073,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 bool, whoseCommit lntypes.ChannelParty,
+func GenTaprootHtlcScript(isIncoming bool, whoseCommit lntypes.ChannelParty,
 	timeout uint32, rHash [32]byte, keyRing *CommitmentKeyRing,
 ) (*input.HtlcScriptTree, error) {
 
@@ -1138,8 +1136,7 @@ func genTaprootHtlcScript(isIncoming bool, whoseCommit lntypes.ChannelParty,
 // along side the multiplexer.
 func genHtlcScript(chanType channeldb.ChannelType, isIncoming bool,
 	whoseCommit lntypes.ChannelParty, timeout uint32, rHash [32]byte,
-	keyRing *CommitmentKeyRing,
-) (input.ScriptDescriptor, error) {
+	keyRing *CommitmentKeyRing) (input.ScriptDescriptor, error) {
 
 	if !chanType.IsTaproot() {
 		return genSegwitV0HtlcScript(
@@ -1148,7 +1145,7 @@ func genHtlcScript(chanType channeldb.ChannelType, isIncoming bool,
 		)
 	}
 
-	return genTaprootHtlcScript(
+	return GenTaprootHtlcScript(
 		isIncoming, whoseCommit, timeout, rHash, keyRing,
 	)
 }
diff --git a/lnwallet/reservation.go b/lnwallet/reservation.go
index 529db1141..df6c8fd94 100644
--- a/lnwallet/reservation.go
+++ b/lnwallet/reservation.go
@@ -261,7 +261,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/lnwire/custom_records.go b/lnwire/custom_records.go
new file mode 100644
index 000000000..1e1988842
--- /dev/null
+++ b/lnwire/custom_records.go
@@ -0,0 +1,176 @@
+package lnwire
+
+import (
+	"bytes"
+	"fmt"
+	"sort"
+
+	"github.com/lightningnetwork/lnd/tlv"
+)
+
+const (
+	// MinCustomRecordsTlvType is the minimum custom records TLV type as
+	// defined in BOLT 01.
+	MinCustomRecordsTlvType = 65536
+)
+
+// CustomRecords stores a set of custom key/value pairs. Map keys are TLV types
+// which must be greater than or equal to MinCustomRecordsTlvType.
+type CustomRecords map[uint64][]byte
+
+// NewCustomRecords creates a new CustomRecords instance from a
+// tlv.TypeMap.
+func NewCustomRecords(tlvMap tlv.TypeMap) (CustomRecords, error) {
+	// Make comparisons in unit tests easy by returning nil if the map is
+	// empty.
+	if len(tlvMap) == 0 {
+		return nil, nil
+	}
+
+	customRecords := make(CustomRecords, len(tlvMap))
+	for k, v := range tlvMap {
+		customRecords[uint64(k)] = v
+	}
+
+	// Validate the custom records.
+	err := customRecords.Validate()
+	if err != nil {
+		return nil, fmt.Errorf("custom records from tlv map "+
+			"validation error: %w", err)
+	}
+
+	return customRecords, nil
+}
+
+// ParseCustomRecords creates a new CustomRecords instance from a tlv.Blob.
+func ParseCustomRecords(b tlv.Blob) (CustomRecords, error) {
+	stream, err := tlv.NewStream()
+	if err != nil {
+		return nil, fmt.Errorf("error creating stream: %w", err)
+	}
+
+	typeMap, err := stream.DecodeWithParsedTypes(bytes.NewReader(b))
+	if err != nil {
+		return nil, fmt.Errorf("error decoding HTLC record: %w", err)
+	}
+
+	return NewCustomRecords(typeMap)
+}
+
+// Validate checks that all custom records are in the custom type range.
+func (c CustomRecords) Validate() error {
+	if c == nil {
+		return nil
+	}
+
+	for key := range c {
+		if key < MinCustomRecordsTlvType {
+			return fmt.Errorf("custom records entry with TLV "+
+				"type below min: %d", MinCustomRecordsTlvType)
+		}
+	}
+
+	return nil
+}
+
+// Copy returns a copy of the custom records.
+func (c CustomRecords) Copy() CustomRecords {
+	if c == nil {
+		return nil
+	}
+
+	customRecords := make(CustomRecords, len(c))
+	for k, v := range c {
+		customRecords[k] = v
+	}
+
+	return customRecords
+}
+
+// ExtendRecordProducers extends the given records slice with the custom
+// records. The resultant records slice will be sorted if the given records
+// slice contains TLV types greater than or equal to MinCustomRecordsTlvType.
+func (c CustomRecords) ExtendRecordProducers(
+	producers []tlv.RecordProducer) ([]tlv.RecordProducer, error) {
+
+	// If the custom records are nil or empty, there is nothing to do.
+	if len(c) == 0 {
+		return producers, nil
+	}
+
+	// Validate the custom records.
+	err := c.Validate()
+	if err != nil {
+		return nil, err
+	}
+
+	// Ensure that the existing records slice TLV types are not also present
+	// in the custom records. If they are, the resultant extended records
+	// slice would erroneously contain duplicate TLV types.
+	for _, rp := range producers {
+		record := rp.Record()
+		recordTlvType := uint64(record.Type())
+
+		_, foundDuplicateTlvType := c[recordTlvType]
+		if foundDuplicateTlvType {
+			return nil, fmt.Errorf("custom records contains a TLV "+
+				"type that is already present in the "+
+				"existing records: %d", recordTlvType)
+		}
+	}
+
+	// Convert the custom records map to a TLV record producer slice and
+	// append them to the exiting records slice.
+	crRecords := tlv.MapToRecords(c)
+	for _, record := range crRecords {
+		r := recordProducer{record}
+		producers = append(producers, &r)
+	}
+
+	// If the records slice which was given as an argument included TLV
+	// values greater than or equal to the minimum custom records TLV type
+	// we will sort the extended records slice to ensure that it is ordered
+	// correctly.
+	sort.Slice(producers, func(i, j int) bool {
+		recordI := producers[i].Record()
+		recordJ := producers[j].Record()
+		return recordI.Type() < recordJ.Type()
+	})
+
+	return producers, nil
+}
+
+// RecordProducers returns a slice of record producers for the custom records.
+func (c CustomRecords) RecordProducers() []tlv.RecordProducer {
+	// If the custom records are nil or empty, return an empty slice.
+	if len(c) == 0 {
+		return nil
+	}
+
+	// Convert the custom records map to a TLV record producer slice.
+	records := tlv.MapToRecords(c)
+
+	// Convert the records to record producers.
+	producers := make([]tlv.RecordProducer, len(records))
+	for i, record := range records {
+		producers[i] = &recordProducer{record}
+	}
+
+	return producers
+}
+
+// Serialize serializes the custom records into a byte slice.
+func (c CustomRecords) Serialize() ([]byte, error) {
+	records := tlv.MapToRecords(c)
+	stream, err := tlv.NewStream(records...)
+	if err != nil {
+		return nil, fmt.Errorf("error creating stream: %w", err)
+	}
+
+	var b bytes.Buffer
+	if err := stream.Encode(&b); err != nil {
+		return nil, fmt.Errorf("error encoding custom records: %w", err)
+	}
+
+	return b.Bytes(), nil
+}
diff --git a/lnwire/encoding.go b/lnwire/encoding.go
index e04b2b01d..72000f81d 100644
--- a/lnwire/encoding.go
+++ b/lnwire/encoding.go
@@ -1,5 +1,7 @@
 package lnwire
 
+import "github.com/lightningnetwork/lnd/tlv"
+
 // QueryEncoding is an enum-like type that represents exactly how a set data is
 // encoded on the wire.
 type QueryEncoding uint8
@@ -15,3 +17,17 @@ const (
 	// NOTE: this should no longer be used or accepted.
 	EncodingSortedZlib QueryEncoding = 1
 )
+
+// recordProducer is a simple helper struct that implements the
+// tlv.RecordProducer interface.
+type recordProducer struct {
+	record tlv.Record
+}
+
+// Record returns the underlying record.
+func (r *recordProducer) Record() tlv.Record {
+	return r.record
+}
+
+// Ensure that recordProducer implements the tlv.RecordProducer interface.
+var _ tlv.RecordProducer = (*recordProducer)(nil)
diff --git a/server.go b/server.go
index 02161bf73..26fdce6ea 100644
--- a/server.go
+++ b/server.go
@@ -4113,11 +4113,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
@@ -4127,13 +4128,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 58%
rename from cmd/lncli/commands_test.go
rename to cmd/commands/commands_test.go
index a1f967561..bab2c8d2b 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,82 @@ 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
+		expectedErr 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"
+    }
+}`,
+		},
+		{
+			name: "doesn't match pattern, returned identical",
+			data: "this ain't even json, and no custom data " +
+				"either",
+			expected: "this ain't even json, and no custom data " +
+				"either",
+		},
+		{
+			name: "invalid json",
+			data: "this ain't json, " +
+				"\"custom_channel_data\":\"a\"",
+			expectedErr: "invalid character 'h' in literal true",
+		},
+		{
+			name:     "valid json, invalid hex, just formatted",
+			data:     "{\"custom_channel_data\":\"f\"}",
+			expected: "{\n    \"custom_channel_data\": \"f\"\n}",
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			result, err := replaceCustomData([]byte(tc.data))
+
+			if tc.expectedErr != "" {
+				require.ErrorContains(t, err, tc.expectedErr)
+				return
+			}
+
+			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/lnwallet/channel_test.go b/lnwallet/channel_test.go
index fcaf0fc08..757a808fe 100644
--- a/lnwallet/channel_test.go
+++ b/lnwallet/channel_test.go
@@ -706,7 +706,7 @@ func TestCooperativeChannelClosure(t *testing.T) {
 		testCoopClose(t, &coopCloseTestCase{
 			chanType: channeldb.SingleFunderTweaklessBit |
 				channeldb.AnchorOutputsBit,
-			anchorAmt: anchorSize * 2,
+			anchorAmt: AnchorSize * 2,
 		})
 	})
 }
@@ -816,7 +816,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) {
@@ -825,7 +825,7 @@ func TestForceClose(t *testing.T) {
 				channeldb.AnchorOutputsBit |
 				channeldb.SimpleTaprootFeatureBit,
 			expectedCommitWeight: input.TaprootCommitWeight,
-			anchorAmt:            anchorSize * 2,
+			anchorAmt:            AnchorSize * 2,
 		})
 	})
 }
@@ -911,7 +911,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 0e5d52723..126b76d6c 100644
--- a/lnwallet/test_utils.go
+++ b/lnwallet/test_utils.go
@@ -258,7 +258,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 4c7fbed53..e9751c6b9 100644
--- a/lnwallet/transactions_test.go
+++ b/lnwallet/transactions_test.go
@@ -925,7 +925,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/lnwire/custom_records_test.go b/lnwire/custom_records_test.go
new file mode 100644
index 000000000..0338d0159
--- /dev/null
+++ b/lnwire/custom_records_test.go
@@ -0,0 +1,198 @@
+package lnwire
+
+import (
+	"bytes"
+	"testing"
+
+	"github.com/lightningnetwork/lnd/fn"
+	"github.com/lightningnetwork/lnd/tlv"
+	"github.com/stretchr/testify/require"
+)
+
+// TestCustomRecords tests the custom records serialization and deserialization,
+// as well as copying and producing records.
+func TestCustomRecords(t *testing.T) {
+	testCases := []struct {
+		name            string
+		customTypes     tlv.TypeMap
+		expectedRecords CustomRecords
+		expectedErr     string
+	}{
+		{
+			name:            "empty custom records",
+			customTypes:     tlv.TypeMap{},
+			expectedRecords: nil,
+		},
+		{
+			name: "custom record with invalid type",
+			customTypes: tlv.TypeMap{
+				123: []byte{1, 2, 3},
+			},
+			expectedErr: "TLV type below min: 65536",
+		},
+		{
+			name: "valid custom record",
+			customTypes: tlv.TypeMap{
+				65536: []byte{1, 2, 3},
+			},
+			expectedRecords: map[uint64][]byte{
+				65536: {1, 2, 3},
+			},
+		},
+		{
+			name: "valid custom records, wrong order",
+			customTypes: tlv.TypeMap{
+				65537: []byte{3, 4, 5},
+				65536: []byte{1, 2, 3},
+			},
+			expectedRecords: map[uint64][]byte{
+				65536: {1, 2, 3},
+				65537: {3, 4, 5},
+			},
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			records, err := NewCustomRecords(tc.customTypes)
+
+			if tc.expectedErr != "" {
+				require.ErrorContains(t, err, tc.expectedErr)
+				return
+			}
+
+			require.NoError(t, err)
+			require.Equal(t, tc.expectedRecords, records)
+
+			// Serialize, then parse the records again.
+			blob, err := records.Serialize()
+			require.NoError(t, err)
+
+			parsedRecords, err := ParseCustomRecords(blob)
+			require.NoError(t, err)
+
+			require.Equal(t, tc.expectedRecords, parsedRecords)
+
+			// Copy() should also return the same records.
+			require.Equal(
+				t, tc.expectedRecords, parsedRecords.Copy(),
+			)
+
+			// RecordProducers() should also allow us to serialize
+			// the records again.
+			serializedProducers := serializeRecordProducers(
+				t, parsedRecords.RecordProducers(),
+			)
+
+			require.Equal(t, blob, serializedProducers)
+		})
+	}
+}
+
+// TestCustomRecordsExtendRecordProducers tests that we can extend a slice of
+// record producers with custom records.
+func TestCustomRecordsExtendRecordProducers(t *testing.T) {
+	testCases := []struct {
+		name           string
+		existingTypes  map[uint64][]byte
+		customRecords  CustomRecords
+		expectedResult tlv.TypeMap
+		expectedErr    string
+	}{
+		{
+			name: "normal merge",
+			existingTypes: map[uint64][]byte{
+				123: {3, 4, 5},
+				345: {1, 2, 3},
+			},
+			customRecords: CustomRecords{
+				65536: {1, 2, 3},
+			},
+			expectedResult: tlv.TypeMap{
+				123:   {3, 4, 5},
+				345:   {1, 2, 3},
+				65536: {1, 2, 3},
+			},
+		},
+		{
+			name: "duplicates",
+			existingTypes: map[uint64][]byte{
+				123:   {3, 4, 5},
+				345:   {1, 2, 3},
+				65536: {1, 2, 3},
+			},
+			customRecords: CustomRecords{
+				65536: {1, 2, 3},
+			},
+			expectedErr: "contains a TLV type that is already " +
+				"present in the existing records: 65536",
+		},
+		{
+			name: "non custom type in custom records",
+			existingTypes: map[uint64][]byte{
+				123:   {3, 4, 5},
+				345:   {1, 2, 3},
+				65536: {1, 2, 3},
+			},
+			customRecords: CustomRecords{
+				123: {1, 2, 3},
+			},
+			expectedErr: "TLV type below min: 65536",
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			nonCustomRecords := tlv.MapToRecords(tc.existingTypes)
+			nonCustomProducers := fn.Map(
+				func(r tlv.Record) tlv.RecordProducer {
+					return &recordProducer{r}
+				}, nonCustomRecords,
+			)
+
+			combined, err := tc.customRecords.ExtendRecordProducers(
+				nonCustomProducers,
+			)
+
+			if tc.expectedErr != "" {
+				require.ErrorContains(t, err, tc.expectedErr)
+				return
+			}
+
+			require.NoError(t, err)
+
+			serializedProducers := serializeRecordProducers(
+				t, combined,
+			)
+
+			stream, err := tlv.NewStream()
+			require.NoError(t, err)
+
+			parsedMap, err := stream.DecodeWithParsedTypes(
+				bytes.NewReader(serializedProducers),
+			)
+			require.NoError(t, err)
+
+			require.Equal(t, tc.expectedResult, parsedMap)
+		})
+	}
+}
+
+// serializeRecordProducers is a helper function that serializes a slice of
+// record producers into a byte slice.
+func serializeRecordProducers(t *testing.T,
+	producers []tlv.RecordProducer) []byte {
+
+	tlvRecords := fn.Map(func(p tlv.RecordProducer) tlv.Record {
+		return p.Record()
+	}, producers)
+
+	stream, err := tlv.NewStream(tlvRecords...)
+	require.NoError(t, err)
+
+	var b bytes.Buffer
+	err = stream.Encode(&b)
+	require.NoError(t, err)
+
+	return b.Bytes()
+}
diff --git a/lnwire/extra_bytes_test.go b/lnwire/extra_bytes_test.go
index fd9f28841..97a908bce 100644
--- a/lnwire/extra_bytes_test.go
+++ b/lnwire/extra_bytes_test.go
@@ -86,14 +86,6 @@ func TestExtraOpaqueDataEncodeDecode(t *testing.T) {
 	}
 }
 
-type recordProducer struct {
-	record tlv.Record
-}
-
-func (r *recordProducer) Record() tlv.Record {
-	return r.record
-}
-
 // TestExtraOpaqueDataPackUnpackRecords tests that we're able to pack a set of
 // tlv.Records into a stream, and unpack them on the other side to obtain the
 // same set of records.