mirror of
https://github.com/lightninglabs/loop.git
synced 2026-08-13 12:33:03 +02:00
LoopMinRequiredLndVersion was 0.17.0, a value that only ever tracked the
go.mod lnd dependency rounded down and was never updated as the client
started depending on newer lnd RPC APIs. The client today uses RPC
fields that do not exist in 0.17.0:
- routerrpc.SendPaymentRequest.first_hop_custom_records and
lnrpc.Route.custom_channel_data, used by asset loop outs in
loopout.go: both added in lnd v0.18.4-beta.
- walletrpc.EstimateFeeResponse.min_relay_fee_sat_per_kw, read by the
sweep batcher fee floor via lndclient WalletKit.MinRelayFee
(sweepbatcher/, loopd/sweep_htlc.go): added in lnd v0.18.3-beta. On
older lnd it silently decodes to 0, disabling the min-relay floor.
Raise the floor to the highest of these (v0.18.4-beta) so loopd fails
fast at startup rather than misbehaving at runtime, and document in
AGENTS.md the rule to keep this value pinned to the lnd APIs the client
actually uses instead of tracking go.mod.
295 lines
8.7 KiB
Go
295 lines
8.7 KiB
Go
package loopd
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/jessevdk/go-flags"
|
|
"github.com/lightninglabs/lndclient"
|
|
"github.com/lightninglabs/loop"
|
|
"github.com/lightningnetwork/lnd/build"
|
|
"github.com/lightningnetwork/lnd/lncfg"
|
|
"github.com/lightningnetwork/lnd/lnrpc/verrpc"
|
|
"github.com/lightningnetwork/lnd/signal"
|
|
)
|
|
|
|
const defaultConfigFilename = "loopd.conf"
|
|
|
|
var (
|
|
// LoopMinRequiredLndVersion is the minimum required version of lnd that
|
|
// is compatible with the current version of the loop client. Also all
|
|
// listed build tags/subservers need to be enabled.
|
|
//
|
|
// IMPORTANT: bump this whenever the client starts using an lnd RPC
|
|
// method or message field that does not exist in older lnd, to the lnd
|
|
// release that introduced that API (see the maintenance note in
|
|
// AGENTS.md). The current floor of v0.18.4-beta is set by the highest
|
|
// such dependency the client has today:
|
|
// - routerrpc.SendPaymentRequest.first_hop_custom_records and
|
|
// lnrpc.Route.custom_channel_data, used by asset loop outs
|
|
// (loopout.go): both added in lnd v0.18.4-beta.
|
|
// - walletrpc.EstimateFeeResponse.min_relay_fee_sat_per_kw, read by
|
|
// the sweep batcher fee floor via lndclient WalletKit.MinRelayFee
|
|
// (sweepbatcher/, loopd/sweep_htlc.go): added in lnd v0.18.3-beta.
|
|
// On older lnd this field silently decodes to 0, disabling the
|
|
// sweeper's min-relay fee floor.
|
|
LoopMinRequiredLndVersion = &verrpc.Version{
|
|
AppMajor: 0,
|
|
AppMinor: 18,
|
|
AppPatch: 4,
|
|
BuildTags: []string{
|
|
"signrpc", "walletrpc", "chainrpc", "invoicesrpc",
|
|
},
|
|
}
|
|
)
|
|
|
|
// RPCConfig holds optional options that can be used to make the loop daemon
|
|
// communicate on custom connections.
|
|
type RPCConfig struct {
|
|
// RPCListener is an optional listener that if set will override the
|
|
// daemon's gRPC settings, and make the gRPC server listen on this
|
|
// listener.
|
|
// Note that setting this will also disable REST.
|
|
RPCListener net.Listener
|
|
|
|
// LndConn is an optional connection to an lnd instance. If set it will
|
|
// override the TCP connection created from daemon's config.
|
|
LndConn net.Conn
|
|
}
|
|
|
|
// NewListenerConfig creates and returns a new listenerCfg from the passed
|
|
// config and RPCConfig.
|
|
func NewListenerConfig(config *Config, rpcCfg RPCConfig) *ListenerCfg {
|
|
return &ListenerCfg{
|
|
grpcListener: func(tlsCfg *tls.Config) (net.Listener, error) {
|
|
// If a custom RPC listener is set, we will listen on
|
|
// it instead of the regular tcp socket.
|
|
if rpcCfg.RPCListener != nil {
|
|
return rpcCfg.RPCListener, nil
|
|
}
|
|
|
|
listener, err := net.Listen("tcp", config.RPCListen)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return tls.NewListener(listener, tlsCfg), nil
|
|
},
|
|
restListener: func(tlsCfg *tls.Config) (net.Listener, error) {
|
|
// If a custom RPC listener is set, we disable REST.
|
|
if rpcCfg.RPCListener != nil {
|
|
return nil, nil
|
|
}
|
|
|
|
listener, err := net.Listen("tcp", config.RESTListen)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return tls.NewListener(listener, tlsCfg), nil
|
|
},
|
|
getLnd: func(network lndclient.Network, cfg *lndConfig) (
|
|
*lndclient.GrpcLndServices, error) {
|
|
|
|
callerCtx, cancel := context.WithCancel(
|
|
context.Background(),
|
|
)
|
|
defer cancel()
|
|
|
|
svcCfg := &lndclient.LndServicesConfig{
|
|
LndAddress: cfg.Host,
|
|
Network: network,
|
|
CustomMacaroonPath: cfg.MacaroonPath,
|
|
TLSPath: cfg.TLSPath,
|
|
CheckVersion: LoopMinRequiredLndVersion,
|
|
CallerCtx: callerCtx,
|
|
RPCTimeout: cfg.RPCTimeout,
|
|
|
|
BlockUntilChainSynced: true,
|
|
BlockUntilUnlocked: true,
|
|
BlockUntilChainNotifier: true,
|
|
}
|
|
|
|
// If a custom lnd connection is specified we use that
|
|
// directly.
|
|
if rpcCfg.LndConn != nil {
|
|
svcCfg.Dialer = func(context.Context, string) (
|
|
net.Conn, error) {
|
|
|
|
return rpcCfg.LndConn, nil
|
|
}
|
|
}
|
|
|
|
// Before we try to get our client connection, setup
|
|
// a goroutine which will cancel our lndclient if loopd
|
|
// is terminated, or exit if our context is cancelled.
|
|
go func() {
|
|
select {
|
|
// If the client decides to kill loop before
|
|
// lnd is synced, we cancel our context, which
|
|
// will unblock lndclient.
|
|
case <-interceptor.ShutdownChannel():
|
|
cancel()
|
|
|
|
// If our sync context was cancelled, we know
|
|
// that the function exited, which means that
|
|
// our client synced.
|
|
case <-callerCtx.Done():
|
|
}
|
|
}()
|
|
|
|
// This will block until lnd is synced to chain.
|
|
return lndclient.NewLndServices(svcCfg)
|
|
},
|
|
}
|
|
}
|
|
|
|
// Run starts the loop daemon and blocks until it's shut down again.
|
|
func Run(rpcCfg RPCConfig) error {
|
|
config := DefaultConfig()
|
|
|
|
// Parse command line flags.
|
|
parser := flags.NewParser(&config, flags.Default)
|
|
parser.SubcommandsOptional = true
|
|
|
|
_, err := parser.Parse()
|
|
if e, ok := err.(*flags.Error); ok && e.Type == flags.ErrHelp {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Parse ini file.
|
|
loopDir := lncfg.CleanAndExpandPath(config.LoopDir)
|
|
configFile, hasExplicitConfig := getConfigPath(config, loopDir)
|
|
|
|
if err := flags.IniParse(configFile, &config); err != nil {
|
|
// File not existing is OK as long as it wasn't specified
|
|
// explicitly. All other errors (parsing, EACCESS...) indicate
|
|
// misconfiguration and need to be reported. In case of
|
|
// non-not-found FS errors there's high likelihood that other
|
|
// operations in data directory would also fail so we treat it
|
|
// as early detection of a problem.
|
|
if hasExplicitConfig || !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Parse command line flags again to restore flags overwritten by ini
|
|
// parse.
|
|
_, err = parser.Parse()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Show the version and exit if the version flag was specified.
|
|
appName := filepath.Base(os.Args[0])
|
|
appName = strings.TrimSuffix(appName, filepath.Ext(appName))
|
|
if config.ShowVersion {
|
|
fmt.Println(appName, "version", loop.RichVersion())
|
|
os.Exit(0)
|
|
}
|
|
|
|
// Validate our config before we proceed.
|
|
if err := Validate(&config); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Start listening for signal interrupts regardless of which command
|
|
// we are running. When our command tries to get a lnd connection, it
|
|
// blocks until lnd is synced. We listen for interrupts so that we can
|
|
// shutdown the daemon while waiting for sync to complete.
|
|
shutdownInterceptor, err := signal.Intercept()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Initialize logging at the default logging level.
|
|
logWriter := build.NewRotatingLogWriter()
|
|
subLogMgr := build.NewSubLoggerManager(
|
|
build.NewDefaultLogHandlers(config.Logging, logWriter)...,
|
|
)
|
|
SetupLoggers(subLogMgr, shutdownInterceptor)
|
|
|
|
// Special show command to list supported subsystems and exit.
|
|
if config.DebugLevel == "show" {
|
|
fmt.Printf("Supported subsystems: %v\n",
|
|
subLogMgr.SupportedSubsystems())
|
|
|
|
os.Exit(0)
|
|
}
|
|
|
|
err = logWriter.InitLogRotator(
|
|
config.Logging.File,
|
|
filepath.Join(config.LogDir, defaultLogFilename),
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = build.ParseAndSetDebugLevels(config.DebugLevel, subLogMgr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Print the version before executing either primary directive.
|
|
infof("Version: %v", loop.RichVersion())
|
|
|
|
lisCfg := NewListenerConfig(&config, rpcCfg)
|
|
|
|
// Execute command.
|
|
if parser.Active == nil {
|
|
daemon := New(&config, lisCfg)
|
|
if err := daemon.Start(); err != nil {
|
|
return err
|
|
}
|
|
|
|
select {
|
|
case <-interceptor.ShutdownChannel():
|
|
infof("Received SIGINT (Ctrl+C).")
|
|
daemon.Stop()
|
|
|
|
// The above stop will return immediately. But we'll be
|
|
// notified on the error channel once the process is
|
|
// complete.
|
|
return <-daemon.ErrChan
|
|
|
|
case err := <-daemon.ErrChan:
|
|
return err
|
|
}
|
|
}
|
|
|
|
if parser.Active.Name == "view" {
|
|
return view(&config, lisCfg)
|
|
}
|
|
|
|
return fmt.Errorf("unimplemented command %v", parser.Active.Name)
|
|
}
|
|
|
|
// getConfigPath gets our config path based on the values that are set in our
|
|
// config. The returned bool is set to true if the config file path was set
|
|
// explicitly by the user and thus should not be ignored if it doesn't exist.
|
|
func getConfigPath(cfg Config, loopDir string) (string, bool) {
|
|
// If the config file path provided by the user is set, then we just
|
|
// use this value.
|
|
if cfg.ConfigFile != defaultConfigFile {
|
|
return lncfg.CleanAndExpandPath(cfg.ConfigFile), true
|
|
}
|
|
|
|
// If the user has set a loop directory that is different to the default
|
|
// we will use this loop directory as the location of our config file.
|
|
// We do not namespace by network, because this is a custom loop dir.
|
|
if loopDir != LoopDirBase {
|
|
return filepath.Join(loopDir, defaultConfigFilename), false
|
|
}
|
|
|
|
// Otherwise, we are using our default loop directory, and the user did
|
|
// not set a config file path. We use our default loop dir, namespaced
|
|
// by network.
|
|
return filepath.Join(loopDir, cfg.Network, defaultConfigFilename), false
|
|
}
|