mirror of
https://github.com/lightninglabs/loop.git
synced 2026-08-13 12:33:03 +02:00
Update LND, Aperture, and Taproot Assets to revisions using the btcd v2 modules, and update lndclient to v0.21.0-3. Migrate Loop chain, transaction, and address types to their corresponding v2 packages. The lndclient release includes the migration from: https://github.com/lightninglabs/lndclient/pull/280 Taproot Assets is temporarily replaced with its btcd v2 revision because the v0.8 release branch has not adopted the new modules. This raises the minimum Go version to 1.26 and changes exported address types.
736 lines
20 KiB
Go
736 lines
20 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/btcsuite/btcd/btcutil/v2"
|
|
"github.com/lightninglabs/lndclient"
|
|
"github.com/lightninglabs/loop"
|
|
"github.com/lightninglabs/loop/loopd"
|
|
"github.com/lightninglabs/loop/looprpc"
|
|
"github.com/lightninglabs/loop/swap"
|
|
"github.com/lightningnetwork/lnd/clock"
|
|
"github.com/lightningnetwork/lnd/lncfg"
|
|
"github.com/lightningnetwork/lnd/lnrpc"
|
|
"github.com/lightningnetwork/lnd/macaroons"
|
|
"github.com/urfave/cli/v3"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/credentials"
|
|
"google.golang.org/protobuf/proto"
|
|
"gopkg.in/macaroon.v2"
|
|
)
|
|
|
|
var (
|
|
// Define route independent max routing fees. We have currently no way
|
|
// to get a reliable estimate of the routing fees. Best we can do is
|
|
// the minimum routing fees, which is not very indicative.
|
|
maxRoutingFeeBase = btcutil.Amount(10)
|
|
|
|
maxRoutingFeeRate = int64(20000)
|
|
|
|
defaultSwapWaitTime = 30 * time.Minute
|
|
|
|
// maxMsgRecvSize is the largest message our client will receive. We
|
|
// set this to 200MiB atm.
|
|
maxMsgRecvSize = grpc.MaxCallRecvMsgSize(1 * 1024 * 1024 * 200)
|
|
|
|
// defaultMacaroonTimeout is the default macaroon timeout in seconds
|
|
// that we set when sending it over the line.
|
|
defaultMacaroonTimeout int64 = 60
|
|
|
|
// defaultInitiator is the default value for the "initiator" part of the
|
|
// user agent string we send when using the command line utility.
|
|
defaultInitiator = "loop-cli"
|
|
|
|
loopDirFlag = &cli.StringFlag{
|
|
Name: "loopdir",
|
|
Value: loopd.LoopDirBase,
|
|
DefaultText: defaultPathText(loopd.LoopDirBase, os.UserHomeDir),
|
|
Usage: "path to loop's base directory",
|
|
Sources: cli.EnvVars(envVarLoopDir),
|
|
}
|
|
networkFlag = &cli.StringFlag{
|
|
Name: "network",
|
|
Aliases: []string{"n"},
|
|
Usage: "the network loop is running on e.g. mainnet, testnet, etc.",
|
|
Value: loopd.DefaultNetwork,
|
|
Sources: cli.EnvVars(envVarNetwork),
|
|
}
|
|
|
|
tlsCertFlag = &cli.StringFlag{
|
|
Name: "tlscertpath",
|
|
Usage: "path to loop's TLS certificate",
|
|
Value: loopd.DefaultTLSCertPath,
|
|
DefaultText: defaultPathText(
|
|
loopd.DefaultTLSCertPath, os.UserHomeDir,
|
|
),
|
|
Sources: cli.EnvVars(envVarTLSCertPath),
|
|
}
|
|
macaroonPathFlag = &cli.StringFlag{
|
|
Name: "macaroonpath",
|
|
Usage: "path to macaroon file",
|
|
Value: loopd.DefaultMacaroonPath,
|
|
DefaultText: defaultPathText(
|
|
loopd.DefaultMacaroonPath, os.UserHomeDir,
|
|
),
|
|
Sources: cli.EnvVars(envVarMacaroonPath),
|
|
}
|
|
verboseFlag = &cli.BoolFlag{
|
|
Name: "verbose",
|
|
Aliases: []string{"v"},
|
|
Usage: "show expanded details",
|
|
}
|
|
|
|
commands = []*cli.Command{
|
|
loopOutCommand, loopInCommand, termsCommand,
|
|
monitorCommand, quoteCommand, listAuthCommand, fetchL402Command,
|
|
listSwapsCommand, swapInfoCommand, getLiquidityParamsCommand,
|
|
setLiquidityRuleCommand, suggestSwapCommand, setParamsCommand,
|
|
getInfoCommand, abandonSwapCommand, reservationsCommands,
|
|
instantOutCommand, listInstantOutsCommand, stopCommand,
|
|
printManCommand, printMarkdownCommand,
|
|
}
|
|
|
|
// cliClock provides the time source used by CLI commands.
|
|
cliClock clock.Clock = clock.NewDefaultClock()
|
|
|
|
// sessionRec is the active recorder when session capture is enabled.
|
|
sessionRec *sessionRecorder
|
|
|
|
// forceDeterministicJSON is enabled by tests to obtain stable JSON
|
|
// output.
|
|
forceDeterministicJSON bool
|
|
)
|
|
|
|
// installSessionSignalHandler records signals and cancels the root context.
|
|
func installSessionSignalHandler(cancel context.CancelFunc) func() {
|
|
if sessionRec == nil {
|
|
return func() {}
|
|
}
|
|
|
|
sigCh := make(chan os.Signal, 1)
|
|
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
interrupted := false
|
|
for sig := range sigCh {
|
|
sessionRec.LogSignal(sig)
|
|
if !interrupted {
|
|
interrupted = true
|
|
cancel()
|
|
continue
|
|
}
|
|
|
|
// A second signal requests immediate shutdown. Finalize the
|
|
// session first because os.Exit skips deferred cleanup.
|
|
_ = sessionRec.Finalize(fmt.Errorf("signal: %s", sig))
|
|
os.Exit(130)
|
|
}
|
|
}()
|
|
|
|
return func() {
|
|
signal.Stop(sigCh)
|
|
close(sigCh)
|
|
<-done
|
|
}
|
|
}
|
|
|
|
const (
|
|
|
|
// satAmtFmt formats a satoshi value into a one line string, intended to
|
|
// prettify the terminal output. For Instance,
|
|
// fmt.Printf(f, "Estimated on-chain fee:", fee)
|
|
// prints out as,
|
|
// Estimated on-chain fee: 7262 sat
|
|
satAmtFmt = "%-36s %12d sat\n"
|
|
|
|
// assetAmtFormat formats a value into a one line string, intended to
|
|
// prettify the terminal output. For Instance,
|
|
// fmt.Printf(f, "Amount:", amt, "USD")
|
|
// prints out as,
|
|
// Amount: 50 USD
|
|
assetAmtFmt = "%-36s %12d %s\n"
|
|
|
|
// rateFmt formats an exchange rate into a one line string, intended to
|
|
// prettify the terminal output. For Instance,
|
|
// fmt.Printf(f, "Exchange rate:", rate, "USD")
|
|
// prints out as,
|
|
// Exchange rate: 0.0002 USD/SAT
|
|
rateFmt = "%-36s %12.4f %s/SAT\n"
|
|
|
|
// blkFmt formats the number of blocks into a one line string, intended
|
|
// to prettify the terminal output. For Instance,
|
|
// fmt.Printf(f, "Conf target", target)
|
|
// prints out as,
|
|
// Conf target: 9 block
|
|
blkFmt = "%-36s %12d block\n"
|
|
|
|
// Environment variables names that can be used to set the global flags.
|
|
envVarRPCServer = "LOOPCLI_RPCSERVER"
|
|
envVarNetwork = "LOOPCLI_NETWORK"
|
|
envVarLoopDir = "LOOPCLI_LOOPDIR"
|
|
envVarTLSCertPath = "LOOPCLI_TLSCERTPATH"
|
|
envVarMacaroonPath = "LOOPCLI_MACAROONPATH"
|
|
)
|
|
|
|
// defaultPathText returns a help-friendly path string that replaces the user's
|
|
// home directory with "~". The homeDir function is injected so callers can
|
|
// control environment-dependent behavior in tests.
|
|
func defaultPathText(value string, homeDir func() (string, error)) string {
|
|
if value == "" {
|
|
return value
|
|
}
|
|
|
|
if homeDir == nil {
|
|
return value
|
|
}
|
|
|
|
home, err := homeDir()
|
|
if err != nil || home == "" {
|
|
return value
|
|
}
|
|
|
|
cleanHome := filepath.Clean(home)
|
|
cleanValue := filepath.Clean(value)
|
|
if cleanValue == cleanHome {
|
|
return "~"
|
|
}
|
|
|
|
prefix := cleanHome + string(filepath.Separator)
|
|
if suffix, ok := strings.CutPrefix(cleanValue, prefix); ok {
|
|
return "~" + string(filepath.Separator) + suffix
|
|
}
|
|
|
|
return value
|
|
}
|
|
|
|
func printJSON(resp any) {
|
|
b, err := json.Marshal(resp)
|
|
if err != nil {
|
|
fatal(err)
|
|
}
|
|
|
|
var out bytes.Buffer
|
|
err = json.Indent(&out, b, "", "\t")
|
|
if err != nil {
|
|
fatal(err)
|
|
}
|
|
out.WriteString("\n")
|
|
printBytes := maybeNormalizeJSON(out.Bytes())
|
|
_, _ = os.Stdout.Write(printBytes)
|
|
}
|
|
|
|
func printRespJSON(resp proto.Message) {
|
|
jsonBytes, err := lnrpc.ProtoJSONMarshalOpts.Marshal(resp)
|
|
if err != nil {
|
|
fmt.Println("unable to decode response: ", err)
|
|
return
|
|
}
|
|
|
|
fmt.Println(string(maybeNormalizeJSON(jsonBytes)))
|
|
}
|
|
|
|
func fatal(err error) {
|
|
fmt.Fprintf(os.Stderr, "[loop] %v\n", err)
|
|
if sessionRec != nil {
|
|
if finalizeErr := sessionRec.Finalize(err); finalizeErr != nil {
|
|
fmt.Fprintf(os.Stderr, "[loop] unable to finalize "+
|
|
"session: %v\n", finalizeErr)
|
|
}
|
|
}
|
|
os.Exit(1)
|
|
}
|
|
|
|
func main() {
|
|
var err error
|
|
sessionRec, err = newSessionRecorder(os.Args)
|
|
if err != nil {
|
|
fatal(err)
|
|
}
|
|
|
|
// Intercept clock and stdio if needed.
|
|
if sessionRec != nil {
|
|
restoreClock := hookClock(
|
|
clock.NewTestClock(sessionRec.ClockStart()),
|
|
)
|
|
defer restoreClock()
|
|
|
|
if err := sessionRec.Start(nil, nil, nil); err != nil {
|
|
fatal(err)
|
|
}
|
|
}
|
|
|
|
// Intercept clock calls if needed.
|
|
var restoreTransport func()
|
|
if sessionRec != nil {
|
|
restoreTransport = hookGrpc(sessionRec)
|
|
defer restoreTransport()
|
|
}
|
|
|
|
rootCmd := newRootCommand()
|
|
|
|
ctx := context.Background()
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
defer cancel()
|
|
|
|
if sessionRec != nil {
|
|
ctx = sessionRec.InjectContext(ctx)
|
|
}
|
|
|
|
if sessionRec != nil {
|
|
signalStop := installSessionSignalHandler(cancel)
|
|
defer signalStop()
|
|
}
|
|
|
|
if err := rootCmd.Run(ctx, os.Args); err != nil {
|
|
fatal(err)
|
|
}
|
|
|
|
if sessionRec != nil {
|
|
if err := sessionRec.Finalize(nil); err != nil {
|
|
fmt.Fprintf(os.Stderr, "[loop] unable to finalize "+
|
|
"session: %v\n", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// newRootCommand constructs the CLI root command for loop.
|
|
func newRootCommand() *cli.Command {
|
|
return &cli.Command{
|
|
Name: "loop",
|
|
Usage: "control plane for your loopd",
|
|
Version: loop.RichVersion(),
|
|
Flags: []cli.Flag{
|
|
&cli.StringFlag{
|
|
Name: "rpcserver",
|
|
Value: "localhost:11010",
|
|
Usage: "loopd daemon address host:port",
|
|
Sources: cli.EnvVars(envVarRPCServer),
|
|
},
|
|
networkFlag,
|
|
loopDirFlag,
|
|
tlsCertFlag,
|
|
macaroonPathFlag,
|
|
},
|
|
Commands: commands,
|
|
Action: func(ctx context.Context, cmd *cli.Command) error {
|
|
return cli.ShowRootCommandHelp(cmd)
|
|
},
|
|
}
|
|
}
|
|
|
|
// getClient establishes a SwapClient RPC connection and returns the client and
|
|
// a cleanup handler.
|
|
func getClient(cmd *cli.Command) (looprpc.SwapClientClient, func(), error) {
|
|
client, _, cleanup, err := getClientWithConn(cmd)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
return client, cleanup, nil
|
|
}
|
|
|
|
// getClientWithConn returns both the SwapClient RPC client and the underlying
|
|
// gRPC connection so callers can perform connection-aware actions.
|
|
func getClientWithConn(cmd *cli.Command) (looprpc.SwapClientClient,
|
|
daemonConn, func(), error) {
|
|
|
|
conn, cleanup, err := sessionTransport.Dial(cmd)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
|
|
loopClient := looprpc.NewSwapClientClient(conn)
|
|
return loopClient, conn, cleanup, nil
|
|
}
|
|
|
|
// hookClock overrides cliClock until the returned callback is called.
|
|
func hookClock(c clock.Clock) func() {
|
|
prev := cliClock
|
|
cliClock = c
|
|
|
|
return func() {
|
|
cliClock = prev
|
|
}
|
|
}
|
|
|
|
// maybeNormalizeJSON rewrites JSON output to avoid the build-dependent spacing
|
|
// introduced by google.golang.org/protobuf/internal/encoding/json (see
|
|
// WriteName in protobuf-go-hex-display/internal/encoding/json/encode.go, which
|
|
// uses internal/detrand.Bool). When recording or replaying sessions we ensure
|
|
// stable output by re-encoding with the standard library.
|
|
func maybeNormalizeJSON(raw []byte) []byte {
|
|
if sessionRec == nil && !forceDeterministicJSON {
|
|
return raw
|
|
}
|
|
|
|
var parsed any
|
|
if err := json.Unmarshal(raw, &parsed); err != nil {
|
|
return raw
|
|
}
|
|
|
|
normalized, err := json.MarshalIndent(parsed, "", " ")
|
|
if err != nil {
|
|
return raw
|
|
}
|
|
|
|
if len(raw) > 0 && raw[len(raw)-1] == '\n' {
|
|
normalized = append(normalized, '\n')
|
|
}
|
|
|
|
return normalized
|
|
}
|
|
|
|
func getMaxRoutingFee(amt btcutil.Amount) btcutil.Amount {
|
|
return swap.CalcFee(amt, maxRoutingFeeBase, maxRoutingFeeRate)
|
|
}
|
|
|
|
// extractPathArgs parses the TLS certificate and macaroon paths from the
|
|
// command.
|
|
func extractPathArgs(cmd *cli.Command) (string, string, error) {
|
|
// We'll start off by parsing the network. This is needed to determine
|
|
// the correct path to the TLS certificate and macaroon when not
|
|
// specified.
|
|
networkStr := strings.ToLower(cmd.String("network"))
|
|
_, err := lndclient.Network(networkStr).ChainParams()
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
// We'll now fetch the loopdir so we can make a decision on how to
|
|
// properly read the macaroons and also the cert. This will either be
|
|
// the default, or will have been overwritten by the end user.
|
|
loopDir := lncfg.CleanAndExpandPath(cmd.String(loopDirFlag.Name))
|
|
|
|
tlsCertPathRaw := cmd.String(tlsCertFlag.Name)
|
|
tlsCertPath := lncfg.CleanAndExpandPath(tlsCertPathRaw)
|
|
|
|
macPathRaw := cmd.String(macaroonPathFlag.Name)
|
|
macPath := lncfg.CleanAndExpandPath(macPathRaw)
|
|
|
|
// If a custom loop directory or network 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 loop directory set. This allows us to set a custom loop
|
|
// directory and/or network, along with custom paths to the TLS cert and
|
|
// macaroon file.
|
|
if loopDir != loopd.LoopDirBase || networkStr != loopd.DefaultNetwork {
|
|
if tlsCertPathRaw == loopd.DefaultTLSCertPath {
|
|
tlsCertPath = filepath.Join(
|
|
loopDir, networkStr,
|
|
loopd.DefaultTLSCertFilename,
|
|
)
|
|
}
|
|
|
|
if macPathRaw == loopd.DefaultMacaroonPath {
|
|
macPath = filepath.Join(
|
|
loopDir, networkStr,
|
|
loopd.DefaultMacaroonFilename,
|
|
)
|
|
}
|
|
}
|
|
|
|
return tlsCertPath, macPath, nil
|
|
}
|
|
|
|
type inLimits struct {
|
|
maxMinerFee btcutil.Amount
|
|
maxSwapFee btcutil.Amount
|
|
}
|
|
|
|
func getInLimits(quote *looprpc.InQuoteResponse) *inLimits {
|
|
return &inLimits{
|
|
// Apply a multiplier to the estimated miner fee, to not get
|
|
// the swap canceled because fees increased in the mean time.
|
|
maxMinerFee: btcutil.Amount(quote.HtlcPublishFeeSat) * 3,
|
|
maxSwapFee: btcutil.Amount(quote.SwapFeeSat),
|
|
}
|
|
}
|
|
|
|
type outLimits struct {
|
|
maxSwapRoutingFee btcutil.Amount
|
|
maxPrepayRoutingFee btcutil.Amount
|
|
maxMinerFee btcutil.Amount
|
|
maxSwapFee btcutil.Amount
|
|
maxPrepayAmt btcutil.Amount
|
|
}
|
|
|
|
func getOutLimits(amt btcutil.Amount,
|
|
quote *looprpc.OutQuoteResponse) *outLimits {
|
|
|
|
maxSwapRoutingFee := getMaxRoutingFee(amt)
|
|
maxPrepayRoutingFee := getMaxRoutingFee(btcutil.Amount(
|
|
quote.PrepayAmtSat,
|
|
))
|
|
maxPrepayAmt := btcutil.Amount(quote.PrepayAmtSat)
|
|
|
|
return &outLimits{
|
|
maxSwapRoutingFee: maxSwapRoutingFee,
|
|
maxPrepayRoutingFee: maxPrepayRoutingFee,
|
|
|
|
// Apply a multiplier to the estimated miner fee, to not get
|
|
// the swap canceled because fees increased in the mean time.
|
|
maxMinerFee: btcutil.Amount(quote.HtlcSweepFeeSat) * 250,
|
|
|
|
maxSwapFee: btcutil.Amount(quote.SwapFeeSat),
|
|
maxPrepayAmt: maxPrepayAmt,
|
|
}
|
|
}
|
|
|
|
func displayInDetails(req *looprpc.QuoteRequest,
|
|
resp *looprpc.InQuoteResponse, verbose bool) error {
|
|
|
|
if req.ExternalHtlc {
|
|
fmt.Printf("On-chain fee for external loop in is not " +
|
|
"included.\nSufficient fees will need to be paid " +
|
|
"when constructing the transaction in the external " +
|
|
"wallet.\n\n")
|
|
}
|
|
|
|
printQuoteInResp(req, resp, verbose)
|
|
|
|
fmt.Printf("\nCONTINUE SWAP? (y/n): ")
|
|
|
|
var answer string
|
|
fmt.Scanln(&answer)
|
|
if answer == "y" {
|
|
return nil
|
|
}
|
|
|
|
return errors.New("swap canceled")
|
|
}
|
|
|
|
func displayOutDetails(l *outLimits, warning string, req *looprpc.QuoteRequest,
|
|
resp *looprpc.OutQuoteResponse, verbose bool) error {
|
|
|
|
printQuoteOutResp(req, resp, verbose)
|
|
|
|
// Display fee limits.
|
|
if verbose {
|
|
fmt.Println()
|
|
fmt.Printf(satAmtFmt, "Max on-chain fee:", l.maxMinerFee)
|
|
fmt.Printf(satAmtFmt,
|
|
"Max off-chain swap routing fee:", l.maxSwapRoutingFee,
|
|
)
|
|
fmt.Printf(satAmtFmt, "Max off-chain prepay routing fee:",
|
|
l.maxPrepayRoutingFee)
|
|
}
|
|
|
|
// show warning
|
|
if warning != "" {
|
|
fmt.Printf("\n%s\n\n", warning)
|
|
}
|
|
|
|
fmt.Printf("CONTINUE SWAP? (y/n): ")
|
|
|
|
var answer string
|
|
fmt.Scanln(&answer)
|
|
if answer == "y" {
|
|
return nil
|
|
}
|
|
|
|
return errors.New("swap canceled")
|
|
}
|
|
|
|
func parseAmt(text string) (btcutil.Amount, error) {
|
|
amtInt64, err := strconv.ParseInt(text, 10, 64)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("invalid amt value %q", text)
|
|
}
|
|
|
|
if amtInt64 < 0 {
|
|
return 0, fmt.Errorf("negative amount %d", amtInt64)
|
|
}
|
|
|
|
return btcutil.Amount(amtInt64), nil
|
|
}
|
|
|
|
func logSwap(swap *looprpc.SwapStatus) {
|
|
// If our swap failed, we add our failure reason to the state.
|
|
swapState := monitorSwapState(swap)
|
|
|
|
if swap.Type == looprpc.SwapType_LOOP_OUT {
|
|
fmt.Printf("%v %v %v %v - %v",
|
|
time.Unix(0, swap.LastUpdateTime).Format(time.RFC3339),
|
|
swap.Type, swapState, btcutil.Amount(swap.Amt),
|
|
swap.HtlcAddressP2Wsh,
|
|
)
|
|
} else {
|
|
fmt.Printf("%v %v %v %v -",
|
|
time.Unix(0, swap.LastUpdateTime).Format(time.RFC3339),
|
|
swap.Type, swapState, btcutil.Amount(swap.Amt))
|
|
|
|
if swap.HtlcAddressP2Wsh != "" {
|
|
fmt.Printf(" P2WSH: %v", swap.HtlcAddressP2Wsh)
|
|
}
|
|
|
|
if swap.HtlcAddressP2Tr != "" {
|
|
fmt.Printf(" P2TR: %v", swap.HtlcAddressP2Tr)
|
|
}
|
|
}
|
|
|
|
showCost := shouldShowSwapCost(swap.GetState())
|
|
if swap.Type == looprpc.SwapType_STATIC_LOOP_IN {
|
|
staticState := swap.GetStaticLoopInState()
|
|
// Static loop-ins key cost visibility off the dedicated FSM state, not
|
|
// the generic SwapState lifecycle used by traditional swaps.
|
|
switch staticState {
|
|
case looprpc.StaticAddressLoopInSwapState_INIT_HTLC,
|
|
looprpc.StaticAddressLoopInSwapState_SIGN_HTLC_TX,
|
|
looprpc.StaticAddressLoopInSwapState_MONITOR_INVOICE_HTLC_TX,
|
|
looprpc.StaticAddressLoopInSwapState_SWEEP_STATIC_ADDRESS_HTLC_TIMEOUT,
|
|
looprpc.StaticAddressLoopInSwapState_MONITOR_HTLC_TIMEOUT_SWEEP,
|
|
looprpc.StaticAddressLoopInSwapState_UNLOCK_DEPOSITS:
|
|
|
|
showCost = false
|
|
|
|
case looprpc.StaticAddressLoopInSwapState_PAYMENT_RECEIVED,
|
|
looprpc.StaticAddressLoopInSwapState_HTLC_STATIC_ADDRESS_TIMEOUT_SWEPT,
|
|
looprpc.StaticAddressLoopInSwapState_SUCCEEDED,
|
|
looprpc.StaticAddressLoopInSwapState_SUCCEEDED_TRANSITIONING_FAILED,
|
|
looprpc.StaticAddressLoopInSwapState_FAILED_STATIC_ADDRESS_SWAP:
|
|
|
|
showCost = true
|
|
}
|
|
}
|
|
|
|
if showCost {
|
|
fmt.Printf(" (cost: server %v, onchain %v, offchain %v)",
|
|
swap.CostServer, swap.CostOnchain, swap.CostOffchain,
|
|
)
|
|
}
|
|
|
|
fmt.Println()
|
|
}
|
|
|
|
// monitorSwapState returns the static loop-in FSM label for static swaps and
|
|
// the shared swap-state label for all others.
|
|
func monitorSwapState(swap *looprpc.SwapStatus) string {
|
|
if swap.Type == looprpc.SwapType_STATIC_LOOP_IN {
|
|
return swap.GetStaticLoopInState().String()
|
|
}
|
|
|
|
return genericMonitorSwapState(swap)
|
|
}
|
|
|
|
// shouldShowSwapCost reports whether a swap's generic state is terminal enough
|
|
// to include the persisted cost summary in monitor output.
|
|
func shouldShowSwapCost(loopState looprpc.SwapState) bool {
|
|
return loopState != looprpc.SwapState_INITIATED &&
|
|
loopState != looprpc.SwapState_HTLC_PUBLISHED &&
|
|
loopState != looprpc.SwapState_PREIMAGE_REVEALED
|
|
}
|
|
|
|
// genericMonitorSwapState formats the shared swap-state label and failure
|
|
// reason used by non-static swaps.
|
|
func genericMonitorSwapState(swap *looprpc.SwapStatus) string {
|
|
loopState := swap.GetState()
|
|
swapState := fmt.Sprintf("%v", loopState)
|
|
if loopState == looprpc.SwapState_FAILED {
|
|
swapState = fmt.Sprintf(
|
|
"%v (%v)", swapState, swap.FailureReason,
|
|
)
|
|
}
|
|
|
|
return swapState
|
|
}
|
|
|
|
// getClientConn dials the loopd gRPC server with TLS and macaroon auth.
|
|
func getClientConn(address, tlsCertPath, macaroonPath string) (daemonConn,
|
|
func(), error) {
|
|
|
|
// We always need to send a macaroon.
|
|
macOption, err := readMacaroon(macaroonPath)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
opts := []grpc.DialOption{
|
|
grpc.WithDefaultCallOptions(maxMsgRecvSize),
|
|
macOption,
|
|
}
|
|
|
|
// Install gRPC interceptors for session recording if needed.
|
|
if unary := sessionTransport.UnaryInterceptor(); unary != nil {
|
|
opts = append(opts, grpc.WithChainUnaryInterceptor(unary))
|
|
}
|
|
if stream := sessionTransport.StreamInterceptor(); stream != nil {
|
|
opts = append(opts, grpc.WithChainStreamInterceptor(stream))
|
|
}
|
|
|
|
// Since TLS cannot be disabled, we'll always have a cert file to read.
|
|
creds, err := credentials.NewClientTLSFromFile(tlsCertPath, "")
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
opts = append(opts, grpc.WithTransportCredentials(creds))
|
|
|
|
conn, err := grpc.NewClient(address, opts...)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("unable to create RPC client: %v",
|
|
err)
|
|
}
|
|
|
|
cleanup := func() {
|
|
_ = conn.Close()
|
|
}
|
|
|
|
return conn, cleanup, nil
|
|
}
|
|
|
|
// readMacaroon tries to read the macaroon file at the specified path and create
|
|
// gRPC dial options from it.
|
|
func readMacaroon(macPath string) (grpc.DialOption, error) {
|
|
// Load the specified macaroon file.
|
|
macBytes, err := os.ReadFile(macPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to read macaroon path : %v", err)
|
|
}
|
|
|
|
mac := &macaroon.Macaroon{}
|
|
if err = mac.UnmarshalBinary(macBytes); err != nil {
|
|
return nil, fmt.Errorf("unable to decode macaroon: %v", 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).
|
|
macaroons.TimeoutConstraint(defaultMacaroonTimeout),
|
|
}
|
|
|
|
// Apply constraints to the macaroon.
|
|
constrainedMac, err := macaroons.AddConstraints(mac, macConstraints...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Now we append the macaroon credentials to the dial options.
|
|
cred, err := macaroons.NewMacaroonCredential(constrainedMac)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error creating macaroon credential: %v",
|
|
err)
|
|
}
|
|
return grpc.WithPerRPCCredentials(cred), nil
|
|
}
|