Merge pull request #8754 from ViktorT-11/2024-05-add-outbound-remote-signer
What changed, and why it matters
This commit adds a new 'outbound' remote-signer mode to LND. In the existing 'inbound' mode, the watch-only node connects to the signer; in the new 'outbound' mode, the signer node connects out to the watch-only node. The change introduces a dedicated gRPC listener, a new macaroon permission ('remotesigner:generate'), and configuration validation. It is a large feature addition rather than a clear-cut vulnerability fix, but it touches authentication, network exposure, and wallet startup logic, so it has security relevance.
Treat this as a security-relevant feature addition requiring review of the new dedicated RPC server's authentication, listener binding, and error handling. Operators should not enable the experimental outbound remote signer in production until the feature is no longer marked experimental and has undergone additional security review. Reviewers should verify the dedicated interceptor correctly rejects missing or under-privileged macaroons and that the shared macaroon service cannot be confused between the main and dedicated RPC servers.
Security signals we found
New dedicated gRPC listener and service for remote signer coordination
New macaroon permission entity 'remotesigner' with action 'generate' required for inbound signer connections
Added integration test verifying unauthorized macaroon is rejected with 'permission denied'
Configuration validation prevents simultaneous watch-only and remote-signer modes
Wallet readiness is now awaited before key derivation in Main startup
All new flags are labeled EXPERIMENTAL in descriptions
Dedicated server explicitly disables mandatory RPC middleware enforcement
Evidence from the diff
PR #8754 implements an outbound remote-signer topology. Key changes: (1) new lncfg.WatchOnlyNode config group for signer-side outbound connection settings; (2) new lncfg.RemoteSigner.InboundWatchOnlyCfg for watch-only-side dedicated listeners and startup timeout; (3) a dedicated gRPC server (watchonlyrpc) exposing only SignCoordinatorStreams on a separate port (default 10019) with its own rpcperms.InterceptorChain but sharing the main macaroon service; (4) macaroon enforcement requiring the ‘remotesigner’ entity to connect; (5) validation that a node cannot be both remote signer and watch-only simultaneously; (6) wallet ReadySignal wait before proceeding with key derivation; (7) extensive integration tests including a macaroon-enforcement test. The feature is marked experimental in multiple flag descriptions.
Changed components
lnd startup and chain control initialization (lnd.go, config_builder.go)remote signer configuration (lncfg/remotesigner.go)new watchonlyrpc gRPC service and generated code (lnrpc/watchonlyrpc/*)rpcwallet remote signer client and connection code (lnwallet/rpcwallet/*)RPC permissions interceptor (rpcperms/interceptor.go)integration tests (itest/lnd_remote_signer_test.go)Inspect captured patch +8600 / −312
### config.go
@@ -556,8 +556,15 @@ type Config struct {
RPCMiddleware *lncfg.RPCMiddleware `group:"rpcmiddleware" namespace:"rpcmiddleware"`
+ // RemoteSigner defines how to connect to a remote signer node. If this
+ // is enabled, the node acts as a watch-only node in a remote signer
+ // setup.
RemoteSigner *lncfg.RemoteSigner `group:"remotesigner" namespace:"remotesigner"`
+ // WatchOnlyNode defines how to connect to a watch-only node. If this is
+ // enabled, the node acts as a remote signer in a remote signer setup.
+ WatchOnlyNode *lncfg.WatchOnlyNode `group:"watchonlynode" namespace:"watchonlynode"`
+
Sweeper *lncfg.Sweeper `group:"sweeper" namespace:"sweeper"`
Htlcswitch *lncfg.Htlcswitch `group:"htlcswitch" namespace:"htlcswitch"`
@@ -864,10 +871,9 @@ func DefaultConfig() Config {
FwdHistoryDeleteBatchSize: defaultFwdHistoryDeleteBatchSize,
CoinSelectionStrategy: defaultCoinSelectionStrategy,
KeepFailedPaymentAttempts: defaultKeepFailedPaymentAttempts,
- RemoteSigner: &lncfg.RemoteSigner{
- Timeout: lncfg.DefaultRemoteSignerRPCTimeout,
- },
- Sweeper: lncfg.DefaultSweeperConfig(),
+ RemoteSigner: lncfg.DefaultRemoteSignerCfg(),
+ WatchOnlyNode: lncfg.DefaultWatchOnlyNodeCfg(),
+ Sweeper: lncfg.DefaultSweeperConfig(),
Htlcswitch: &lncfg.Htlcswitch{
MailboxDeliveryTimeout: htlcswitch.DefaultMailboxDeliveryTimeout,
QuiescenceTimeout: lncfg.DefaultQuiescenceTimeout,
@@ -1918,6 +1924,16 @@ func ValidateConfig(cfg Config, interceptor signal.Interceptor, fileParser,
)
}
+ // Validate that the node isn't configured as both a remote signer and a
+ // watch-only node.
+ if cfg.RemoteSigner.Enable &&
+ cfg.WatchOnlyNode.ExperimentalEnable {
+
+ return nil, fmt.Errorf("cannot be configured as both a " +
+ "watchonly node and a remote signer node " +
+ "simultaneously")
+ }
+
// Validate the subconfigs for workers, caches, and the tower client.
err = lncfg.Validate(
cfg.Workers,
@@ -1928,6 +1944,7 @@ func ValidateConfig(cfg Config, interceptor signal.Interceptor, fileParser,
cfg.HealthChecks,
cfg.RPCMiddleware,
cfg.RemoteSigner,
+ cfg.WatchOnlyNode,
cfg.Sweeper,
cfg.Htlcswitch,
cfg.Invoices,
### config_builder.go
@@ -148,7 +148,24 @@ type ChainControlBuilder interface {
// BuildChainControl is responsible for creating a fully populated chain
// control instance from a wallet.
BuildChainControl(*chainreg.PartialChainControl,
- *btcwallet.Config) (*chainreg.ChainControl, func(), error)
+ *btcwallet.Config) (*ChainControlResult, error)
+}
+
+// ChainControlResult is the result of creating the active chain control. It
+// carries the chain control itself, the cleanup hook for any resources created
+// during initialization and, if applicable, the inbound remote signer
+// connection used by the dedicated remote signer RPC server.
+type ChainControlResult struct {
+ // ChainControl is the initialized chain control for the active wallet.
+ ChainControl *chainreg.ChainControl
+
+ // CleanUp releases all resources created while building the chain
+ // control result.
+ CleanUp func()
+
+ // InboundRemoteSignerConn is the optional inbound remote signer
+ // connection exposed through the dedicated remote signer RPC server.
+ InboundRemoteSignerConn rpcwallet.InboundRemoteSignerConnection
}
// ImplementationCfg is a struct that holds all configuration items for
@@ -764,15 +781,15 @@ func (w *walletReBroadcaster) Started() bool {
// NOTE: This is part of the ChainControlBuilder interface.
func (d *DefaultWalletImpl) BuildChainControl(
partialChainControl *chainreg.PartialChainControl,
- walletConfig *btcwallet.Config) (*chainreg.ChainControl, func(), error) {
+ walletConfig *btcwallet.Config) (*ChainControlResult, error) {
walletController, err := btcwallet.New(
*walletConfig, partialChainControl.Cfg.BlockCache,
)
if err != nil {
err := fmt.Errorf("unable to create wallet controller: %w", err)
d.logger.Error(err)
- return nil, nil, err
+ return nil, err
}
keyRing := keychain.NewBtcWalletKeyRing(
@@ -833,10 +850,13 @@ func (d *DefaultWalletImpl) BuildChainControl(
if err != nil {
err := fmt.Errorf("unable to create chain control: %w", err)
d.logger.Error(err)
- return nil, nil, err
+ return nil, err
}
- return activeChainControl, cleanUp, nil
+ return &ChainControlResult{
+ ChainControl: activeChainControl,
+ CleanUp: cleanUp,
+ }, nil
}
// RPCSignerWalletImpl is a wallet implementation that uses a remote signer over
@@ -873,30 +893,54 @@ func NewRPCSignerWalletImpl(cfg *Config, logger btclog.Logger,
// NOTE: This is part of the ChainControlBuilder interface.
func (d *RPCSignerWalletImpl) BuildChainControl(
partialChainControl *chainreg.PartialChainControl,
- walletConfig *btcwallet.Config) (*chainreg.ChainControl, func(), error) {
+ walletConfig *btcwallet.Config) (*ChainControlResult, error) {
+
+ // Keeps track of both the remote signer and the chain control clean up
+ // functions.
+ var (
+ cleanUpTasks []func()
+ cleanUp = func() {
+ for i := len(cleanUpTasks) - 1; i >= 0; i-- {
+ cleanUpTasks[i]()
+ }
+ }
+ )
walletController, err := btcwallet.New(
*walletConfig, partialChainControl.Cfg.BlockCache,
)
if err != nil {
err := fmt.Errorf("unable to create wallet controller: %w", err)
d.logger.Error(err)
- return nil, nil, err
+ return &ChainControlResult{CleanUp: cleanUp}, err
}
+ // Create the remote signer connection instance.
+ remoteSignerConn, err := rpcwallet.BuildRemoteSignerConnection(
+ context.TODO(), d.DefaultWalletImpl.cfg.RemoteSigner,
+ )
+ if err != nil {
+ err := fmt.Errorf("unable to set up remote signer: %w", err)
+ d.logger.Error(err)
+ return &ChainControlResult{CleanUp: cleanUp}, err
+ }
+
+ cleanUpTasks = append(cleanUpTasks, remoteSignerConn.Stop)
+
baseKeyRing := keychain.NewBtcWalletKeyRing(
walletController.InternalWallet(), walletConfig.CoinType,
)
rpcKeyRing, err := rpcwallet.NewRPCKeyRing(
baseKeyRing, walletController,
- d.DefaultWalletImpl.cfg.RemoteSigner, walletConfig.NetParams,
+ remoteSignerConn, walletConfig.NetParams,
)
if err != nil {
err := fmt.Errorf("unable to create RPC remote signing wallet "+
"%v", err)
d.logger.Error(err)
- return nil, nil, err
+
+ return &ChainControlResult{CleanUp: cleanUp}, err
}
// Create, and start the lnwallet, which handles the core payment
@@ -915,16 +959,29 @@ func (d *RPCSignerWalletImpl) BuildChainControl(
// We've created the wallet configuration now, so we can finish
// initializing the main chain control.
- activeChainControl, cleanUp, err := chainreg.NewChainControl(
+ activeChainControl, ccCleanUp, err := chainreg.NewChainControl(
lnWalletConfig, rpcKeyRing, partialChainControl,
)
if err != nil {
err := fmt.Errorf("unable to create chain control: %w", err)
d.logger.Error(err)
- return nil, nil, err
+ return &ChainControlResult{CleanUp: cleanUp}, err
}
- return activeChainControl, cleanUp, nil
+ cleanUpTasks = append(cleanUpTasks, ccCleanUp)
+
+ // Note that if the remote signer connection does implement the
+ // InboundRemoteSignerConnection interface, the type assertion leaves
+ // inboundRemoteSignerConn nil, which is what want to set it to in that
+ // case.
+ inboundRemoteSignerConn, _ := remoteSignerConn.(rpcwallet.
+ InboundRemoteSignerConnection)
+
+ return &ChainControlResult{
+ ChainControl: activeChainControl,
+ CleanUp: cleanUp,
+ InboundRemoteSignerConn: inboundRemoteSignerConn,
+ }, nil
}
// DatabaseInstances is a struct that holds all instances to the actual
### config_test.go
@@ -6,7 +6,9 @@ import (
"github.com/lightningnetwork/lnd/chainreg"
"github.com/lightningnetwork/lnd/htlcswitch"
+ "github.com/lightningnetwork/lnd/lncfg"
"github.com/lightningnetwork/lnd/routing"
+ "github.com/lightningnetwork/lnd/tor"
"github.com/stretchr/testify/require"
)
@@ -117,6 +119,55 @@ func TestSupplyEnvValue(t *testing.T) {
}
}
+// TestNormalizeRemoteSignerListenAddrs makes sure lnd preserves explicitly
+// configured dedicated inbound remote signer listener ports and applies the
+// dedicated default port when none is specified. We keep the default-port case
+// as a unit test because an itest would need to bind the real default port
+// 10019, which becomes flaky under the parallel CI tranche runner where
+// multiple test processes can contend for the same host port. The remote
+// signer itests also covers the end-to-end dedicated listener path, but they
+// do so with explicit dynamically assigned ports instead of the fixed default.
+func TestNormalizeRemoteSignerListenAddrs(t *testing.T) {
+ tests := []struct {
+ name string
+ listener string
+ expected string
+ }{
+ {
+ name: "default port",
+ listener: "localhost",
+ expected: "127.0.0.1:10019",
+ },
+ {
+ name: "explicit port",
+ listener: "localhost:12019",
+ expected: "127.0.0.1:12019",
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ inboundCfg := lncfg.InboundWatchOnlyCfg{
+ ExperimentalRPCListeners: []string{
+ test.listener,
+ },
+ }
+
+ cfg := &Config{
+ RemoteSigner: &lncfg.RemoteSigner{
+ InboundWatchOnlyCfg: inboundCfg,
+ },
+ net: &tor.ClearNet{},
+ }
+
+ addrs, err := normalizeRemoteSignerListenAddrs(cfg)
+ require.NoError(t, err)
+ require.Len(t, addrs, 1)
+ require.Equal(t, test.expected, addrs[0].String())
+ })
+ }
+}
+
// TestValidateConfigTrickleDelay tests that the TrickleDelay configuration
// is properly validated and defaulted in ValidateConfig. This test directly
// verifies the validation logic without going through the full ValidateConfig
### docs/release-notes/release-notes-0.21.0.md
@@ -230,6 +230,11 @@
specify a list of inputs to use as transaction inputs via the new
`inputs` field in `EstimateFeeRequest`.
+* [SignCoordinatorStreams](https://github.com/lightningnetwork/lnd/pull/8754)
+ allows a remote signer to connect to the lnd node, if the
+ `remotesigner.experimentalallowinboundconnection` cfg value has been set to
+ `true`.
+
## lncli Additions
* The `estimatefee` command now supports the `--utxos` flag to specify explicit
@@ -252,10 +257,16 @@
This applies to both funders and fundees, with the ability to override the
value during channel opening or acceptance.
+
* Rename [experimental endorsement signal](https://github.com/lightning/blips/blob/a833e7b49f224e1240b5d669e78fa950160f5a06/blip-0004.md)
to [accountable](https://github.com/lightningnetwork/lnd/pull/10367) to match
the latest [proposal](https://github.com/lightning/blips/pull/67).
+* [Added](https://github.com/lightningnetwork/lnd/pull/8754) support for a new
+ remote signer type `outbound`, which makes an outbound connection to the
+ watch-only node, instead of requiring on an inbound connection from the
+ watch-only node.
+
## RPC Updates
* routerrpc HTLC event subscribers now receive specific failure details for
### docs/remote-signing.md
@@ -8,11 +8,11 @@ keys in its wallet. The second instance (in this document referred to as
the **private** keys.
The advantage of such a setup is that the `lnd` instance containing the private
-keys (the "signer") can be completely offline except for a single inbound gRPC
-connection.
+keys (the "signer") can be completely offline except for a single inbound or
+outbound gRPC connection.
The signer instance can run on a different machine with more tightly locked down
-network security, optimally only allowing the single gRPC connection from the
-outside.
+network security, optimally only allowing the single gRPC connection to or from
+the outside.
An example setup could look like:
@@ -39,12 +39,24 @@ xxx xx
```
-## Example setup
+When using a remote signer, the "signer" node can be configured to operate in
+one of two modes.
+It can either be configured as an "inbound" remote signer (the default setting)
+or as an "outbound" remote signer. As an "inbound" remote signer, the signer
+node permits a single inbound gRPC connection **from** the watch-only lnd node.
+Conversely, when configured as an "outbound" remote signer, it allows a single
+outbound gRPC connection **to** the watch-only lnd node.
-In this example we are going to set up two nodes, the "signer" that has the full
-seed and private keys and the "watch-only" node that only has public keys.
+## Example setups
-### The "signer" node
+In the examples below, we demonstrate how to configure the "signer" node and the
+"watch-only" node, either using an "inbound" or an "outbound" remote signer. The
+"signer" node possesses the full seed and private keys, while the "watch-only"
+node holds only the public keys.
+
+### Inbound remote signer example (default option)
+
+#### The inbound "signer" node
The node "signer" is the hardened node that contains the private key material
and is not connected to the internet or LN P2P network at all. Ideally only a
@@ -104,7 +116,7 @@ signer> $ lncli bakemacaroon --save_to signer.custom.macaroon \
Copy this file (`signer.custom.macaroon`) along with the `tls.cert` of the
signer node to the machine where the watch-only node will be running.
-### The "watch-only" node
+#### The "watch-only" node with an inbound remote signer
The node "watch-only" is the public, internet facing node that does not contain
any private keys in its wallet but delegates all signing operations to the node
@@ -118,6 +130,10 @@ remotesigner.enable=true
remotesigner.rpchost=zane.example.internal:10019
remotesigner.tlscertpath=/home/watch-only/example/signer.tls.cert
remotesigner.macaroonpath=/home/watch-only/example/signer.custom.macaroon
+# Optionally, specify that the watch-only doesn't allow any inbound connections
+# from the remote signer.
+# However, since this is the default behaviour, this isn't required.
+remotesigner.experimentalallowinboundconnection=false
```
After starting "watch-only", the wallet can be created in watch-only mode by
@@ -136,7 +152,188 @@ Input an optional address look-ahead used to scan for used keys (default 2500):
```
Alternatively a script can be used for initializing the watch-only wallet
-through the RPC interface as is described in the next section.
+through the RPC interface as is described in the
+[section below](#Example-initialization-script).
+
+### Outbound remote signer example
+
+The setup of an outbound remote signer, can be done in 3 steps:
+
+1. Start the signer node and export the `xpub`s of the wallet.
+2. Bake a custom macaroon for the watch-only node with a specified root key,
+which allows the signer node to establish an outbound connection to it.
+3. Start watch-only node and initialize its watch-only wallet using the same
+root key as in step 2.
+
+Note: These steps are only required during the initial setup of the signer
+wallet with a connected watch-only wallet. After this setup, the signer and
+watch-only node can be started as usual, provided the configuration from these
+steps remains in place.
+
+#### Step 1: export the `xpub`s of the outbound signer node's wallet
+
+When starting the signer node to export the `xpub`s of the wallet, these entries
+in `lnd.conf` are recommended:
+
+```text
+# We apply some basic "hardening" parameters to make sure no connections to the
+# internet are opened.
+
+[Application Options]
+# Don't listen on the p2p port.
+nolisten=true
+
+# Don't reach out to the bootstrap nodes, we don't need a synced graph.
+nobootstrap=true
+
+# The signer node will not look at the chain at all, it only needs to sign
+# things with the keys contained in its wallet. So we don't need to hook it up
+# to any chain backend.
+[bitcoin]
+# We still need to signal that we're using the Bitcoin chain.
+bitcoin.active=true
+
+# And we're making sure mainnet parameters are used.
+bitcoin.mainnet=true
+
+# But we aren't using a "real" chain backed but a mocked one.
+bitcoin.node=nochainbackend
+
+# Specify that signer will make an outbound connection to the watch-only node.
+watchonlynode.experimentalenable=true
+
+# The watch-only node's dedicated remote signer RPC host.
+watchonlynode.rpchost=zane.example.internal:10019
+
+# A macaroon and TLS certificate for the watch-only node.
+watchonlynode.macaroonpath=/home/signer/example/watch-only.custom.macaroon
+watchonlynode.tlscertpath=/home/signer/example/watch-only.tls.cert
+```
+
+**Note:** The watch-only node’s `rpchost`, `macaroonpath`, and `tlscertpath`
+specified in the configuration will not resolve successfully until steps 2 and 3
+are completed, as these files do not yet exist, and no node is currently running
+at the specified `rpchost`.
+The signer node will continuously attempt to establish a connection to the
+watch-only node using these values until the connection is successful.
+Consequently, the configuration values will resolve properly once steps 2 and 3
+have been executed.
+
+After successfully starting up the "signer", and either unlocking an existing or
+creating a new wallet, the following command can be run to export the `xpub`s of
+the wallet:
+
+```shell
+signer> $ lncli wallet accounts list > accounts-signer.json
+```
+
+That `accounts-signer.json` file has to be copied to the machine on which
+"watch-only" will be running. It contains the extended public keys for all of
+`lnd`'s accounts (see [required accounts](#required-accounts) ).
+
+#### Step 2: Bake the watch-only node's custom macaroon with a specified root key
+
+To bake the custom macaroon for the watch-only node before its wallet exists,
+first generate a root key, which will be used both to bake the macaroon and to
+create the watch-only node's wallet.
+
+Generation of a root key is exemplified below:
+
+```shell
+watch-only> $ ROOT_KEY=$(cat /dev/urandom | head -c32 | xxd -p -c32)
+```
+
+Once the root key is ready, bake the custom macaroon with:
+
+```shell
+watch-only> $ lncli bakemacaroon --root_key $ROOT_KEY \
+--save_to /home/signer/example/watch-only.custom.macaroon remotesigner:generate
+```
+
+**Note:** The `save_to` path should match the `watchonlynode.macaroonpath`
+specified in step 1. If the signer and watch-only nodes are on separate
+environments, move the macaroon to the `watchonlynode.macaroonpath` after baking
+it instead.
+
+Also note that the watch-only node does not need to be running to execute this
+command.
+
+
+#### Step 3: Start the Watch-Only Node and Initialize Its Watch-Only Wallet
+
+When starting the watch-only node, ensure the following entries are set in
+`lnd.conf`:
+
+```text
+# Enable the use of a remote signer.
+remotesigner.enable=true
+
+# Specify that the watch-only node will accept an incoming connection from the
+# remote signer.
+remotesigner.experimentalallowinboundconnection=true
+
+# Dedicated RPC listen address for the remote signer connection. This should
+# match the watchonlynode.rpchost configured on the signer node in
+# step 1.
+remotesigner.experimentalrpclisten=zane.example.internal:10019
+```
+
+It is also recommended to set the following parameter, which defines the
+interval at which the watch-only node will check if the signer node is still
+connected. If the signer node is disconnected during a check, the watch-only
+node will shut down:
+
+```text
+# Set the interval for how often the watch-only node will check that the signer
+# node is still connected.
+healthcheck.remotesigner.interval=5s
+```
+
+If the signer disconnects after startup, it may take some time to reconnect.
+During that window, the watch-only node may run a remote signer health check
+while the signer is still disconnected, which will cause the watch-only node to
+shut down.
+
+Increasing the health check interval reduces how often this check runs, which
+reduces the chance of a shutdown during reconnect backoff. If you want to
+minimize this risk, increase the `healthcheck.remotesigner.interval` value and
+also increase the remote signer health check timeout:
+
+```text
+healthcheck.remotesigner.timeout=5s
+```
+Set this timeout to the maximum reconnect delay you want to tolerate before the
+watch-only node shuts down. Note that other regular requests sent concurrently
+to the remote signer may still time out and return errors while waiting for the
+signer to reconnect. Therefore, setting this value too high is experimental and
+may have unexpected side effects.
+
+After starting the watch-only node, you can create a new watch-only wallet by
+following the example below:
+
+```shell
+watch-only> $ lncli createwatchonly --mac_root_key $ROOT_KEY \
+ accounts-signer.json
+
+Input wallet password:
+Confirm password:
+
+Input an optional wallet birthday unix timestamp of first block to start scanning from (default 0):
+
+
+Input an optional address look-ahead used to scan for used keys (default 2500):
+```
+
+**Note:** This command should be executed in an environment where the
+`$ROOT_KEY` environment variable, created in Step 2, is defined. When selecting
+a wallet birthday UNIX timestamp, choose one that is as close as possible to the
+wallet’s actual creation time to expedite the initial setup of the watch-only
+wallet.
+
+Finally, if the watch-only node and signer node are set up in different
+environments, you will also need to copy the watch-only node's TLS certificate
+and place it in the path specified for the `watchonlynode.tlscertpath`
+configuration field in Step 1.
## Migrating an existing setup to remote signing
@@ -146,9 +343,9 @@ a watch-only and a remote signer node).
To migrate an existing node, follow these steps:
1. Create a new "signer" node using the same seed as the existing node,
- following the steps [mentioned above](#the-signer-node).
+ following the steps the "signer" node examples above.
2. In the configuration of the existing node, add the configuration entries as
- [shown above](#the-watch-only-node). But instead of creating a new wallet
+ "watch-only" node examples above. But instead of creating a new wallet
(since one already exists), instruct `lnd` to migrate the existing wallet to
a watch-only one (by purging all private key material from it) by adding the
`remotesigner.migrate-wallet-to-watch-only=true` configuration entry.
### itest/list_on_test.go
@@ -551,6 +551,10 @@ var allTestCases = []*lntest.TestCase{
Name: "async payments benchmark",
TestFunc: testAsyncPayments,
},
+ {
+ Name: "outbound remote signer macaroon enforcement",
+ TestFunc: testOutboundRSMacaroonEnforcement,
+ },
{
Name: "taproot coop close",
TestFunc: testTaprootCoopClose,
### itest/lnd_remote_signer_test.go
@@ -3,16 +3,21 @@ package itest
import (
"fmt"
"testing"
+ "time"
"github.com/btcsuite/btcd/btcutil/v2"
"github.com/btcsuite/btcd/btcutil/v2/hdkeychain"
"github.com/btcsuite/btcwallet/waddrmgr"
"github.com/lightningnetwork/lnd/keychain"
+ "github.com/lightningnetwork/lnd/lncfg"
"github.com/lightningnetwork/lnd/lnrpc"
"github.com/lightningnetwork/lnd/lnrpc/signrpc"
"github.com/lightningnetwork/lnd/lnrpc/walletrpc"
"github.com/lightningnetwork/lnd/lntest"
"github.com/lightningnetwork/lnd/lntest/node"
+ "github.com/lightningnetwork/lnd/lntest/port"
+ "github.com/lightningnetwork/lnd/lntest/wait"
+ "github.com/lightningnetwork/lnd/lnwallet/rpcwallet"
"github.com/stretchr/testify/require"
)
@@ -21,60 +26,116 @@ import (
var remoteSignerTestCases = []*lntest.TestCase{
{
Name: "random seed",
- TestFunc: testRemoteSignerRadomSeed,
+ TestFunc: testRemoteSignerRandomSeed,
+ },
+ {
+ Name: "random seed outbound",
+ TestFunc: testRemoteSignerRandomSeedOutbound,
},
{
Name: "account import",
TestFunc: testRemoteSignerAccountImport,
},
+ {
+ Name: "account import outbound",
+ TestFunc: testRemoteSignerAccountImportOutbound,
+ },
{
Name: "tapscript import",
TestFunc: testRemoteSignerTapscriptImport,
},
+ {
+ Name: "tapscript import outbound",
+ TestFunc: testRemoteSignerTapscriptImportOutbound,
+ },
{
Name: "channel open",
TestFunc: testRemoteSignerChannelOpen,
},
+ {
+ Name: "channel open outbound",
+ TestFunc: testRemoteSignerChannelOpenOutbound,
+ },
{
Name: "funding input types",
TestFunc: testRemoteSignerChannelFundingInputTypes,
},
+ {
+ Name: "funding input types outbound",
+ TestFunc: testRemoteSignerChannelFundingInputTypesOutbound,
+ },
{
Name: "funding async payments",
TestFunc: testRemoteSignerAsyncPayments,
},
+ {
+ Name: "funding async payments outbound",
+ TestFunc: testRemoteSignerAsyncPaymentsOutbound,
+ },
{
Name: "funding async payments taproot",
TestFunc: testRemoteSignerAsyncPaymentsTaproot,
},
+ {
+ Name: "funding async payments taproot outbound",
+ TestFunc: testRemoteSignerAsyncPaymentsTaprootOutbound,
+ },
{
Name: "funding async payments taproot final",
TestFunc: testRemoteSignerAsyncPaymentsTaprootFinal,
},
+ {
+ Name: "funding async payments taproot final outbound",
+ TestFunc: testRemoteSignerAsyncPaymentsTaprootFinalOutbound,
+ },
{
Name: "shared key",
TestFunc: testRemoteSignerSharedKey,
},
+ {
+ Name: "shared key outbound",
+ TestFunc: testRemoteSignerSharedKeyOutbound,
+ },
{
Name: "bump fee",
TestFunc: testRemoteSignerBumpFee,
},
+ {
+ Name: "bump fee outbound",
+ TestFunc: testRemoteSignerBumpFeeOutbound,
+ },
{
Name: "psbt",
TestFunc: testRemoteSignerPSBT,
},
+ {
+ Name: "psbt outbound",
+ TestFunc: testRemoteSignerPSBTOutbound,
+ },
{
Name: "sign output raw",
TestFunc: testRemoteSignerSignOutputRaw,
},
+ {
+ Name: "sign output raw outbound",
+ TestFunc: testRemoteSignerSignOutputRawOutbound,
+ },
{
Name: "verify msg",
TestFunc: testRemoteSignerSignVerifyMsg,
},
+ {
+ Name: "verify msg outbound",
+ TestFunc: testRemoteSignerSignVerifyMsgOutbound,
+ },
{
Name: "taproot",
TestFunc: testRemoteSignerTaproot,
},
+ {
+ Name: "taproot outbound",
+ TestFunc: testRemoteSignerTaprootOutbound,
+ },
}
var (
@@ -116,17 +177,18 @@ var (
// remoteSignerTestCase defines a test case for the remote signer test suite.
type remoteSignerTestCase struct {
- name string
randomSeed bool
sendCoins bool
commitType lnrpc.CommitmentType
+ isOutbound bool
fn func(tt *lntest.HarnessTest, wo, carol *node.HarnessNode)
}
-// prepareRemoteSignerTest prepares a test case for the remote signer test
-// suite by creating three nodes.
-func prepareRemoteSignerTest(ht *lntest.HarnessTest, tc remoteSignerTestCase) (
- *node.HarnessNode, *node.HarnessNode, *node.HarnessNode) {
+// prepareInboundRemoteSignerTest prepares a test case using an inbound remote
+// signer for the test suite by creating three nodes.
+func prepareInboundRemoteSignerTest(ht *lntest.HarnessTest,
+ tc remoteSignerTestCase) (*node.HarnessNode, *node.HarnessNode,
+ *node.HarnessNode) {
// Signer is our signing node and has the wallet with the full master
// private key. We test that we can create the watch-only wallet from
@@ -168,7 +230,7 @@ func prepareRemoteSignerTest(ht *lntest.HarnessTest, tc remoteSignerTestCase) (
// WatchOnly is the node that has a watch-only wallet and uses the
// Signer node for any operation that requires access to private keys.
- watchOnly := ht.NewNodeRemoteSigner(
+ watchOnly := ht.NewNodeWatchOnly(
"WatchOnly", append([]string{
"--remotesigner.enable",
fmt.Sprintf(
@@ -208,40 +270,185 @@ func prepareRemoteSignerTest(ht *lntest.HarnessTest, tc remoteSignerTestCase) (
return signer, watchOnly, carol
}
-// testRemoteSignerRadomSeed tests that a watch-only wallet can use a remote
-// signing wallet to perform any signing or ECDH operations.
-func testRemoteSignerRadomSeed(ht *lntest.HarnessTest) {
- tc := remoteSignerTestCase{
- name: "random seed",
+// prepareInboundRemoteSignerTest prepares a test case using an outbound remote
+// signer for the test suite by creating three nodes.
+func prepareOutboundRemoteSignerTest(ht *lntest.HarnessTest,
+ tc remoteSignerTestCase) (*node.HarnessNode, *node.HarnessNode,
+ *node.HarnessNode) {
+
+ // Signer is our signing node and has the wallet with the full
+ // master private key. We test that we can create the watch-only
+ // wallet from the exported accounts but also from a static key
+ // to make sure the derivation of the account public keys is
+ // correct in both cases.
+ password := []byte("itestpassword")
+ var (
+ signerNodePubKey = nodePubKey
+ watchOnlyAccounts = deriveCustomScopeAccounts(ht.T)
+ signer *node.HarnessNode
+ err error
+ )
+
+ var commitArgs []string
+ if tc.commitType == lnrpc.CommitmentType_SIMPLE_TAPROOT ||
+ tc.commitType == lnrpc.CommitmentType_SIMPLE_TAPROOT_FINAL {
+
+ commitArgs = lntest.NodeArgsForCommitType(
+ tc.commitType,
+ )
+ }
+
+ // WatchOnly is the node that has a watch-only wallet and uses
+ // the Signer node for any operation that requires access to
+ // private keys. We use the outbound signer type here, meaning
+ // that the watch-only node expects the signer to make an
+ // outbound connection to it.
+ rsHost := fmt.Sprintf("localhost:%d", port.NextAvailablePort())
+ rsRpcListen := fmt.Sprintf(
+ "--remotesigner.experimentalrpclisten=%s", rsHost,
+ )
+
+ watchOnly := ht.CreateNewNode(
+ "WatchOnly", append([]string{
+ "--remotesigner.enable",
+ "--remotesigner.experimentalallowinboundconnection",
+ "--remotesigner.timeout=30s",
+ "--remotesigner.experimentalrequesttimeout=30s",
+ rsRpcListen,
+ }, commitArgs...),
+ password, true,
+ )
+
+ // As the signer node will make an outbound connection to the
+ // watch-only node, we must specify the watch-only node's RPC
+ // connection details in the signer's configuration.
+ signerArgs := []string{
+ "--watchonlynode.experimentalenable",
+ "--watchonlynode.timeout=30s",
+ "--watchonlynode.experimentalrequesttimeout=10s",
+ fmt.Sprintf(
+ "--watchonlynode.rpchost=%s",
+ rsHost,
+ ),
+ fmt.Sprintf(
+ "--watchonlynode.tlscertpath=%s",
+ watchOnly.Cfg.TLSCertPath,
+ ),
+ fmt.Sprintf(
+ "--watchonlynode.macaroonpath=%s",
+ watchOnly.Cfg.AdminMacPath,
+ ),
+ }
+
+ if !tc.randomSeed {
+ signer = ht.RestoreNodeWithSeed(
+ "Signer", signerArgs, password, nil, rootKey, 0,
+ nil,
+ )
+ } else {
+ signer = ht.NewNode("Signer", signerArgs)
+ signerNodePubKey = signer.PubKeyStr
+
+ rpcAccts := signer.RPC.ListAccounts(
+ &walletrpc.ListAccountsRequest{},
+ )
+
+ watchOnlyAccounts, err = walletrpc.AccountsToWatchOnly(
+ rpcAccts.Accounts,
+ )
+ require.NoError(ht, err)
+ }
+
+ // As the watch-only node will not fully start until the signer
+ // node connects to it, we need to start the watch-only node
+ // after having started the signer node.
+ ht.StartWatchOnly(watchOnly, "WatchOnly", password,
+ &lnrpc.WatchOnly{
+ MasterKeyBirthdayTimestamp: 0,
+ MasterKeyFingerprint: nil,
+ Accounts: watchOnlyAccounts,
+ },
+ )
+
+ resp := watchOnly.RPC.GetInfo()
+ require.Equal(ht, signerNodePubKey, resp.IdentityPubkey)
+
+ if tc.sendCoins {
+ ht.FundCoins(btcutil.SatoshiPerBitcoin, watchOnly)
+ ht.AssertWalletAccountBalance(
+ watchOnly, "default",
+ btcutil.SatoshiPerBitcoin, 0,
+ )
+ }
+
+ carol := ht.NewNode("carol", commitArgs)
+ ht.EnsureConnected(watchOnly, carol)
+
+ return signer, watchOnly, carol
+}
+
+func executeRemoteSignerTestCase(ht *lntest.HarnessTest,
+ tc remoteSignerTestCase) {
+
+ var watchOnly, carol *node.HarnessNode
+
+ if tc.isOutbound {
+ _, watchOnly, carol = prepareOutboundRemoteSignerTest(ht, tc)
+ } else {
+ _, watchOnly, carol = prepareInboundRemoteSignerTest(ht, tc)
+ }
+
+ tc.fn(ht, watchOnly, carol)
+}
+
+func randomSeedTestCase(isOutbound bool) remoteSignerTestCase {
+ return remoteSignerTestCase{
randomSeed: true,
+ isOutbound: isOutbound,
fn: func(tt *lntest.HarnessTest, wo, carol *node.HarnessNode) {
// Nothing more to test here.
},
}
+}
- _, watchOnly, carol := prepareRemoteSignerTest(ht, tc)
- tc.fn(ht, watchOnly, carol)
+// testRemoteSignerRandomSeed tests that a watch-only wallet can use a remote
+// signing wallet to perform any signing or ECDH operations.
+func testRemoteSignerRandomSeed(ht *lntest.HarnessTest) {
+ executeRemoteSignerTestCase(ht, randomSeedTestCase(false))
}
-func testRemoteSignerAccountImport(ht *lntest.HarnessTest) {
- tc := remoteSignerTestCase{
- name: "account import",
+// testRemoteSignerRandomSeed tests that a watch-only wallet can use an outbound
+// remote signing wallet to perform any signing or ECDH operations.
+func testRemoteSignerRandomSeedOutbound(ht *lntest.HarnessTest) {
+ executeRemoteSignerTestCase(ht, randomSeedTestCase(true))
+}
+
+func accountImportTestCase(isOutbound bool) remoteSignerTestCase {
+ return remoteSignerTestCase{
+ isOutbound: isOutbound,
fn: func(tt *lntest.HarnessTest, wo, carol *node.HarnessNode) {
runWalletImportAccountScenario(
tt, walletrpc.AddressType_WITNESS_PUBKEY_HASH,
carol, wo,
)
},
}
+}
- _, watchOnly, carol := prepareRemoteSignerTest(ht, tc)
- tc.fn(ht, watchOnly, carol)
+func testRemoteSignerAccountImport(ht *lntest.HarnessTest) {
+ executeRemoteSignerTestCase(ht, accountImportTestCase(false))
}
-func testRemoteSignerTapscriptImport(ht *lntest.HarnessTest) {
- tc := remoteSignerTestCase{
- name: "tapscript import",
- sendCoins: true,
+func testRemoteSignerAccountImportOutbound(ht *lntest.HarnessTest) {
+ executeRemoteSignerTestCase(ht, accountImportTestCase(true))
+}
+
+func tapscriptImportTestCase(ht *lntest.HarnessTest,
+ isOutbound bool) remoteSignerTestCase {
+
+ return remoteSignerTestCase{
+ sendCoins: true,
+ isOutbound: isOutbound,
fn: func(tt *lntest.HarnessTest, wo, carol *node.HarnessNode) {
testTaprootImportTapscriptFullTree(ht, wo)
testTaprootImportTapscriptPartialReveal(ht, wo)
@@ -251,54 +458,74 @@ func testRemoteSignerTapscriptImport(ht *lntest.HarnessTest) {
testTaprootImportTapscriptFullKeyFundPsbt(ht, wo)
},
}
+}
- _, watchOnly, carol := prepareRemoteSignerTest(ht, tc)
- tc.fn(ht, watchOnly, carol)
+func testRemoteSignerTapscriptImport(ht *lntest.HarnessTest) {
+ executeRemoteSignerTestCase(ht, tapscriptImportTestCase(ht, false))
}
-func testRemoteSignerChannelOpen(ht *lntest.HarnessTest) {
- tc := remoteSignerTestCase{
- name: "basic channel open close",
- sendCoins: true,
+func testRemoteSignerTapscriptImportOutbound(ht *lntest.HarnessTest) {
+ executeRemoteSignerTestCase(ht, tapscriptImportTestCase(ht, true))
+}
+
+func channelOpenTestCase(isOutbound bool) remoteSignerTestCase {
+ return remoteSignerTestCase{
+ sendCoins: true,
+ isOutbound: isOutbound,
fn: func(tt *lntest.HarnessTest, wo, carol *node.HarnessNode) {
runBasicChannelCreationAndUpdates(tt, wo, carol)
},
}
+}
- _, watchOnly, carol := prepareRemoteSignerTest(ht, tc)
- tc.fn(ht, watchOnly, carol)
+func testRemoteSignerChannelOpen(ht *lntest.HarnessTest) {
+ executeRemoteSignerTestCase(ht, channelOpenTestCase(false))
}
-func testRemoteSignerChannelFundingInputTypes(ht *lntest.HarnessTest) {
- tc := remoteSignerTestCase{
- name: "channel funding input types",
- sendCoins: false,
+func testRemoteSignerChannelOpenOutbound(ht *lntest.HarnessTest) {
+ executeRemoteSignerTestCase(ht, channelOpenTestCase(true))
+}
+
+func channelFundingInputTypesTestCase(isOutbound bool) remoteSignerTestCase {
+ return remoteSignerTestCase{
+ sendCoins: false,
+ isOutbound: isOutbound,
fn: func(tt *lntest.HarnessTest, wo, carol *node.HarnessNode) {
runChannelFundingInputTypes(tt, carol, wo)
},
}
+}
- _, watchOnly, carol := prepareRemoteSignerTest(ht, tc)
- tc.fn(ht, watchOnly, carol)
+func testRemoteSignerChannelFundingInputTypes(ht *lntest.HarnessTest) {
+ executeRemoteSignerTestCase(ht, channelFundingInputTypesTestCase(false))
}
-func testRemoteSignerAsyncPayments(ht *lntest.HarnessTest) {
- tc := remoteSignerTestCase{
- name: "async payments",
- sendCoins: true,
+func testRemoteSignerChannelFundingInputTypesOutbound(ht *lntest.HarnessTest) {
+ executeRemoteSignerTestCase(ht, channelFundingInputTypesTestCase(true))
+}
+
+func asyncPaymentsTestCase(isOutbound bool) remoteSignerTestCase {
+ return remoteSignerTestCase{
+ sendCoins: true,
+ isOutbound: isOutbound,
fn: func(tt *lntest.HarnessTest, wo, carol *node.HarnessNode) {
runAsyncPayments(tt, wo, carol, nil)
},
}
+}
- _, watchOnly, carol := prepareRemoteSignerTest(ht, tc)
- tc.fn(ht, watchOnly, carol)
+func testRemoteSignerAsyncPayments(ht *lntest.HarnessTest) {
+ executeRemoteSignerTestCase(ht, asyncPaymentsTestCase(false))
}
-func testRemoteSignerAsyncPaymentsTaproot(ht *lntest.HarnessTest) {
- tc := remoteSignerTestCase{
- name: "async payments taproot",
- sendCoins: true,
+func testRemoteSignerAsyncPaymentsOutbound(ht *lntest.HarnessTest) {
+ executeRemoteSignerTestCase(ht, asyncPaymentsTestCase(true))
+}
+
+func asyncPaymentsTaprootTestCase(isOutbound bool) remoteSignerTestCase {
+ return remoteSignerTestCase{
+ sendCoins: true,
+ isOutbound: isOutbound,
fn: func(tt *lntest.HarnessTest, wo, carol *node.HarnessNode) {
commitType := lnrpc.CommitmentType_SIMPLE_TAPROOT
@@ -308,15 +535,20 @@ func testRemoteSignerAsyncPaymentsTaproot(ht *lntest.HarnessTest) {
},
commitType: lnrpc.CommitmentType_SIMPLE_TAPROOT,
}
+}
- _, watchOnly, carol := prepareRemoteSignerTest(ht, tc)
- tc.fn(ht, watchOnly, carol)
+func testRemoteSignerAsyncPaymentsTaproot(ht *lntest.HarnessTest) {
+ executeRemoteSignerTestCase(ht, asyncPaymentsTaprootTestCase(false))
}
-func testRemoteSignerAsyncPaymentsTaprootFinal(ht *lntest.HarnessTest) {
- tc := remoteSignerTestCase{
- name: "async payments taproot final",
- sendCoins: true,
+func testRemoteSignerAsyncPaymentsTaprootOutbound(ht *lntest.HarnessTest) {
+ executeRemoteSignerTestCase(ht, asyncPaymentsTaprootTestCase(true))
+}
+
+func asyncPaymentsTaprootFinalTestCase(isOutbound bool) remoteSignerTestCase {
+ return remoteSignerTestCase{
+ sendCoins: true,
+ isOutbound: isOutbound,
fn: func(tt *lntest.HarnessTest, wo, carol *node.HarnessNode) {
commitType := lnrpc.CommitmentType_SIMPLE_TAPROOT_FINAL
@@ -326,40 +558,59 @@ func testRemoteSignerAsyncPaymentsTaprootFinal(ht *lntest.HarnessTest) {
},
commitType: lnrpc.CommitmentType_SIMPLE_TAPROOT_FINAL,
}
+}
- _, watchOnly, carol := prepareRemoteSignerTest(ht, tc)
- tc.fn(ht, watchOnly, carol)
+func testRemoteSignerAsyncPaymentsTaprootFinal(ht *lntest.HarnessTest) {
+ executeRemoteSignerTestCase(
+ ht, asyncPaymentsTaprootFinalTestCase(false),
+ )
}
-func testRemoteSignerSharedKey(ht *lntest.HarnessTest) {
- tc := remoteSignerTestCase{
- name: "shared key",
+func testRemoteSignerAsyncPaymentsTaprootFinalOutbound(ht *lntest.HarnessTest) {
+ executeRemoteSignerTestCase(ht, asyncPaymentsTaprootFinalTestCase(true))
+}
+
+func sharedKeyTestCase(isOutbound bool) remoteSignerTestCase {
+ return remoteSignerTestCase{
+ isOutbound: isOutbound,
fn: func(tt *lntest.HarnessTest, wo, carol *node.HarnessNode) {
runDeriveSharedKey(tt, wo)
},
}
+}
- _, watchOnly, carol := prepareRemoteSignerTest(ht, tc)
- tc.fn(ht, watchOnly, carol)
+func testRemoteSignerSharedKey(ht *lntest.HarnessTest) {
+ executeRemoteSignerTestCase(ht, sharedKeyTestCase(false))
}
-func testRemoteSignerBumpFee(ht *lntest.HarnessTest) {
- tc := remoteSignerTestCase{
- name: "bumpfee",
- sendCoins: true,
+func testRemoteSignerSharedKeyOutbound(ht *lntest.HarnessTest) {
+ executeRemoteSignerTestCase(ht, sharedKeyTestCase(true))
+}
+
+func bumpFeeTestCase(isOutbound bool) remoteSignerTestCase {
+ return remoteSignerTestCase{
+ sendCoins: true,
+ isOutbound: isOutbound,
fn: func(tt *lntest.HarnessTest, wo, carol *node.HarnessNode) {
runBumpFee(tt, wo)
},
}
+}
- _, watchOnly, carol := prepareRemoteSignerTest(ht, tc)
- tc.fn(ht, watchOnly, carol)
+func testRemoteSignerBumpFee(ht *lntest.HarnessTest) {
+ executeRemoteSignerTestCase(ht, bumpFeeTestCase(false))
}
-func testRemoteSignerPSBT(ht *lntest.HarnessTest) {
- tc := remoteSignerTestCase{
- name: "psbt",
+func testRemoteSignerBumpFeeOutbound(ht *lntest.HarnessTest) {
+ executeRemoteSignerTestCase(ht, bumpFeeTestCase(true))
+}
+
+func psbtTestCase(ht *lntest.HarnessTest,
+ isOutbound bool) remoteSignerTestCase {
+
+ return remoteSignerTestCase{
randomSeed: true,
+ isOutbound: isOutbound,
fn: func(tt *lntest.HarnessTest, wo, carol *node.HarnessNode) {
runPsbtChanFundingWithNodes(
tt, carol, wo, false,
@@ -381,42 +632,57 @@ func testRemoteSignerPSBT(ht *lntest.HarnessTest) {
runFundPsbt(ht, wo, carol)
},
}
+}
- _, watchOnly, carol := prepareRemoteSignerTest(ht, tc)
- tc.fn(ht, watchOnly, carol)
+func testRemoteSignerPSBT(ht *lntest.HarnessTest) {
+ executeRemoteSignerTestCase(ht, psbtTestCase(ht, false))
}
-func testRemoteSignerSignOutputRaw(ht *lntest.HarnessTest) {
- tc := remoteSignerTestCase{
- name: "sign output raw",
- sendCoins: true,
+func testRemoteSignerPSBTOutbound(ht *lntest.HarnessTest) {
+ executeRemoteSignerTestCase(ht, psbtTestCase(ht, true))
+}
+
+func signOutputRawTestCase(isOutbound bool) remoteSignerTestCase {
+ return remoteSignerTestCase{
+ sendCoins: true,
+ isOutbound: isOutbound,
fn: func(tt *lntest.HarnessTest, wo, carol *node.HarnessNode) {
runSignOutputRaw(tt, wo)
},
}
+}
- _, watchOnly, carol := prepareRemoteSignerTest(ht, tc)
- tc.fn(ht, watchOnly, carol)
+func testRemoteSignerSignOutputRaw(ht *lntest.HarnessTest) {
+ executeRemoteSignerTestCase(ht, signOutputRawTestCase(false))
}
-func testRemoteSignerSignVerifyMsg(ht *lntest.HarnessTest) {
- tc := remoteSignerTestCase{
- name: "sign verify msg",
- sendCoins: true,
+func testRemoteSignerSignOutputRawOutbound(ht *lntest.HarnessTest) {
+ executeRemoteSignerTestCase(ht, signOutputRawTestCase(true))
+}
+
+func signVerifyMsgTestCase(isOutbound bool) remoteSignerTestCase {
+ return remoteSignerTestCase{
+ sendCoins: true,
+ isOutbound: isOutbound,
fn: func(tt *lntest.HarnessTest, wo, carol *node.HarnessNode) {
runSignVerifyMessage(tt, wo)
},
}
+}
- _, watchOnly, carol := prepareRemoteSignerTest(ht, tc)
- tc.fn(ht, watchOnly, carol)
+func testRemoteSignerSignVerifyMsg(ht *lntest.HarnessTest) {
+ executeRemoteSignerTestCase(ht, signVerifyMsgTestCase(false))
}
-func testRemoteSignerTaproot(ht *lntest.HarnessTest) {
- tc := remoteSignerTestCase{
- name: "taproot",
+func testRemoteSignerSignVerifyMsgOutbound(ht *lntest.HarnessTest) {
+ executeRemoteSignerTestCase(ht, signVerifyMsgTestCase(true))
+}
+
+func taprootTestCase(isOutbound bool) remoteSignerTestCase {
+ return remoteSignerTestCase{
sendCoins: true,
randomSeed: true,
+ isOutbound: isOutbound,
fn: func(tt *lntest.HarnessTest, wo, carol *node.HarnessNode) {
testTaprootSendCoinsKeySpendBip86(tt, wo)
testTaprootComputeInputScriptKeySpendBip86(tt, wo)
@@ -440,13 +706,117 @@ func testRemoteSignerTaproot(ht *lntest.HarnessTest) {
}
},
}
+}
- _, watchOnly, carol := prepareRemoteSignerTest(ht, tc)
- tc.fn(ht, watchOnly, carol)
+func testRemoteSignerTaproot(ht *lntest.HarnessTest) {
+ executeRemoteSignerTestCase(ht, taprootTestCase(false))
+}
+
+func testRemoteSignerTaprootOutbound(ht *lntest.HarnessTest) {
+ executeRemoteSignerTestCase(ht, taprootTestCase(true))
+}
+
+// testOutboundRSMacaroonEnforcement tests that a valid macaroon including the
+// `remotesigner` entity is required to connect to the dedicated remote signer
+// RPC server of a watch-only node that expects an outbound remote signer.
+func testOutboundRSMacaroonEnforcement(ht *lntest.HarnessTest) {
+ // Ensure that the watch-only node uses a configuration that requires an
+ // outbound remote signer during startup.
+ remoteSignerHost := fmt.Sprintf(
+ "localhost:%d", port.NextAvailablePort(),
+ )
+ watchOnlyArgs := []string{
+ "--remotesigner.enable",
+ "--remotesigner.experimentalallowinboundconnection",
+ fmt.Sprintf("--remotesigner.experimentalrpclisten=%s",
+ remoteSignerHost),
+ "--remotesigner.timeout=15s",
+ "--remotesigner.experimentalrequesttimeout=15s",
+ }
+
+ // Create the watch-only node. Note that we require authentication for
+ // the watch-only node, as we want to test that the macaroon enforcement
+ // works as expected.
+ watchOnly := ht.CreateNewNode("WatchOnly", watchOnlyArgs, nil, false)
+
+ startChan := make(chan error)
+
+ // Start the watch-only node in a goroutine as it requires a remote
+ // signer to connect before it can fully start.
+ go func() {
+ startChan <- watchOnly.Start(ht.Context())
+ }()
+
+ // The watch-only node writes its non-admin macaroons during startup,
+ // before it fully transitions to the ready state. Wait for the invoice
+ // macaroon to exist before we try to use it below, otherwise this test
+ // can race that file creation on slower CI runners.
+ _, err := watchOnly.ReadMacaroon(
+ watchOnly.Cfg.InvoiceMacPath, 15*time.Second,
+ )
+ require.NoError(ht, err)
+
+ // Set up a connection to the watch-only node. However, instead of using
+ // the watch-only node's admin macaroon, we'll use the invoice macaroon.
+ // The connection should not be allowed using this macaroon because it
+ // lacks the `remotesigner` entity required when the signer node
+ // connects to the watch-only node.
+ connectionCfg := lncfg.ConnectionCfg{
+ RPCHost: remoteSignerHost,
+ MacaroonPath: watchOnly.Cfg.InvoiceMacPath,
+ TLSCertPath: watchOnly.Cfg.TLSCertPath,
+ Timeout: 10 * time.Second,
+ ExperimentalRequestTimeout: 10 * time.Second,
+ }
+
+ streamFeeder := rpcwallet.NewStreamFeeder(connectionCfg)
+ var stream *rpcwallet.Stream
+ err = wait.NoError(func() error {
+ var err error
+ stream, err = streamFeeder.GetStream(ht.Context())
+ return err
+ }, 15*time.Second)
+ require.NoError(ht, err)
+
+ defer func() {
+ require.NoError(ht, stream.Close())
+ }()
+
+ // Since we're using an unauthorized macaroon, we should expect to be
+ // denied access to the watch-only node.
+ _, err = stream.Recv()
+ require.ErrorContains(ht, err, "permission denied")
+
+ // Finally, connect a real signer to the watch-only node so that
+ // it can start up properly.
+ signerArgs := []string{
+ "--watchonlynode.experimentalenable",
+ "--watchonlynode.timeout=30s",
+ "--watchonlynode.experimentalrequesttimeout=10s",
+ fmt.Sprintf(
+ "--watchonlynode.rpchost=%s",
+ remoteSignerHost,
+ ),
+ fmt.Sprintf(
+ "--watchonlynode.tlscertpath=%s",
+ watchOnly.Cfg.TLSCertPath,
+ ),
+ fmt.Sprintf(
+ "--watchonlynode.macaroonpath=%s",
+ watchOnly.Cfg.AdminMacPath, // An authorized macaroon.
+ ),
+ }
+
+ _ = ht.NewNode("Signer", signerArgs)
+
+ // Finally, wait and ensure that the watch-only node is able to start
+ // up properly.
+ err = <-startChan
+ require.NoError(ht, err, "Shouldn't error on watch-only node startup")
}
-// deriveCustomScopeAccounts derives the first 255 default accounts of the custom lnd
-// internal key scope.
+// deriveCustomScopeAccounts derives the first 255 default accounts of the
+// custom lnd internal key scope.
func deriveCustomScopeAccounts(t *testing.T) []*lnrpc.WatchOnlyAccount {
allAccounts := make([]*lnrpc.WatchOnlyAccount, 0, 255+len(accounts))
allAccounts = append(allAccounts, accounts...)
### lncfg/remotesigner.go
@@ -6,39 +6,236 @@ import (
)
const (
- // DefaultRemoteSignerRPCTimeout is the default timeout that is used
- // when forwarding a request to the remote signer through RPC.
+ // DefaultRemoteSignerListenPort is the default port for the dedicated
+ // inbound remote signer RPC listeners when no port is specified.
+ DefaultRemoteSignerListenPort = 10019
+
+ // DefaultRemoteSignerRPCTimeout is the default connection timeout
+ // that is used when connecting to the remote signer or watch-only node
+ // through RPC.
DefaultRemoteSignerRPCTimeout = 5 * time.Second
+
+ // DefaultRemoteSignerRequestTimeout is the default timeout used for
+ // requests to and from the remote signer.
+ DefaultRemoteSignerRequestTimeout = 5 * time.Second
+
+ // DefaultStartupTimeout is the default startup timeout used when a
+ // watch-only node with
+ // 'remotesigner.experimentalallowinboundconnection' set to true waits
+ // for the remote signer to connect.
+ DefaultStartupTimeout = 5 * time.Minute
)
-// RemoteSigner holds the configuration options for a remote RPC signer.
+// RemoteSigner holds the configuration options for how to connect to a remote
+// signer. Only a watch-only node specifies this config.
//
//nolint:ll
type RemoteSigner struct {
- Enable bool `long:"enable" description:"Use a remote signer for signing any on-chain related transactions or messages. Only recommended if local wallet is initialized as watch-only. Remote signer must use the same seed/root key as the local watch-only wallet but must have private keys."`
- RPCHost string `long:"rpchost" description:"The remote signer's RPC host:port"`
- MacaroonPath string `long:"macaroonpath" description:"The macaroon to use for authenticating with the remote signer"`
- TLSCertPath string `long:"tlscertpath" description:"The TLS certificate to use for establishing the remote signer's identity"`
- Timeout time.Duration `long:"timeout" description:"The timeout for connecting to and signing requests with the remote signer. Valid time units are {s, m, h}."`
- MigrateWatchOnly bool `long:"migrate-wallet-to-watch-only" description:"If a wallet with private key material already exists, migrate it into a watch-only wallet on first startup. WARNING: This cannot be undone! Make sure you have backed up your seed before you use this flag! All private keys will be purged from the wallet after first unlock with this flag!"`
+ // Enable signals if this node is a watch-only node in a remote signer
+ // setup.
+ Enable bool `long:"enable" description:"Use a remote signer for signing any on-chain related transactions or messages. Only recommended if local wallet is initialized as watch-only. Remote signer must use the same seed/root key as the local watch-only wallet but must have private keys."`
+
+ // ExperimentalAllowInboundConnection is true if the remote signer node
+ // will connect to this node.
+ ExperimentalAllowInboundConnection bool `long:"experimentalallowinboundconnection" description:"EXPERIMENTAL: Signals that we allow an inbound connection from a remote signer to this node."`
+
+ // MigrateWatchOnly migrates the wallet to a watch-only wallet by
+ // purging all private keys from the wallet after first unlock with this
+ // flag.
+ MigrateWatchOnly bool `long:"migrate-wallet-to-watch-only" description:"If a wallet with private key material already exists, migrate it into a watch-only wallet on first startup. WARNING: This cannot be undone! Make sure you have backed up your seed before you use this flag! All private keys will be purged from the wallet after first unlock with this flag!"`
+
+ // ConnectionCfg holds the connection configuration options that the
+ // watch-only node will use when setting up the connection to the remote
+ // signer.
+ ConnectionCfg
+
+ // InboundWatchOnlyCfg holds the configuration options specifically
+ // used when the watch-only node expects an inbound connection from
+ // the remote signer.
+ InboundWatchOnlyCfg
+}
+
+// DefaultRemoteSignerCfg returns the default RemoteSigner config.
+func DefaultRemoteSignerCfg() *RemoteSigner {
+ return &RemoteSigner{
+ Enable: false,
+ ExperimentalAllowInboundConnection: false,
+ ConnectionCfg: defaultConnectionCfg(),
+ InboundWatchOnlyCfg: InboundWatchOnlyCfg{
+ ExperimentalStartupTimeout: DefaultStartupTimeout,
+ },
+ }
}
// Validate checks the values configured for our remote RPC signer.
func (r *RemoteSigner) Validate() error {
if !r.Enable {
+ if r.MigrateWatchOnly {
+ return fmt.Errorf("remote signer: cannot turn on " +
+ "wallet migration to watch-only if remote " +
+ "signing is not enabled")
+ }
+
+ if r.ExperimentalAllowInboundConnection {
+ return fmt.Errorf("remote signer: cannot enable " +
+ "'experimentalallowinboundconnection' if " +
+ "remote signing is not enabled")
+ }
+
+ return nil
+ }
+
+ if r.ExperimentalAllowInboundConnection {
+ if len(r.ExperimentalRPCListeners) == 0 {
+ //nolint:ll
+ return fmt.Errorf("remotesigner.experimentalrpclisten " +
+ "must be set when " +
+ "experimentalallowinboundconnection is enabled")
+ }
+
+ if r.ExperimentalStartupTimeout < 0 {
+ return fmt.Errorf("remotesigner."+
+ "experimentalstartuptimeout of %v is "+
+ "invalid, cannot be smaller than %v",
+ r.ExperimentalStartupTimeout, 0)
+ }
+ }
+
+ // Validate the shared timeout values in both inbound and outbound mode.
+ err := r.ConnectionCfg.validateTimeouts()
+ if err != nil {
+ return fmt.Errorf("remotesigner.%w", err)
+ }
+
+ // The host and credential settings are required only when the
+ // watch-only node initiates the outbound connection to the remote
+ // signer.
+ if r.ExperimentalAllowInboundConnection {
+ return nil
+ }
+
+ err = r.ConnectionCfg.validateRemoteHostCredentials()
+ if err != nil {
+ return fmt.Errorf("remotesigner.%w", err)
+ }
+
+ return nil
+}
+
+// InboundWatchOnlyCfg holds the configuration options specific for watch-only
+// nodes with the `experimentalallowinboundconnection` option set.
+//
+//nolint:ll
+type InboundWatchOnlyCfg struct {
+ ExperimentalStartupTimeout time.Duration `long:"experimentalstartuptimeout" description:"EXPERIMENTAL: The time the watch-only node will wait for the remote signer to connect during startup. If the timeout expires before the remote signer connects, the watch-only node will shut down. If set to 0, no timeout will not expire. Valid time units are {s, m, h}."`
+
+ // RPCListeners is the set of dedicated gRPC listener addresses that
+ // serve only the SignCoordinatorStreams RPC for inbound remote signer
+ // connections. This must be set when
+ // experimentalallowinboundconnection is enabled.
+ // If a listener omits a port, the default remote signer RPC port is
+ // used.
+ ExperimentalRPCListeners []string `long:"experimentalrpclisten" description:"EXPERIMENTAL: Dedicated RPC listen address(es) for inbound remote signer connections. When experimentalallowinboundconnection is enabled, lnd starts a separate gRPC server on these listeners that serves only the SignCoordinatorStreams RPC. If no port is specified, the default remote signer RPC port 10019 is used."`
+}
+
+// WatchOnlyNode holds the configuration options for how to connect to a watch
+// only node. Only a signer node specifies this config.
+//
+//nolint:ll
+type WatchOnlyNode struct {
+ // Enable signals if this node a signer node and is expected to connect
+ // to a watch-only node.
+ ExperimentalEnable bool `long:"experimentalenable" description:"EXPERIMENTAL: Signals that this node functions as a remote signer that will to connect with a watch-only node."`
+
+ // ConnectionCfg holds the connection configuration options that the
+ // remote signer node will use when setting up the connection to the
+ // watch-only node.
+ ConnectionCfg
+}
+
+// DefaultWatchOnlyNodeCfg returns the default WatchOnlyNode config.
+func DefaultWatchOnlyNodeCfg() *WatchOnlyNode {
+ return &WatchOnlyNode{
+ ExperimentalEnable: false,
+ ConnectionCfg: defaultConnectionCfg(),
+ }
+}
+
+// Validate checks the values set in the WatchOnlyNode config are valid.
+func (w *WatchOnlyNode) Validate() error {
+ if !w.ExperimentalEnable {
return nil
}
- if r.Timeout < time.Millisecond {
- return fmt.Errorf("remote signer: timeout of %v is invalid, "+
- "cannot be smaller than %v", r.Timeout,
- time.Millisecond)
+ err := w.ConnectionCfg.Validate()
+ if err != nil {
+ return fmt.Errorf("watchonlynode.%w", err)
+ }
+
+ return nil
+}
+
+// ConnectionCfg holds the configuration options required when setting up a
+// connection to either a remote signer or watch-only node, depending on which
+// side makes the outbound connection.
+//
+//nolint:ll
+type ConnectionCfg struct {
+ RPCHost string `long:"rpchost" description:"The RPC host:port of the remote signer or watch-only node. For watch-only nodes with 'remotesigner.experimentalallowinboundconnection' set to false (the default value if not specifically set), this should be set to the remote signer's RPC host:port. For remote signer nodes connecting to a watch-only node with 'remotesigner.experimentalallowinboundconnection' set to true, this should be set to the watch-only node's RPC host:port."`
+ MacaroonPath string `long:"macaroonpath" description:"The macaroon to use for authenticating with the remote signer or the watch-only node. For watch-only nodes with 'remotesigner.experimentalallowinboundconnection' set to false (the default value if not specifically set), this should be set to the remote signer's macaroon. For remote signer nodes connecting to a watch-only node with 'remotesigner.experimentalallowinboundconnection' set to true, this should be set to the watch-only node's macaroon."`
+ TLSCertPath string `long:"tlscertpath" description:"The TLS certificate to use for establishing the remote signer's or watch-only node's identity. For watch-only nodes with 'remotesigner.experimentalallowinboundconnection' set to false (the default value if not specifically set), this should be set to the remote signer's TLS certificate. For remote signer nodes connecting to a watch-only node with 'remotesigner.experimentalallowinboundconnection' set to true, this should be set to the watch-only node's TLS certificate."`
+ Timeout time.Duration `long:"timeout" description:"The timeout for making the connection to the remote signer or watch-only node, depending on whether the node acts as a watch-only node or a signer. For watch-only nodes with 'remotesigner.experimentalallowinboundconnection' set to true, this timeout value has no effect. Valid time units are {s, m, h}."`
+ ExperimentalRequestTimeout time.Duration `long:"experimentalrequesttimeout" description:"EXPERIMENTAL: The time we will wait when making requests to the remote signer or watch-only node, depending on whether the node acts as a watch-only node or a signer. Valid time units are {s, m, h}."`
+}
+
+// defaultConnectionCfg returns the default ConnectionCfg config.
+func defaultConnectionCfg() ConnectionCfg {
+ return ConnectionCfg{
+ Timeout: DefaultRemoteSignerRPCTimeout,
+ ExperimentalRequestTimeout: DefaultRemoteSignerRequestTimeout,
+ }
+}
+
+// Validate checks the values set in the ConnectionCfg config are valid.
+func (c *ConnectionCfg) Validate() error {
+ err := c.validateTimeouts()
+ if err != nil {
+ return err
+ }
+
+ return c.validateRemoteHostCredentials()
+}
+
+// validateTimeouts checks the timeout values that apply in both inbound and
+// outbound remote signer modes.
+func (c *ConnectionCfg) validateTimeouts() error {
+ if c.Timeout < time.Millisecond {
+ return fmt.Errorf("timeout of %v is invalid, cannot be "+
+ "smaller than %v", c.Timeout, time.Millisecond)
+ }
+
+ if c.ExperimentalRequestTimeout < time.Second {
+ return fmt.Errorf("experimentalrequesttimeout of %v is "+
+ "invalid, cannot be smaller than %v",
+ c.ExperimentalRequestTimeout, time.Second)
+ }
+
+ return nil
+}
+
+// validateRemoteHostCredentials checks the host and credential settings needed
+// when this node initiates the outbound RPC connection.
+func (c *ConnectionCfg) validateRemoteHostCredentials() error {
+ if c.RPCHost == "" {
+ return fmt.Errorf("rpchost must be set")
+ }
+
+ if c.MacaroonPath == "" {
+ return fmt.Errorf("macaroonpath must be set")
}
- if r.MigrateWatchOnly && !r.Enable {
- return fmt.Errorf("remote signer: cannot turn on wallet " +
- "migration to watch-only if remote signing is not " +
- "enabled")
+ if c.TLSCertPath == "" {
+ return fmt.Errorf("tlscertpath must be set")
}
return nil
### lncfg/remotesigner_test.go
@@ -0,0 +1,25 @@
+package lncfg_test
+
+import (
+ "testing"
+
+ "github.com/lightningnetwork/lnd/lncfg"
+ "github.com/stretchr/testify/require"
+)
+
+// TestRemoteSignerValidateInboundRequiresListeners makes sure inbound remote
+// signer mode still requires at least one dedicated listener address.
+func TestRemoteSignerValidateInboundRequiresListeners(t *testing.T) {
+ cfg := lncfg.DefaultRemoteSignerCfg()
+ cfg.Enable = true
+ cfg.ExperimentalAllowInboundConnection = true
+
+ err := cfg.Validate()
+ require.ErrorContains(
+ t, err, "remotesigner.experimentalrpclisten must be set",
+ )
+
+ cfg.ExperimentalRPCListeners = []string{"localhost"}
+
+ require.NoError(t, cfg.Validate())
+}
### lnd.go
@@ -15,6 +15,7 @@ import (
"os"
"runtime"
runtimePprof "runtime/pprof"
+ "strconv"
"strings"
"sync"
"time"
@@ -29,7 +30,9 @@ import (
"github.com/lightningnetwork/lnd/keychain"
"github.com/lightningnetwork/lnd/lncfg"
"github.com/lightningnetwork/lnd/lnrpc"
+ "github.com/lightningnetwork/lnd/lnrpc/watchonlyrpc"
"github.com/lightningnetwork/lnd/lnwallet"
+ "github.com/lightningnetwork/lnd/lnwallet/rpcwallet"
"github.com/lightningnetwork/lnd/macaroons"
"github.com/lightningnetwork/lnd/monitoring"
"github.com/lightningnetwork/lnd/rpcperms"
@@ -314,6 +317,8 @@ func Main(cfg *Config, lisCfg ListenerCfg, implCfg *ImplementationCfg,
defer cleanUp()
}
+ baseServerOpts := append([]grpc.ServerOption{}, serverOpts...)
+
// If we have chosen to start with a dedicated listener for the
// rpc server, we set it directly.
grpcListeners := append([]*ListenerWithSignal{}, lisCfg.RPCListeners...)
@@ -493,14 +498,75 @@ func Main(cfg *Config, lisCfg ListenerCfg, implCfg *ImplementationCfg,
defer cleanUp()
- activeChainControl, cleanUp, err := implCfg.BuildChainControl(
+ chainControlResult, err := implCfg.BuildChainControl(
partialChainControl, walletConfig,
)
if err != nil {
+ if chainControlResult != nil &&
+ chainControlResult.CleanUp != nil {
+
+ chainControlResult.CleanUp()
+ }
+
return mkErr("error loading chain control", err)
}
- defer cleanUp()
+ defer chainControlResult.CleanUp()
+
+ activeChainControl := chainControlResult.ChainControl
+
+ // If the chain control returned an inbound remote signer connection,
+ // start the dedicated RPC server for it. This RPC is not served by
+ // the main RPC server.
+ if chainControlResult.InboundRemoteSignerConn != nil {
+ rsGRPCServer, rsListeners,
+ rsInterceptor, err := startInboundWatchOnlyRPCServer(
+ cfg, baseServerOpts, serverKeepalive, clientKeepalive,
+ interceptorChain,
+ chainControlResult.InboundRemoteSignerConn,
+ )
+ if err != nil {
+ return mkErr("error starting inbound remote signer "+
+ "RPC server", err)
+ }
+ if rsGRPCServer != nil {
+ defer rsGRPCServer.Stop()
+ }
+ if rsInterceptor != nil {
+ defer func() {
+ if err := rsInterceptor.Stop(); err != nil {
+ ltndLog.Warnf("error stopping remote "+
+ "signer RPC interceptor "+
+ "chain: %v", err)
+ }
+ }()
+ }
+ for _, lis := range rsListeners {
+ defer lis.Close()
+ }
+ }
+
+ // We'll wait until the wallet is fully ready to be used before we
+ // proceed to derive keys from it.
+ select {
+ case err = <-activeChainControl.Wallet.WalletController.ReadySignal(
+ ctx,
+ ):
+ if err != nil {
+ return mkErr("error when waiting for wallet to be "+
+ "ready", err)
+ }
+
+ case <-interceptor.ShutdownChannel():
+ // If we receive a shutdown signal while waiting for the wallet
+ // to be ready, we must stop blocking so that all the deferred
+ // clean up functions can be executed. That will also shut down
+ // the wallet.
+ // We can't continue to execute the code below as we can't
+ // do any operations which requires private keys.
+ return mkErr("Shutting down", errors.New("shutdown signal "+
+ "received while waiting for wallet to be ready"))
+ }
// TODO(roasbeef): add rotation
idKeyDesc, err := activeChainControl.KeyRing.DeriveKey(
@@ -616,13 +682,24 @@ func Main(cfg *Config, lisCfg ListenerCfg, implCfg *ImplementationCfg,
multiAcceptor = chanacceptor.NewChainedAcceptor()
}
+ rscBuilder := rpcwallet.NewRemoteSignerClientBuilder(cfg.WatchOnlyNode)
+
+ // We pass a factory instead of a concrete RemoteSignerClient because
+ // the builder needs rpcServer.subServers (WalletKit/Signer), and those
+ // sub-servers are only instantiated in rpcServer.addDeps() later in
+ // startup. The server calls this factory in server.Start(), after that.
+ remoteSignerClientFactory :=
+ func() (rpcwallet.RemoteSignerClient, error) {
+ return rscBuilder.Build(rpcServer.subServers)
+ }
+
// Set up the core server which will listen for incoming peer
// connections.
server, err := newServer(
ctx, cfg, cfg.Listeners, dbs, activeChainControl, &idKeyDesc,
activeChainControl.Cfg.WalletUnlockParams.ChansToRestore,
multiAcceptor, torController, tlsManager, leaderElector,
- implCfg,
+ implCfg, remoteSignerClientFactory,
)
if err != nil {
return mkErr("unable to create server", err)
@@ -1055,3 +1132,182 @@ func startRestProxy(ctx context.Context, cfg *Config, rpcServer *rpcServer,
return shutdown, nil
}
+
+// makeRemoteSignerListeners normalizes and binds the listeners for the
+// dedicated remote signer RPC server. A nil or empty listener set indicates
+// the inbound watch-only signer endpoint is disabled.
+func makeRemoteSignerListeners(cfg *Config) ([]*ListenerWithSignal, error) {
+ addrs, err := normalizeRemoteSignerListenAddrs(cfg)
+ if err != nil {
+ return nil, err
+ }
+ if len(addrs) == 0 {
+ return nil, nil
+ }
+
+ listeners := make([]*ListenerWithSignal, 0, len(addrs))
+ for _, addr := range addrs {
+ lis, err := lncfg.ListenOnAddress(addr)
+ if err != nil {
+ for _, openLis := range listeners {
+ _ = openLis.Close()
+ }
+
+ return nil, fmt.Errorf("unable to listen on remote "+
+ "signer RPC endpoint %s: %w", addr, err)
+ }
+
+ listeners = append(listeners, &ListenerWithSignal{
+ Listener: lis,
+ Ready: make(chan struct{}),
+ })
+ }
+
+ return listeners, nil
+}
+
+// normalizeRemoteSignerListenAddrs normalizes the configured dedicated remote
+// signer listener addresses and applies the dedicated remote signer default
+// port when no port is specified.
+func normalizeRemoteSignerListenAddrs(cfg *Config) ([]net.Addr, error) {
+ if cfg.RemoteSigner == nil ||
+ len(cfg.RemoteSigner.ExperimentalRPCListeners) == 0 {
+
+ return nil, nil
+ }
+
+ addrs, err := lncfg.NormalizeAddresses(
+ cfg.RemoteSigner.ExperimentalRPCListeners,
+ strconv.Itoa(lncfg.DefaultRemoteSignerListenPort),
+ cfg.net.ResolveTCPAddr,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("error normalizing remote signer RPC "+
+ "listen addrs: %w", err)
+ }
+
+ return addrs, nil
+}
+
+// startInboundWatchOnlyRPCServer starts the dedicated gRPC server that serves
+// the inbound watch-only signer stream. The server uses its own interceptor
+// chain and listeners so remote signing can be exposed independently from the
+// main RPC server while still sharing the main macaroon service.
+func startInboundWatchOnlyRPCServer(cfg *Config,
+ baseServerOpts []grpc.ServerOption,
+ serverKeepalive keepalive.ServerParameters,
+ clientKeepalive keepalive.EnforcementPolicy,
+ mainInterceptor *rpcperms.InterceptorChain,
+ conn rpcwallet.InboundRemoteSignerConnection) (*grpc.Server,
+ []*ListenerWithSignal, *rpcperms.InterceptorChain, error) {
+
+ var (
+ cleanups []func()
+ success bool
+ )
+
+ cleanup := func() {
+ for i := len(cleanups) - 1; i >= 0; i-- {
+ cleanups[i]()
+ }
+ }
+ defer func() {
+ if !success {
+ cleanup()
+ }
+ }()
+
+ listeners, err := makeRemoteSignerListeners(cfg)
+ if err != nil {
+ return nil, nil, nil, err
+ }
+ if len(listeners) == 0 {
+ return nil, nil, nil, nil
+ }
+
+ cleanups = append(cleanups, func() {
+ for _, lis := range listeners {
+ _ = lis.Close()
+ }
+ })
+
+ // This dedicated server cannot host middleware registrations itself,
+ // so mandatory middleware enforcement must remain disabled here.
+ interceptor := rpcperms.NewInterceptorChain(
+ rpcsLog, cfg.NoMacaroons, nil,
+ )
+ if err := interceptor.Start(); err != nil {
+ return nil, nil, nil, fmt.Errorf("error starting remote "+
+ "signer interceptor chain: %w", err)
+ }
+
+ cleanups = append(cleanups, func() {
+ _ = interceptor.Stop()
+ })
+
+ interceptor.AddMacaroonService(mainInterceptor.MacaroonService())
+ if err := interceptor.AddPermission(
+ watchonlyrpc.FullMethodSignCoordinatorStreams,
+ watchonlyrpc.SignCoordinatorStreamsPermissions,
+ ); err != nil {
+ return nil, nil, nil, fmt.Errorf("error adding remote signer "+
+ "RPC permission: %w", err)
+ }
+
+ // This dedicated server is only intended to serve the inbound remote
+ // signer stream during startup and runtime, so it can be active
+ // immediately.
+ interceptor.SetRPCActive()
+
+ serverOpts := append([]grpc.ServerOption{}, baseServerOpts...)
+ serverOpts = append(serverOpts, interceptor.CreateServerOpts()...)
+ serverOpts = append(
+ serverOpts,
+ grpc.KeepaliveParams(serverKeepalive),
+ grpc.KeepaliveEnforcementPolicy(clientKeepalive),
+ grpc.MaxRecvMsgSize(lnrpc.MaxGrpcMsgSize),
+ )
+
+ grpcServer := grpc.NewServer(serverOpts...)
+ cleanups = append(cleanups, grpcServer.Stop)
+ watchonlyrpc.RegisterWatchOnlyServer(grpcServer,
+ &watchonlyrpc.InboundServer{Conn: conn})
+
+ err = startGrpcListenNoPrometheus(grpcServer, listeners)
+ if err != nil {
+ return nil, nil, nil, err
+ }
+
+ success = true
+
+ return grpcServer, listeners, interceptor, nil
+}
+
+// startGrpcListenNoPrometheus starts a gRPC server on the passed listeners
+// without exporting Prometheus metrics. This is used by the dedicated remote
+// signer RPC server because Prometheus registration must remain a single-shot
+// operation on the main RPC server.
+func startGrpcListenNoPrometheus(grpcServer *grpc.Server,
+ listeners []*ListenerWithSignal) error {
+
+ // We only use the wait group to wait until each listener has signaled
+ // readiness, not until the serving goroutines exit.
+ var wg sync.WaitGroup
+
+ for _, lis := range listeners {
+ wg.Add(1)
+ go func() {
+ rpcsLog.Infof("Remote signer RPC server listening "+
+ "on %s", lis.Addr())
+
+ close(lis.Ready)
+ wg.Done()
+
+ _ = grpcServer.Serve(lis)
+ }()
+ }
+
+ wg.Wait()
+
+ return nil
+}
### lnrpc/watchonlyrpc/server.go
@@ -0,0 +1,48 @@
+package watchonlyrpc
+
+import (
+ "fmt"
+
+ "gopkg.in/macaroon-bakery.v2/bakery"
+)
+
+const (
+ // FullMethodSignCoordinatorStreams is the full gRPC method path for the
+ // remote signer stream RPC.
+ FullMethodSignCoordinatorStreams = "/watchonlyrpc.WatchOnly/" +
+ "SignCoordinatorStreams"
+)
+
+// SignCoordinatorStreamsPermissions are the macaroon permissions required to
+// access the remote signer coordination stream.
+var SignCoordinatorStreamsPermissions = []bakery.Op{{
+ Entity: "remotesigner",
+ Action: "generate",
+}}
+
+// InboundConnection is the minimal interface the dedicated remote signer RPC
+// server needs to accept and manage an inbound sign coordinator stream.
+type InboundConnection interface {
+ AddConnection(stream WatchOnly_SignCoordinatorStreamsServer) error
+}
+
+// InboundServer is a minimal gRPC server implementation that exposes only the
+// SignCoordinatorStreams RPC.
+type InboundServer struct {
+ UnimplementedWatchOnlyServer
+
+ Conn InboundConnection
+}
+
+// SignCoordinatorStreams accepts an inbound remote signer stream and hands it
+// over to the configured coordinator/connection.
+func (s *InboundServer) SignCoordinatorStreams(
+ stream WatchOnly_SignCoordinatorStreamsServer) error {
+
+ if s.Conn == nil {
+ return fmt.Errorf("inbound connections from remote signers " +
+ "not enabled")
+ }
+
+ return s.Conn.AddConnection(stream)
+}
### lnrpc/watchonlyrpc/watchonly.pb.go
@@ -0,0 +1,1034 @@
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// protoc-gen-go v1.36.11
+// protoc v3.21.12
+// source: watchonlyrpc/watchonly.proto
+
+package watchonlyrpc
+
+import (
+ signrpc "github.com/lightningnetwork/lnd/lnrpc/signrpc"
+ walletrpc "github.com/lightningnetwork/lnd/lnrpc/walletrpc"
+ protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+ protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+ reflect "reflect"
+ sync "sync"
+ unsafe "unsafe"
+)
+
+const (
+ // Verify that this generated code is sufficiently up-to-date.
+ _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+ // Verify that runtime/protoimpl is sufficiently up-to-date.
+ _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+type SignCoordinatorRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // A unique request ID of a SignCoordinator gRPC request. Useful for mapping
+ // requests to responses.
+ // Note that request_id 1 is reserved for the handshake with between
+ // watch-only node and the remote signer.
+ RequestId uint64 `protobuf:"varint,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"`
+ // Messages between the watch-only node and the remote signer can only be of
+ // certain types.
+ //
+ // Types that are valid to be assigned to SignRequestType:
+ //
+ // *SignCoordinatorRequest_RegistrationResponse
+ // *SignCoordinatorRequest_Ping
+ // *SignCoordinatorRequest_SharedKeyRequest
+ // *SignCoordinatorRequest_SignMessageReq
+ // *SignCoordinatorRequest_MuSig2SessionRequest
+ // *SignCoordinatorRequest_MuSig2RegisterNoncesRequest
+ // *SignCoordinatorRequest_MuSig2CombinedNoncesReq
+ // *SignCoordinatorRequest_MuSig2GetCombinedNoncesReq
+ // *SignCoordinatorRequest_MuSig2SignRequest
+ // *SignCoordinatorRequest_MuSig2CombineSigRequest
+ // *SignCoordinatorRequest_MuSig2CleanupRequest
+ // *SignCoordinatorRequest_SignPsbtRequest
+ SignRequestType isSignCoordinatorRequest_SignRequestType `protobuf_oneof:"sign_request_type"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *SignCoordinatorRequest) Reset() {
+ *x = SignCoordinatorRequest{}
+ mi := &file_watchonlyrpc_watchonly_proto_msgTypes[0]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *SignCoordinatorRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*SignCoordinatorRequest) ProtoMessage() {}
+
+func (x *SignCoordinatorRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_watchonlyrpc_watchonly_proto_msgTypes[0]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use SignCoordinatorRequest.ProtoReflect.Descriptor instead.
+func (*SignCoordinatorRequest) Descriptor() ([]byte, []int) {
+ return file_watchonlyrpc_watchonly_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *SignCoordinatorRequest) GetRequestId() uint64 {
+ if x != nil {
+ return x.RequestId
+ }
+ return 0
+}
+
+func (x *SignCoordinatorRequest) GetSignRequestType() isSignCoordinatorRequest_SignRequestType {
+ if x != nil {
+ return x.SignRequestType
+ }
+ return nil
+}
+
+func (x *SignCoordinatorRequest) GetRegistrationResponse() *RegistrationResponse {
+ if x != nil {
+ if x, ok := x.SignRequestType.(*SignCoordinatorRequest_RegistrationResponse); ok {
+ return x.RegistrationResponse
+ }
+ }
+ return nil
+}
+
+func (x *SignCoordinatorRequest) GetPing() bool {
+ if x != nil {
+ if x, ok := x.SignRequestType.(*SignCoordinatorRequest_Ping); ok {
+ return x.Ping
+ }
+ }
+ return false
+}
+
+func (x *SignCoordinatorRequest) GetSharedKeyRequest() *signrpc.SharedKeyRequest {
+ if x != nil {
+ if x, ok := x.SignRequestType.(*SignCoordinatorRequest_SharedKeyRequest); ok {
+ return x.SharedKeyRequest
+ }
+ }
+ return nil
+}
+
+func (x *SignCoordinatorRequest) GetSignMessageReq() *signrpc.SignMessageReq {
+ if x != nil {
+ if x, ok := x.SignRequestType.(*SignCoordinatorRequest_SignMessageReq); ok {
+ return x.SignMessageReq
+ }
+ }
+ return nil
+}
+
+func (x *SignCoordinatorRequest) GetMuSig2SessionRequest() *signrpc.MuSig2SessionRequest {
+ if x != nil {
+ if x, ok := x.SignRequestType.(*SignCoordinatorRequest_MuSig2SessionRequest); ok {
+ return x.MuSig2SessionRequest
+ }
+ }
+ return nil
+}
+
+func (x *SignCoordinatorRequest) GetMuSig2RegisterNoncesRequest() *signrpc.MuSig2RegisterNoncesRequest {
+ if x != nil {
+ if x, ok := x.SignRequestType.(*SignCoordinatorRequest_MuSig2RegisterNoncesRequest); ok {
+ return x.MuSig2RegisterNoncesRequest
+ }
+ }
+ return nil
+}
+
+func (x *SignCoordinatorRequest) GetMuSig2CombinedNoncesReq() *signrpc.MuSig2RegisterCombinedNonceRequest {
+ if x != nil {
+ if x, ok := x.SignRequestType.(*SignCoordinatorRequest_MuSig2CombinedNoncesReq); ok {
+ return x.MuSig2CombinedNoncesReq
+ }
+ }
+ return nil
+}
+
+func (x *SignCoordinatorRequest) GetMuSig2GetCombinedNoncesReq() *signrpc.MuSig2GetCombinedNonceRequest {
+ if x != nil {
+ if x, ok := x.SignRequestType.(*SignCoordinatorRequest_MuSig2GetCombinedNoncesReq); ok {
+ return x.MuSig2GetCombinedNoncesReq
+ }
+ }
+ return nil
+}
+
+func (x *SignCoordinatorRequest) GetMuSig2SignRequest() *signrpc.MuSig2SignRequest {
+ if x != nil {
+ if x, ok := x.SignRequestType.(*SignCoordinatorRequest_MuSig2SignRequest); ok {
+ return x.MuSig2SignRequest
+ }
+ }
+ return nil
+}
+
+func (x *SignCoordinatorRequest) GetMuSig2CombineSigRequest() *signrpc.MuSig2CombineSigRequest {
+ if x != nil {
+ if x, ok := x.SignRequestType.(*SignCoordinatorRequest_MuSig2CombineSigRequest); ok {
+ return x.MuSig2CombineSigRequest
+ }
+ }
+ return nil
+}
+
+func (x *SignCoordinatorRequest) GetMuSig2CleanupRequest() *signrpc.MuSig2CleanupRequest {
+ if x != nil {
+ if x, ok := x.SignRequestType.(*SignCoordinatorRequest_MuSig2CleanupRequest); ok {
+ return x.MuSig2CleanupRequest
+ }
+ }
+ return nil
+}
+
+func (x *SignCoordinatorRequest) GetSignPsbtRequest() *walletrpc.SignPsbtRequest {
+ if x != nil {
+ if x, ok := x.SignRequestType.(*SignCoordinatorRequest_SignPsbtRequest); ok {
+ return x.SignPsbtRequest
+ }
+ }
+ return nil
+}
+
+type isSignCoordinatorRequest_SignRequestType interface {
+ isSignCoordinatorRequest_SignRequestType()
+}
+
+type SignCoordinatorRequest_RegistrationResponse struct {
+ // The Registration Response message is returned by the watch-only lnd as
+ // a response to SignerRegistration message.
+ RegistrationResponse *RegistrationResponse `protobuf:"bytes,2,opt,name=registration_response,json=registrationResponse,proto3,oneof"`
+}
+
+type SignCoordinatorRequest_Ping struct {
+ // To ensure that the remote signer is still active and alive, the
+ // watch-only lnd can send a Ping message to the remote signer, which
+ // should then respond with the respective Pong message.
+ Ping bool `protobuf:"varint,3,opt,name=ping,proto3,oneof"`
+}
+
+type SignCoordinatorRequest_SharedKeyRequest struct {
+ // Requests a shared public key from the remote signer.
+ SharedKeyRequest *signrpc.SharedKeyRequest `protobuf:"bytes,4,opt,name=shared_key_request,json=sharedKeyRequest,proto3,oneof"`
+}
+
+type SignCoordinatorRequest_SignMessageReq struct {
+ // Requests that the remote signer signs the passed message.
+ SignMessageReq *signrpc.SignMessageReq `protobuf:"bytes,5,opt,name=sign_message_req,json=signMessageReq,proto3,oneof"`
+}
+
+type SignCoordinatorRequest_MuSig2SessionRequest struct {
+ // Requests a MuSig2 Session of the remote signer.
+ MuSig2SessionRequest *signrpc.MuSig2SessionRequest `protobuf:"bytes,6,opt,name=mu_sig2_session_request,json=muSig2SessionRequest,proto3,oneof"`
+}
+
+type SignCoordinatorRequest_MuSig2RegisterNoncesRequest struct {
+ // Requests that the remote signer registers a nonce with the referenced
+ // MuSig2 Session.
+ MuSig2RegisterNoncesRequest *signrpc.MuSig2RegisterNoncesRequest `protobuf:"bytes,7,opt,name=mu_sig2_register_nonces_request,json=muSig2RegisterNoncesRequest,proto3,oneof"`
+}
+
+type SignCoordinatorRequest_MuSig2CombinedNoncesReq struct {
+ // Requests that the remote signer registers a combined nonce with the
+ // referenced MuSig2 Session.
+ MuSig2CombinedNoncesReq *signrpc.MuSig2RegisterCombinedNonceRequest `protobuf:"bytes,8,opt,name=mu_sig2_combined_nonces_req,json=muSig2CombinedNoncesReq,proto3,oneof"`
+}
+
+type SignCoordinatorRequest_MuSig2GetCombinedNoncesReq struct {
+ // Requests that the remote signer gets a combined nonce with the
+ // referenced MuSig2 Session.
+ MuSig2GetCombinedNoncesReq *signrpc.MuSig2GetCombinedNonceRequest `protobuf:"bytes,9,opt,name=mu_sig2_get_combined_nonces_req,json=muSig2GetCombinedNoncesReq,proto3,oneof"`
+}
+
+type SignCoordinatorRequest_MuSig2SignRequest struct {
+ // Requests that the remote signer signs the passed message digest with
+ // the referenced MuSig2 Session.
+ MuSig2SignRequest *signrpc.MuSig2SignRequest `protobuf:"bytes,10,opt,name=mu_sig2_sign_request,json=muSig2SignRequest,proto3,oneof"`
+}
+
+type SignCoordinatorRequest_MuSig2CombineSigRequest struct {
+ // Requests that the remote signer combines and adds the passed partial
+ // signatures for the referenced MuSig2 Session.
+ MuSig2CombineSigRequest *signrpc.MuSig2CombineSigRequest `protobuf:"bytes,11,opt,name=mu_sig2_combine_sig_request,json=muSig2CombineSigRequest,proto3,oneof"`
+}
+
+type SignCoordinatorRequest_MuSig2CleanupRequest struct {
+ // Requests that the remote signer removes/cleans up the referenced
+ // MuSig2 session.
+ MuSig2CleanupRequest *signrpc.MuSig2CleanupRequest `protobuf:"bytes,12,opt,name=mu_sig2_cleanup_request,json=muSig2CleanupRequest,proto3,oneof"`
+}
+
+type SignCoordinatorRequest_SignPsbtRequest struct {
+ // Requests that the remote signer signs the passed PSBT.
+ SignPsbtRequest *walletrpc.SignPsbtRequest `protobuf:"bytes,13,opt,name=sign_psbt_request,json=signPsbtRequest,proto3,oneof"`
+}
+
+func (*SignCoordinatorRequest_RegistrationResponse) isSignCoordinatorRequest_SignRequestType() {}
+
+func (*SignCoordinatorRequest_Ping) isSignCoordinatorRequest_SignRequestType() {}
+
+func (*SignCoordinatorRequest_SharedKeyRequest) isSignCoordinatorRequest_SignRequestType() {}
+
+func (*SignCoordinatorRequest_SignMessageReq) isSignCoordinatorRequest_SignRequestType() {}
+
+func (*SignCoordinatorRequest_MuSig2SessionRequest) isSignCoordinatorRequest_SignRequestType() {}
+
+func (*SignCoordinatorRequest_MuSig2RegisterNoncesRequest) isSignCoordinatorRequest_SignRequestType() {
+}
+
+func (*SignCoordinatorRequest_MuSig2CombinedNoncesReq) isSignCoordinatorRequest_SignRequestType() {}
+
+func (*SignCoordinatorRequest_MuSig2GetCombinedNoncesReq) isSignCoordinatorRequest_SignRequestType() {
+}
+
+func (*SignCoordinatorRequest_MuSig2SignRequest) isSignCoordinatorRequest_SignRequestType() {}
+
+func (*SignCoordinatorRequest_MuSig2CombineSigRequest) isSignCoordinatorRequest_SignRequestType() {}
+
+func (*SignCoordinatorRequest_MuSig2CleanupRequest) isSignCoordinatorRequest_SignRequestType() {}
+
+func (*SignCoordinatorRequest_SignPsbtRequest) isSignCoordinatorRequest_SignRequestType() {}
+
+type SignCoordinatorResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // The request ID this response refers to.
+ RefRequestId uint64 `protobuf:"varint,1,opt,name=ref_request_id,json=refRequestId,proto3" json:"ref_request_id,omitempty"`
+ // The remote signer responses can only be of certain types.
+ //
+ // Types that are valid to be assigned to SignResponseType:
+ //
+ // *SignCoordinatorResponse_SignerRegistration
+ // *SignCoordinatorResponse_Pong
+ // *SignCoordinatorResponse_SharedKeyResponse
+ // *SignCoordinatorResponse_SignMessageResp
+ // *SignCoordinatorResponse_MuSig2SessionResponse
+ // *SignCoordinatorResponse_MuSig2RegisterNoncesResponse
+ // *SignCoordinatorResponse_MuSig2CombNoncesResp
+ // *SignCoordinatorResponse_MuSig2GetCombNoncesResp
+ // *SignCoordinatorResponse_MuSig2SignResponse
+ // *SignCoordinatorResponse_MuSig2CombineSigResponse
+ // *SignCoordinatorResponse_MuSig2CleanupResponse
+ // *SignCoordinatorResponse_SignPsbtResponse
+ // *SignCoordinatorResponse_SignerError
+ SignResponseType isSignCoordinatorResponse_SignResponseType `protobuf_oneof:"sign_response_type"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *SignCoordinatorResponse) Reset() {
+ *x = SignCoordinatorResponse{}
+ mi := &file_watchonlyrpc_watchonly_proto_msgTypes[1]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *SignCoordinatorResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*SignCoordinatorResponse) ProtoMessage() {}
+
+func (x *SignCoordinatorResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_watchonlyrpc_watchonly_proto_msgTypes[1]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use SignCoordinatorResponse.ProtoReflect.Descriptor instead.
+func (*SignCoordinatorResponse) Descriptor() ([]byte, []int) {
+ return file_watchonlyrpc_watchonly_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *SignCoordinatorResponse) GetRefRequestId() uint64 {
+ if x != nil {
+ return x.RefRequestId
+ }
+ return 0
+}
+
+func (x *SignCoordinatorResponse) GetSignResponseType() isSignCoordinatorResponse_SignResponseType {
+ if x != nil {
+ return x.SignResponseType
+ }
+ return nil
+}
+
+func (x *SignCoordinatorResponse) GetSignerRegistration() *SignerRegistration {
+ if x != nil {
+ if x, ok := x.SignResponseType.(*SignCoordinatorResponse_SignerRegistration); ok {
+ return x.SignerRegistration
+ }
+ }
+ return nil
+}
+
+func (x *SignCoordinatorResponse) GetPong() bool {
+ if x != nil {
+ if x, ok := x.SignResponseType.(*SignCoordinatorResponse_Pong); ok {
+ return x.Pong
+ }
+ }
+ return false
+}
+
+func (x *SignCoordinatorResponse) GetSharedKeyResponse() *signrpc.SharedKeyResponse {
+ if x != nil {
+ if x, ok := x.SignResponseType.(*SignCoordinatorResponse_SharedKeyResponse); ok {
+ return x.SharedKeyResponse
+ }
+ }
+ return nil
+}
+
+func (x *SignCoordinatorResponse) GetSignMessageResp() *signrpc.SignMessageResp {
+ if x != nil {
+ if x, ok := x.SignResponseType.(*SignCoordinatorResponse_SignMessageResp); ok {
+ return x.SignMessageResp
+ }
+ }
+ return nil
+}
+
+func (x *SignCoordinatorResponse) GetMuSig2SessionResponse() *signrpc.MuSig2SessionResponse {
+ if x != nil {
+ if x, ok := x.SignResponseType.(*SignCoordinatorResponse_MuSig2SessionResponse); ok {
+ return x.MuSig2SessionResponse
+ }
+ }
+ return nil
+}
+
+func (x *SignCoordinatorResponse) GetMuSig2RegisterNoncesResponse() *signrpc.MuSig2RegisterNoncesResponse {
+ if x != nil {
+ if x, ok := x.SignResponseType.(*SignCoordinatorResponse_MuSig2RegisterNoncesResponse); ok {
+ return x.MuSig2RegisterNoncesResponse
+ }
+ }
+ return nil
+}
+
+func (x *SignCoordinatorResponse) GetMuSig2CombNoncesResp() *signrpc.MuSig2RegisterCombinedNonceResponse {
+ if x != nil {
+ if x, ok := x.SignResponseType.(*SignCoordinatorResponse_MuSig2CombNoncesResp); ok {
+ return x.MuSig2CombNoncesResp
+ }
+ }
+ return nil
+}
+
+func (x *SignCoordinatorResponse) GetMuSig2GetCombNoncesResp() *signrpc.MuSig2GetCombinedNonceResponse {
+ if x != nil {
+ if x, ok := x.SignResponseType.(*SignCoordinatorResponse_MuSig2GetCombNoncesResp); ok {
+ return x.MuSig2GetCombNoncesResp
+ }
+ }
+ return nil
+}
+
+func (x *SignCoordinatorResponse) GetMuSig2SignResponse() *signrpc.MuSig2SignResponse {
+ if x != nil {
+ if x, ok := x.SignResponseType.(*SignCoordinatorResponse_MuSig2SignResponse); ok {
+ return x.MuSig2SignResponse
+ }
+ }
+ return nil
+}
+
+func (x *SignCoordinatorResponse) GetMuSig2CombineSigResponse() *signrpc.MuSig2CombineSigResponse {
+ if x != nil {
+ if x, ok := x.SignResponseType.(*SignCoordinatorResponse_MuSig2CombineSigResponse); ok {
+ return x.MuSig2CombineSigResponse
+ }
+ }
+ return nil
+}
+
+func (x *SignCoordinatorResponse) GetMuSig2CleanupResponse() *signrpc.MuSig2CleanupResponse {
+ if x != nil {
+ if x, ok := x.SignResponseType.(*SignCoordinatorResponse_MuSig2CleanupResponse); ok {
+ return x.MuSig2CleanupResponse
+ }
+ }
+ return nil
+}
+
+func (x *SignCoordinatorResponse) GetSignPsbtResponse() *walletrpc.SignPsbtResponse {
+ if x != nil {
+ if x, ok := x.SignResponseType.(*SignCoordinatorResponse_SignPsbtResponse); ok {
+ return x.SignPsbtResponse
+ }
+ }
+ return nil
+}
+
+func (x *SignCoordinatorResponse) GetSignerError() *SignerError {
+ if x != nil {
+ if x, ok := x.SignResponseType.(*SignCoordinatorResponse_SignerError); ok {
+ return x.SignerError
+ }
+ }
+ return nil
+}
+
+type isSignCoordinatorResponse_SignResponseType interface {
+ isSignCoordinatorResponse_SignResponseType()
+}
+
+type SignCoordinatorResponse_SignerRegistration struct {
+ // The Signer Registration message is sent by the remote signer when it
+ // connects to the watch-only lnd node, to initialize a handshake between
+ // the nodes.
+ SignerRegistration *SignerRegistration `protobuf:"bytes,2,opt,name=signer_registration,json=signerRegistration,proto3,oneof"`
+}
+
+type SignCoordinatorResponse_Pong struct {
+ // To ensure that the remote signer is still active and alive, the
+ // watch-only node can send a Ping message to remote signer. This Pong
+ // message should then be sent by the remote signer to respond to the Ping
+ // message.
+ Pong bool `protobuf:"varint,3,opt,name=pong,proto3,oneof"`
+}
+
+type SignCoordinatorResponse_SharedKeyResponse struct {
+ // The remote signer's corresponding response to a Shared Key request.
+ SharedKeyResponse *signrpc.SharedKeyResponse `protobuf:"bytes,4,opt,name=shared_key_response,json=sharedKeyResponse,proto3,oneof"`
+}
+
+type SignCoordinatorResponse_SignMessageResp struct {
+ // The remote signer's corresponding response to a Sign Message request.
+ SignMessageResp *signrpc.SignMessageResp `protobuf:"bytes,5,opt,name=sign_message_resp,json=signMessageResp,proto3,oneof"`
+}
+
+type SignCoordinatorResponse_MuSig2SessionResponse struct {
+ // The remote signer's corresponding response to a Mu Sig2 Session
+ // request.
+ MuSig2SessionResponse *signrpc.MuSig2SessionResponse `protobuf:"bytes,6,opt,name=mu_sig2_session_response,json=muSig2SessionResponse,proto3,oneof"`
+}
+
+type SignCoordinatorResponse_MuSig2RegisterNoncesResponse struct {
+ // The remote signer's corresponding response to a Mu Sig2 Register Nonces
+ // request.
+ MuSig2RegisterNoncesResponse *signrpc.MuSig2RegisterNoncesResponse `protobuf:"bytes,7,opt,name=mu_sig2_register_nonces_response,json=muSig2RegisterNoncesResponse,proto3,oneof"`
+}
+
+type SignCoordinatorResponse_MuSig2CombNoncesResp struct {
+ // The remote signer's corresponding response to a Mu Sig2 Register
+ // Combined Nonces request.
+ MuSig2CombNoncesResp *signrpc.MuSig2RegisterCombinedNonceResponse `protobuf:"bytes,8,opt,name=mu_sig2_comb_nonces_resp,json=muSig2CombNoncesResp,proto3,oneof"`
+}
+
+type SignCoordinatorResponse_MuSig2GetCombNoncesResp struct {
+ // The remote signer's corresponding response to a Mu Sig2 Get Combined
+ // Nonces request.
+ MuSig2GetCombNoncesResp *signrpc.MuSig2GetCombinedNonceResponse `protobuf:"bytes,9,opt,name=mu_sig2_get_comb_nonces_resp,json=muSig2GetCombNoncesResp,proto3,oneof"`
+}
+
+type SignCoordinatorResponse_MuSig2SignResponse struct {
+ // The remote signer's corresponding response to a Mu Sig2 Sign request.
+ MuSig2SignResponse *signrpc.MuSig2SignResponse `protobuf:"bytes,10,opt,name=mu_sig2_sign_response,json=muSig2SignResponse,proto3,oneof"`
+}
+
+type SignCoordinatorResponse_MuSig2CombineSigResponse struct {
+ // The remote signer's corresponding response to a Mu Sig2 Combine Sig
+ // request.
+ MuSig2CombineSigResponse *signrpc.MuSig2CombineSigResponse `protobuf:"bytes,11,opt,name=mu_sig2_combine_sig_response,json=muSig2CombineSigResponse,proto3,oneof"`
+}
+
+type SignCoordinatorResponse_MuSig2CleanupResponse struct {
+ // The remote signer's corresponding response to a Mu Sig2 Cleanup
+ // request.
+ MuSig2CleanupResponse *signrpc.MuSig2CleanupResponse `protobuf:"bytes,12,opt,name=mu_sig2_cleanup_response,json=muSig2CleanupResponse,proto3,oneof"`
+}
+
+type SignCoordinatorResponse_SignPsbtResponse struct {
+ // The remote signer's corresponding response to a Sign Psbt request.
+ SignPsbtResponse *walletrpc.SignPsbtResponse `protobuf:"bytes,13,opt,name=sign_psbt_response,json=signPsbtResponse,proto3,oneof"`
+}
+
+type SignCoordinatorResponse_SignerError struct {
+ // If the remote signer encounters an error while processing a request, it
+ // will respond with a SignerError message that details the error.
+ SignerError *SignerError `protobuf:"bytes,14,opt,name=signer_error,json=signerError,proto3,oneof"`
+}
+
+func (*SignCoordinatorResponse_SignerRegistration) isSignCoordinatorResponse_SignResponseType() {}
+
+func (*SignCoordinatorResponse_Pong) isSignCoordinatorResponse_SignResponseType() {}
+
+func (*SignCoordinatorResponse_SharedKeyResponse) isSignCoordinatorResponse_SignResponseType() {}
+
+func (*SignCoordinatorResponse_SignMessageResp) isSignCoordinatorResponse_SignResponseType() {}
+
+func (*SignCoordinatorResponse_MuSig2SessionResponse) isSignCoordinatorResponse_SignResponseType() {}
+
+func (*SignCoordinatorResponse_MuSig2RegisterNoncesResponse) isSignCoordinatorResponse_SignResponseType() {
+}
+
+func (*SignCoordinatorResponse_MuSig2CombNoncesResp) isSignCoordinatorResponse_SignResponseType() {}
+
+func (*SignCoordinatorResponse_MuSig2GetCombNoncesResp) isSignCoordinatorResponse_SignResponseType() {
+}
+
+func (*SignCoordinatorResponse_MuSig2SignResponse) isSignCoordinatorResponse_SignResponseType() {}
+
+func (*SignCoordinatorResponse_MuSig2CombineSigResponse) isSignCoordinatorResponse_SignResponseType() {
+}
+
+func (*SignCoordinatorResponse_MuSig2CleanupResponse) isSignCoordinatorResponse_SignResponseType() {}
+
+func (*SignCoordinatorResponse_SignPsbtResponse) isSignCoordinatorResponse_SignResponseType() {}
+
+func (*SignCoordinatorResponse_SignerError) isSignCoordinatorResponse_SignResponseType() {}
+
+type SignerError struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // Details an error which occurred on remote signer.
+ Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *SignerError) Reset() {
+ *x = SignerError{}
+ mi := &file_watchonlyrpc_watchonly_proto_msgTypes[2]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *SignerError) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*SignerError) ProtoMessage() {}
+
+func (x *SignerError) ProtoReflect() protoreflect.Message {
+ mi := &file_watchonlyrpc_watchonly_proto_msgTypes[2]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use SignerError.ProtoReflect.Descriptor instead.
+func (*SignerError) Descriptor() ([]byte, []int) {
+ return file_watchonlyrpc_watchonly_proto_rawDescGZIP(), []int{2}
+}
+
+func (x *SignerError) GetError() string {
+ if x != nil {
+ return x.Error
+ }
+ return ""
+}
+
+type SignerRegistration struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // The registration challenge allows the remote signer to pass data that will
+ // be signed by the watch-only lnd. The resulting signature will be returned in
+ // the RegistrationResponse message.
+ RegistrationChallenge []byte `protobuf:"bytes,1,opt,name=registration_challenge,json=registrationChallenge,proto3" json:"registration_challenge,omitempty"`
+ // The registration info contains details about the remote signer that may be
+ // useful for the watch-only lnd.
+ RegistrationInfo string `protobuf:"bytes,2,opt,name=registration_info,json=registrationInfo,proto3" json:"registration_info,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *SignerRegistration) Reset() {
+ *x = SignerRegistration{}
+ mi := &file_watchonlyrpc_watchonly_proto_msgTypes[3]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *SignerRegistration) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*SignerRegistration) ProtoMessage() {}
+
+func (x *SignerRegistration) ProtoReflect() protoreflect.Message {
+ mi := &file_watchonlyrpc_watchonly_proto_msgTypes[3]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use SignerRegistration.ProtoReflect.Descriptor instead.
+func (*SignerRegistration) Descriptor() ([]byte, []int) {
+ return file_watchonlyrpc_watchonly_proto_rawDescGZIP(), []int{3}
+}
+
+func (x *SignerRegistration) GetRegistrationChallenge() []byte {
+ if x != nil {
+ return x.RegistrationChallenge
+ }
+ return nil
+}
+
+func (x *SignerRegistration) GetRegistrationInfo() string {
+ if x != nil {
+ return x.RegistrationInfo
+ }
+ return ""
+}
+
+type RegistrationResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // The registration response indicates either a successful registration or an
+ // error.
+ //
+ // Types that are valid to be assigned to RegistrationResponseType:
+ //
+ // *RegistrationResponse_RegistrationComplete
+ // *RegistrationResponse_RegistrationError
+ RegistrationResponseType isRegistrationResponse_RegistrationResponseType `protobuf_oneof:"registration_response_type"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *RegistrationResponse) Reset() {
+ *x = RegistrationResponse{}
+ mi := &file_watchonlyrpc_watchonly_proto_msgTypes[4]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *RegistrationResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*RegistrationResponse) ProtoMessage() {}
+
+func (x *RegistrationResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_watchonlyrpc_watchonly_proto_msgTypes[4]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use RegistrationResponse.ProtoReflect.Descriptor instead.
+func (*RegistrationResponse) Descriptor() ([]byte, []int) {
+ return file_watchonlyrpc_watchonly_proto_rawDescGZIP(), []int{4}
+}
+
+func (x *RegistrationResponse) GetRegistrationResponseType() isRegistrationResponse_RegistrationResponseType {
+ if x != nil {
+ return x.RegistrationResponseType
+ }
+ return nil
+}
+
+func (x *RegistrationResponse) GetRegistrationComplete() *RegistrationComplete {
+ if x != nil {
+ if x, ok := x.RegistrationResponseType.(*RegistrationResponse_RegistrationComplete); ok {
+ return x.RegistrationComplete
+ }
+ }
+ return nil
+}
+
+func (x *RegistrationResponse) GetRegistrationError() string {
+ if x != nil {
+ if x, ok := x.RegistrationResponseType.(*RegistrationResponse_RegistrationError); ok {
+ return x.RegistrationError
+ }
+ }
+ return ""
+}
+
+type isRegistrationResponse_RegistrationResponseType interface {
+ isRegistrationResponse_RegistrationResponseType()
+}
+
+type RegistrationResponse_RegistrationComplete struct {
+ // Sent by the watch-only lnd when the remote signer registration is
+ // successful.
+ RegistrationComplete *RegistrationComplete `protobuf:"bytes,1,opt,name=registration_complete,json=registrationComplete,proto3,oneof"`
+}
+
+type RegistrationResponse_RegistrationError struct {
+ // Contains details about any errors that occurred during remote signer
+ // registration.
+ RegistrationError string `protobuf:"bytes,2,opt,name=registration_error,json=registrationError,proto3,oneof"`
+}
+
+func (*RegistrationResponse_RegistrationComplete) isRegistrationResponse_RegistrationResponseType() {}
+
+func (*RegistrationResponse_RegistrationError) isRegistrationResponse_RegistrationResponseType() {}
+
+type RegistrationComplete struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // Holds the signature generated by the watch-only node when signing the
+ // registration_challenge provided by the remote signer in SignerRegistration.
+ Signature string `protobuf:"bytes,1,opt,name=signature,proto3" json:"signature,omitempty"`
+ // Contains information about the watch-only lnd that may be useful for the
+ // remote signer.
+ RegistrationInfo string `protobuf:"bytes,2,opt,name=registration_info,json=registrationInfo,proto3" json:"registration_info,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *RegistrationComplete) Reset() {
+ *x = RegistrationComplete{}
+ mi := &file_watchonlyrpc_watchonly_proto_msgTypes[5]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *RegistrationComplete) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*RegistrationComplete) ProtoMessage() {}
+
+func (x *RegistrationComplete) ProtoReflect() protoreflect.Message {
+ mi := &file_watchonlyrpc_watchonly_proto_msgTypes[5]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use RegistrationComplete.ProtoReflect.Descriptor instead.
+func (*RegistrationComplete) Descriptor() ([]byte, []int) {
+ return file_watchonlyrpc_watchonly_proto_rawDescGZIP(), []int{5}
+}
+
+func (x *RegistrationComplete) GetSignature() string {
+ if x != nil {
+ return x.Signature
+ }
+ return ""
+}
+
+func (x *RegistrationComplete) GetRegistrationInfo() string {
+ if x != nil {
+ return x.RegistrationInfo
+ }
+ return ""
+}
+
+var File_watchonlyrpc_watchonly_proto protoreflect.FileDescriptor
+
+const file_watchonlyrpc_watchonly_proto_rawDesc = "" +
+ "\n" +
+ "\x1cwatchonlyrpc/watchonly.proto\x12\fwatchonlyrpc\x1a\x14signrpc/signer.proto\x1a\x19walletrpc/walletkit.proto\"\xc2\b\n" +
+ "\x16SignCoordinatorRequest\x12\x1d\n" +
+ "\n" +
+ "request_id\x18\x01 \x01(\x04R\trequestId\x12Y\n" +
+ "\x15registration_response\x18\x02 \x01(\v2\".watchonlyrpc.RegistrationResponseH\x00R\x14registrationResponse\x12\x14\n" +
+ "\x04ping\x18\x03 \x01(\bH\x00R\x04ping\x12I\n" +
+ "\x12shared_key_request\x18\x04 \x01(\v2\x19.signrpc.SharedKeyRequestH\x00R\x10sharedKeyRequest\x12C\n" +
+ "\x10sign_message_req\x18\x05 \x01(\v2\x17.signrpc.SignMessageReqH\x00R\x0esignMessageReq\x12V\n" +
+ "\x17mu_sig2_session_request\x18\x06 \x01(\v2\x1d.signrpc.MuSig2SessionRequestH\x00R\x14muSig2SessionRequest\x12l\n" +
+ "\x1fmu_sig2_register_nonces_request\x18\a \x01(\v2$.signrpc.MuSig2RegisterNoncesRequestH\x00R\x1bmuSig2RegisterNoncesRequest\x12k\n" +
+ "\x1bmu_sig2_combined_nonces_req\x18\b \x01(\v2+.signrpc.MuSig2RegisterCombinedNonceRequestH\x00R\x17muSig2CombinedNoncesReq\x12m\n" +
+ "\x1fmu_sig2_get_combined_nonces_req\x18\t \x01(\v2&.signrpc.MuSig2GetCombinedNonceRequestH\x00R\x1amuSig2GetCombinedNoncesReq\x12M\n" +
+ "\x14mu_sig2_sign_request\x18\n" +
+ " \x01(\v2\x1a.signrpc.MuSig2SignRequestH\x00R\x11muSig2SignRequest\x12`\n" +
+ "\x1bmu_sig2_combine_sig_request\x18\v \x01(\v2 .signrpc.MuSig2CombineSigRequestH\x00R\x17muSig2CombineSigRequest\x12V\n" +
+ "\x17mu_sig2_cleanup_request\x18\f \x01(\v2\x1d.signrpc.MuSig2CleanupRequestH\x00R\x14muSig2CleanupRequest\x12H\n" +
+ "\x11sign_psbt_request\x18\r \x01(\v2\x1a.walletrpc.SignPsbtRequestH\x00R\x0fsignPsbtRequestB\x13\n" +
+ "\x11sign_request_type\"\x93\t\n" +
+ "\x17SignCoordinatorResponse\x12$\n" +
+ "\x0eref_request_id\x18\x01 \x01(\x04R\frefRequestId\x12S\n" +
+ "\x13signer_registration\x18\x02 \x01(\v2 .watchonlyrpc.SignerRegistrationH\x00R\x12signerRegistration\x12\x14\n" +
+ "\x04pong\x18\x03 \x01(\bH\x00R\x04pong\x12L\n" +
+ "\x13shared_key_response\x18\x04 \x01(\v2\x1a.signrpc.SharedKeyResponseH\x00R\x11sharedKeyResponse\x12F\n" +
+ "\x11sign_message_resp\x18\x05 \x01(\v2\x18.signrpc.SignMessageRespH\x00R\x0fsignMessageResp\x12Y\n" +
+ "\x18mu_sig2_session_response\x18\x06 \x01(\v2\x1e.signrpc.MuSig2SessionResponseH\x00R\x15muSig2SessionResponse\x12o\n" +
+ " mu_sig2_register_nonces_response\x18\a \x01(\v2%.signrpc.MuSig2RegisterNoncesResponseH\x00R\x1cmuSig2RegisterNoncesResponse\x12f\n" +
+ "\x18mu_sig2_comb_nonces_resp\x18\b \x01(\v2,.signrpc.MuSig2RegisterCombinedNonceResponseH\x00R\x14muSig2CombNoncesResp\x12h\n" +
+ "\x1cmu_sig2_get_comb_nonces_resp\x18\t \x01(\v2'.signrpc.MuSig2GetCombinedNonceResponseH\x00R\x17muSig2GetCombNoncesResp\x12P\n" +
+ "\x15mu_sig2_sign_response\x18\n" +
+ " \x01(\v2\x1b.signrpc.MuSig2SignResponseH\x00R\x12muSig2SignResponse\x12c\n" +
+ "\x1cmu_sig2_combine_sig_response\x18\v \x01(\v2!.signrpc.MuSig2CombineSigResponseH\x00R\x18muSig2CombineSigResponse\x12Y\n" +
+ "\x18mu_sig2_cleanup_response\x18\f \x01(\v2\x1e.signrpc.MuSig2CleanupResponseH\x00R\x15muSig2CleanupResponse\x12K\n" +
+ "\x12sign_psbt_response\x18\r \x01(\v2\x1b.walletrpc.SignPsbtResponseH\x00R\x10signPsbtResponse\x12>\n" +
+ "\fsigner_error\x18\x0e \x01(\v2\x19.watchonlyrpc.SignerErrorH\x00R\vsignerErrorB\x14\n" +
+ "\x12sign_response_type\"#\n" +
+ "\vSignerError\x12\x14\n" +
+ "\x05error\x18\x01 \x01(\tR\x05error\"x\n" +
+ "\x12SignerRegistration\x125\n" +
+ "\x16registration_challenge\x18\x01 \x01(\fR\x15registrationChallenge\x12+\n" +
+ "\x11registration_info\x18\x02 \x01(\tR\x10registrationInfo\"\xc0\x01\n" +
+ "\x14RegistrationResponse\x12Y\n" +
+ "\x15registration_complete\x18\x01 \x01(\v2\".watchonlyrpc.RegistrationCompleteH\x00R\x14registrationComplete\x12/\n" +
+ "\x12registration_error\x18\x02 \x01(\tH\x00R\x11registrationErrorB\x1c\n" +
+ "\x1aregistration_response_type\"a\n" +
+ "\x14RegistrationComplete\x12\x1c\n" +
+ "\tsignature\x18\x01 \x01(\tR\tsignature\x12+\n" +
+ "\x11registration_info\x18\x02 \x01(\tR\x10registrationInfo2v\n" +
+ "\tWatchOnly\x12i\n" +
+ "\x16SignCoordinatorStreams\x12%.watchonlyrpc.SignCoordinatorResponse\x1a$.watchonlyrpc.SignCoordinatorRequest(\x010\x01B4Z2github.com/lightningnetwork/lnd/lnrpc/watchonlyrpcb\x06proto3"
+
+var (
+ file_watchonlyrpc_watchonly_proto_rawDescOnce sync.Once
+ file_watchonlyrpc_watchonly_proto_rawDescData []byte
+)
+
+func file_watchonlyrpc_watchonly_proto_rawDescGZIP() []byte {
+ file_watchonlyrpc_watchonly_proto_rawDescOnce.Do(func() {
+ file_watchonlyrpc_watchonly_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_watchonlyrpc_watchonly_proto_rawDesc), len(file_watchonlyrpc_watchonly_proto_rawDesc)))
+ })
+ return file_watchonlyrpc_watchonly_proto_rawDescData
+}
+
+var file_watchonlyrpc_watchonly_proto_msgTypes = make([]protoimpl.MessageInfo, 6)
+var file_watchonlyrpc_watchonly_proto_goTypes = []any{
+ (*SignCoordinatorRequest)(nil), // 0: watchonlyrpc.SignCoordinatorRequest
+ (*SignCoordinatorResponse)(nil), // 1: watchonlyrpc.SignCoordinatorResponse
+ (*SignerError)(nil), // 2: watchonlyrpc.SignerError
+ (*SignerRegistration)(nil), // 3: watchonlyrpc.SignerRegistration
+ (*RegistrationResponse)(nil), // 4: watchonlyrpc.RegistrationResponse
+ (*RegistrationComplete)(nil), // 5: watchonlyrpc.RegistrationComplete
+ (*signrpc.SharedKeyRequest)(nil), // 6: signrpc.SharedKeyRequest
+ (*signrpc.SignMessageReq)(nil), // 7: signrpc.SignMessageReq
+ (*signrpc.MuSig2SessionRequest)(nil), // 8: signrpc.MuSig2SessionRequest
+ (*signrpc.MuSig2RegisterNoncesRequest)(nil), // 9: signrpc.MuSig2RegisterNoncesRequest
+ (*signrpc.MuSig2RegisterCombinedNonceRequest)(nil), // 10: signrpc.MuSig2RegisterCombinedNonceRequest
+ (*signrpc.MuSig2GetCombinedNonceRequest)(nil), // 11: signrpc.MuSig2GetCombinedNonceRequest
+ (*signrpc.MuSig2SignRequest)(nil), // 12: signrpc.MuSig2SignRequest
+ (*signrpc.MuSig2CombineSigRequest)(nil), // 13: signrpc.MuSig2CombineSigRequest
+ (*signrpc.MuSig2CleanupRequest)(nil), // 14: signrpc.MuSig2CleanupRequest
+ (*walletrpc.SignPsbtRequest)(nil), // 15: walletrpc.SignPsbtRequest
+ (*signrpc.SharedKeyResponse)(nil), // 16: signrpc.SharedKeyResponse
+ (*signrpc.SignMessageResp)(nil), // 17: signrpc.SignMessageResp
+ (*signrpc.MuSig2SessionResponse)(nil), // 18: signrpc.MuSig2SessionResponse
+ (*signrpc.MuSig2RegisterNoncesResponse)(nil), // 19: signrpc.MuSig2RegisterNoncesResponse
+ (*signrpc.MuSig2RegisterCombinedNonceResponse)(nil), // 20: signrpc.MuSig2RegisterCombinedNonceResponse
+ (*signrpc.MuSig2GetCombinedNonceResponse)(nil), // 21: signrpc.MuSig2GetCombinedNonceResponse
+ (*signrpc.MuSig2SignResponse)(nil), // 22: signrpc.MuSig2SignResponse
+ (*signrpc.MuSig2CombineSigResponse)(nil), // 23: signrpc.MuSig2CombineSigResponse
+ (*signrpc.MuSig2CleanupResponse)(nil), // 24: signrpc.MuSig2CleanupResponse
+ (*walletrpc.SignPsbtResponse)(nil), // 25: walletrpc.SignPsbtResponse
+}
+var file_watchonlyrpc_watchonly_proto_depIdxs = []int32{
+ 4, // 0: watchonlyrpc.SignCoordinatorRequest.registration_response:type_name -> watchonlyrpc.RegistrationResponse
+ 6, // 1: watchonlyrpc.SignCoordinatorRequest.shared_key_request:type_name -> signrpc.SharedKeyRequest
+ 7, // 2: watchonlyrpc.SignCoordinatorRequest.sign_message_req:type_name -> signrpc.SignMessageReq
+ 8, // 3: watchonlyrpc.SignCoordinatorRequest.mu_sig2_session_request:type_name -> signrpc.MuSig2SessionRequest
+ 9, // 4: watchonlyrpc.SignCoordinatorRequest.mu_sig2_register_nonces_request:type_name -> signrpc.MuSig2RegisterNoncesRequest
+ 10, // 5: watchonlyrpc.SignCoordinatorRequest.mu_sig2_combined_nonces_req:type_name -> signrpc.MuSig2RegisterCombinedNonceRequest
+ 11, // 6: watchonlyrpc.SignCoordinatorRequest.mu_sig2_get_combined_nonces_req:type_name -> signrpc.MuSig2GetCombinedNonceRequest
+ 12, // 7: watchonlyrpc.SignCoordinatorRequest.mu_sig2_sign_request:type_name -> signrpc.MuSig2SignRequest
+ 13, // 8: watchonlyrpc.SignCoordinatorRequest.mu_sig2_combine_sig_request:type_name -> signrpc.MuSig2CombineSigRequest
+ 14, // 9: watchonlyrpc.SignCoordinatorRequest.mu_sig2_cleanup_request:type_name -> signrpc.MuSig2CleanupRequest
+ 15, // 10: watchonlyrpc.SignCoordinatorRequest.sign_psbt_request:type_name -> walletrpc.SignPsbtRequest
+ 3, // 11: watchonlyrpc.SignCoordinatorResponse.signer_registration:type_name -> watchonlyrpc.SignerRegistration
+ 16, // 12: watchonlyrpc.SignCoordinatorResponse.shared_key_response:type_name -> signrpc.SharedKeyResponse
+ 17, // 13: watchonlyrpc.SignCoordinatorResponse.sign_message_resp:type_name -> signrpc.SignMessageResp
+ 18, // 14: watchonlyrpc.SignCoordinatorResponse.mu_sig2_session_response:type_name -> signrpc.MuSig2SessionResponse
+ 19, // 15: watchonlyrpc.SignCoordinatorResponse.mu_sig2_register_nonces_response:type_name -> signrpc.MuSig2RegisterNoncesResponse
+ 20, // 16: watchonlyrpc.SignCoordinatorResponse.mu_sig2_comb_nonces_resp:type_name -> signrpc.MuSig2RegisterCombinedNonceResponse
+ 21, // 17: watchonlyrpc.SignCoordinatorResponse.mu_sig2_get_comb_nonces_resp:type_name -> signrpc.MuSig2GetCombinedNonceResponse
+ 22, // 18: watchonlyrpc.SignCoordinatorResponse.mu_sig2_sign_response:type_name -> signrpc.MuSig2SignResponse
+ 23, // 19: watchonlyrpc.SignCoordinatorResponse.mu_sig2_combine_sig_response:type_name -> signrpc.MuSig2CombineSigResponse
+ 24, // 20: watchonlyrpc.SignCoordinatorResponse.mu_sig2_cleanup_response:type_name -> signrpc.MuSig2CleanupResponse
+ 25, // 21: watchonlyrpc.SignCoordinatorResponse.sign_psbt_response:type_name -> walletrpc.SignPsbtResponse
+ 2, // 22: watchonlyrpc.SignCoordinatorResponse.signer_error:type_name -> watchonlyrpc.SignerError
+ 5, // 23: watchonlyrpc.RegistrationResponse.registration_complete:type_name -> watchonlyrpc.RegistrationComplete
+ 1, // 24: watchonlyrpc.WatchOnly.SignCoordinatorStreams:input_type -> watchonlyrpc.SignCoordinatorResponse
+ 0, // 25: watchonlyrpc.WatchOnly.SignCoordinatorStreams:output_type -> watchonlyrpc.SignCoordinatorRequest
+ 25, // [25:26] is the sub-list for method output_type
+ 24, // [24:25] is the sub-list for method input_type
+ 24, // [24:24] is the sub-list for extension type_name
+ 24, // [24:24] is the sub-list for extension extendee
+ 0, // [0:24] is the sub-list for field type_name
+}
+
+func init() { file_watchonlyrpc_watchonly_proto_init() }
+func file_watchonlyrpc_watchonly_proto_init() {
+ if File_watchonlyrpc_watchonly_proto != nil {
+ return
+ }
+ file_watchonlyrpc_watchonly_proto_msgTypes[0].OneofWrappers = []any{
+ (*SignCoordinatorRequest_RegistrationResponse)(nil),
+ (*SignCoordinatorRequest_Ping)(nil),
+ (*SignCoordinatorRequest_SharedKeyRequest)(nil),
+ (*SignCoordinatorRequest_SignMessageReq)(nil),
+ (*SignCoordinatorRequest_MuSig2SessionRequest)(nil),
+ (*SignCoordinatorRequest_MuSig2RegisterNoncesRequest)(nil),
+ (*SignCoordinatorRequest_MuSig2CombinedNoncesReq)(nil),
+ (*SignCoordinatorRequest_MuSig2GetCombinedNoncesReq)(nil),
+ (*SignCoordinatorRequest_MuSig2SignRequest)(nil),
+ (*SignCoordinatorRequest_MuSig2CombineSigRequest)(nil),
+ (*SignCoordinatorRequest_MuSig2CleanupRequest)(nil),
+ (*SignCoordinatorRequest_SignPsbtRequest)(nil),
+ }
+ file_watchonlyrpc_watchonly_proto_msgTypes[1].OneofWrappers = []any{
+ (*SignCoordinatorResponse_SignerRegistration)(nil),
+ (*SignCoordinatorResponse_Pong)(nil),
+ (*SignCoordinatorResponse_SharedKeyResponse)(nil),
+ (*SignCoordinatorResponse_SignMessageResp)(nil),
+ (*SignCoordinatorResponse_MuSig2SessionResponse)(nil),
+ (*SignCoordinatorResponse_MuSig2RegisterNoncesResponse)(nil),
+ (*SignCoordinatorResponse_MuSig2CombNoncesResp)(nil),
+ (*SignCoordinatorResponse_MuSig2GetCombNoncesResp)(nil),
+ (*SignCoordinatorResponse_MuSig2SignResponse)(nil),
+ (*SignCoordinatorResponse_MuSig2CombineSigResponse)(nil),
+ (*SignCoordinatorResponse_MuSig2CleanupResponse)(nil),
+ (*SignCoordinatorResponse_SignPsbtResponse)(nil),
+ (*SignCoordinatorResponse_SignerError)(nil),
+ }
+ file_watchonlyrpc_watchonly_proto_msgTypes[4].OneofWrappers = []any{
+ (*RegistrationResponse_RegistrationComplete)(nil),
+ (*RegistrationResponse_RegistrationError)(nil),
+ }
+ type x struct{}
+ out := protoimpl.TypeBuilder{
+ File: protoimpl.DescBuilder{
+ GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+ RawDescriptor: unsafe.Slice(unsafe.StringData(file_watchonlyrpc_watchonly_proto_rawDesc), len(file_watchonlyrpc_watchonly_proto_rawDesc)),
+ NumEnums: 0,
+ NumMessages: 6,
+ NumExtensions: 0,
+ NumServices: 1,
+ },
+ GoTypes: file_watchonlyrpc_watchonly_proto_goTypes,
+ DependencyIndexes: file_watchonlyrpc_watchonly_proto_depIdxs,
+ MessageInfos: file_watchonlyrpc_watchonly_proto_msgTypes,
+ }.Build()
+ File_watchonlyrpc_watchonly_proto = out.File
+ file_watchonlyrpc_watchonly_proto_goTypes = nil
+ file_watchonlyrpc_watchonly_proto_depIdxs = nil
+}
### lnrpc/watchonlyrpc/watchonly.pb.gw.go
@@ -0,0 +1,162 @@
+// Code generated by protoc-gen-grpc-gateway. DO NOT EDIT.
+// source: watchonlyrpc/watchonly.proto
+
+/*
+Package watchonlyrpc is a reverse proxy.
+
+It translates gRPC into RESTful JSON APIs.
+*/
+package watchonlyrpc
+
+import (
+ "context"
+ "io"
+ "net/http"
+
+ "github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
+ "github.com/grpc-ecosystem/grpc-gateway/v2/utilities"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/grpclog"
+ "google.golang.org/grpc/metadata"
+ "google.golang.org/grpc/status"
+ "google.golang.org/protobuf/proto"
+)
+
+// Suppress "imported and not used" errors
+var _ codes.Code
+var _ io.Reader
+var _ status.Status
+var _ = runtime.String
+var _ = utilities.NewDoubleArray
+var _ = metadata.Join
+
+func request_WatchOnly_SignCoordinatorStreams_0(ctx context.Context, marshaler runtime.Marshaler, client WatchOnlyClient, req *http.Request, pathParams map[string]string) (WatchOnly_SignCoordinatorStreamsClient, runtime.ServerMetadata, error) {
+ var metadata runtime.ServerMetadata
+ stream, err := client.SignCoordinatorStreams(ctx)
+ if err != nil {
+ grpclog.Infof("Failed to start streaming: %v", err)
+ return nil, metadata, err
+ }
+ dec := marshaler.NewDecoder(req.Body)
+ handleSend := func() error {
+ var protoReq SignCoordinatorResponse
+ err := dec.Decode(&protoReq)
+ if err == io.EOF {
+ return err
+ }
+ if err != nil {
+ grpclog.Infof("Failed to decode request: %v", err)
+ return err
+ }
+ if err := stream.Send(&protoReq); err != nil {
+ grpclog.Infof("Failed to send request: %v", err)
+ return err
+ }
+ return nil
+ }
+ go func() {
+ for {
+ if err := handleSend(); err != nil {
+ break
+ }
+ }
+ if err := stream.CloseSend(); err != nil {
+ grpclog.Infof("Failed to terminate client stream: %v", err)
+ }
+ }()
+ header, err := stream.Header()
+ if err != nil {
+ grpclog.Infof("Failed to get header from client: %v", err)
+ return nil, metadata, err
+ }
+ metadata.HeaderMD = header
+ return stream, metadata, nil
+}
+
+// RegisterWatchOnlyHandlerServer registers the http handlers for service WatchOnly to "mux".
+// UnaryRPC :call WatchOnlyServer directly.
+// StreamingRPC :currently unsupported pending https://github.com/grpc/grpc-go/issues/906.
+// Note that using this registration option will cause many gRPC library features to stop working. Consider using RegisterWatchOnlyHandlerFromEndpoint instead.
+func RegisterWatchOnlyHandlerServer(ctx context.Context, mux *runtime.ServeMux, server WatchOnlyServer) error {
+
+ mux.Handle("POST", pattern_WatchOnly_SignCoordinatorStreams_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ err := status.Error(codes.Unimplemented, "streaming calls are not yet supported in the in-process transport")
+ _, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ })
+
+ return nil
+}
+
+// RegisterWatchOnlyHandlerFromEndpoint is same as RegisterWatchOnlyHandler but
+// automatically dials to "endpoint" and closes the connection when "ctx" gets done.
+func RegisterWatchOnlyHandlerFromEndpoint(ctx context.Context, mux *runtime.ServeMux, endpoint string, opts []grpc.DialOption) (err error) {
+ conn, err := grpc.DialContext(ctx, endpoint, opts...)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err != nil {
+ if cerr := conn.Close(); cerr != nil {
+ grpclog.Infof("Failed to close conn to %s: %v", endpoint, cerr)
+ }
+ return
+ }
+ go func() {
+ <-ctx.Done()
+ if cerr := conn.Close(); cerr != nil {
+ grpclog.Infof("Failed to close conn to %s: %v", endpoint, cerr)
+ }
+ }()
+ }()
+
+ return RegisterWatchOnlyHandler(ctx, mux, conn)
+}
+
+// RegisterWatchOnlyHandler registers the http handlers for service WatchOnly to "mux".
+// The handlers forward requests to the grpc endpoint over "conn".
+func RegisterWatchOnlyHandler(ctx context.Context, mux *runtime.ServeMux, conn *grpc.ClientConn) error {
+ return RegisterWatchOnlyHandlerClient(ctx, mux, NewWatchOnlyClient(conn))
+}
+
+// RegisterWatchOnlyHandlerClient registers the http handlers for service WatchOnly
+// to "mux". The handlers forward requests to the grpc endpoint over the given implementation of "WatchOnlyClient".
+// Note: the gRPC framework executes interceptors within the gRPC handler. If the passed in "WatchOnlyClient"
+// doesn't go through the normal gRPC flow (creating a gRPC client etc.) then it will be up to the passed in
+// "WatchOnlyClient" to call the correct interceptors.
+func RegisterWatchOnlyHandlerClient(ctx context.Context, mux *runtime.ServeMux, client WatchOnlyClient) error {
+
+ mux.Handle("POST", pattern_WatchOnly_SignCoordinatorStreams_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ var err error
+ var annotatedContext context.Context
+ annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/watchonlyrpc.WatchOnly/SignCoordinatorStreams", runtime.WithHTTPPathPattern("/v2/watchonly/stream"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_WatchOnly_SignCoordinatorStreams_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+
+ forward_WatchOnly_SignCoordinatorStreams_0(annotatedContext, mux, outboundMarshaler, w, req, func() (proto.Message, error) { return resp.Recv() }, mux.GetForwardResponseOptions()...)
+
+ })
+
+ return nil
+}
+
+var (
+ pattern_WatchOnly_SignCoordinatorStreams_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"v2", "watchonly", "stream"}, ""))
+)
+
+var (
+ forward_WatchOnly_SignCoordinatorStreams_0 = runtime.ForwardResponseStream
+)
### lnrpc/watchonlyrpc/watchonly.proto
@@ -0,0 +1,250 @@
+syntax = "proto3";
+
+package watchonlyrpc;
+
+import "signrpc/signer.proto";
+import "walletrpc/walletkit.proto";
+
+option go_package = "github.com/lightningnetwork/lnd/lnrpc/watchonlyrpc";
+
+// WatchOnly exposes RPCs used by a remote signer to connect to a watch-only
+// node.
+service WatchOnly {
+ // SignCoordinatorStreams dispatches a bi-directional streaming RPC that
+ // allows a remote signer to connect to a watch-only node and remotely
+ // provide signatures and ECDH results on demand.
+ rpc SignCoordinatorStreams (stream SignCoordinatorResponse)
+ returns (stream SignCoordinatorRequest);
+}
+
+message SignCoordinatorRequest {
+ /*
+ A unique request ID of a SignCoordinator gRPC request. Useful for mapping
+ requests to responses.
+ Note that request_id 1 is reserved for the handshake with between
+ watch-only node and the remote signer.
+ */
+ uint64 request_id = 1;
+
+ /*
+ Messages between the watch-only node and the remote signer can only be of
+ certain types.
+ */
+ oneof sign_request_type {
+ /*
+ The Registration Response message is returned by the watch-only lnd as
+ a response to SignerRegistration message.
+ */
+ RegistrationResponse registration_response = 2;
+
+ /*
+ To ensure that the remote signer is still active and alive, the
+ watch-only lnd can send a Ping message to the remote signer, which
+ should then respond with the respective Pong message.
+ */
+ bool ping = 3;
+
+ /*
+ Requests a shared public key from the remote signer.
+ */
+ signrpc.SharedKeyRequest shared_key_request = 4;
+
+ /*
+ Requests that the remote signer signs the passed message.
+ */
+ signrpc.SignMessageReq sign_message_req = 5;
+
+ /*
+ Requests a MuSig2 Session of the remote signer.
+ */
+ signrpc.MuSig2SessionRequest mu_sig2_session_request = 6;
+
+ /*
+ Requests that the remote signer registers a nonce with the referenced
+ MuSig2 Session.
+ */
+ signrpc.MuSig2RegisterNoncesRequest mu_sig2_register_nonces_request = 7;
+
+ /*
+ Requests that the remote signer registers a combined nonce with the
+ referenced MuSig2 Session.
+ */
+ signrpc.MuSig2RegisterCombinedNonceRequest mu_sig2_combined_nonces_req =
+ 8;
+
+ /*
+ Requests that the remote signer gets a combined nonce with the
+ referenced MuSig2 Session.
+ */
+ signrpc.MuSig2GetCombinedNonceRequest mu_sig2_get_combined_nonces_req =
+ 9;
+
+ /*
+ Requests that the remote signer signs the passed message digest with
+ the referenced MuSig2 Session.
+ */
+ signrpc.MuSig2SignRequest mu_sig2_sign_request = 10;
+
+ /*
+ Requests that the remote signer combines and adds the passed partial
+ signatures for the referenced MuSig2 Session.
+ */
+ signrpc.MuSig2CombineSigRequest mu_sig2_combine_sig_request = 11;
+
+ /*
+ Requests that the remote signer removes/cleans up the referenced
+ MuSig2 session.
+ */
+ signrpc.MuSig2CleanupRequest mu_sig2_cleanup_request = 12;
+
+ /*
+ Requests that the remote signer signs the passed PSBT.
+ */
+ walletrpc.SignPsbtRequest sign_psbt_request = 13;
+ }
+}
+
+message SignCoordinatorResponse {
+ /*
+ The request ID this response refers to.
+ */
+ uint64 ref_request_id = 1;
+
+ /*
+ The remote signer responses can only be of certain types.
+ */
+ oneof sign_response_type {
+ /*
+ The Signer Registration message is sent by the remote signer when it
+ connects to the watch-only lnd node, to initialize a handshake between
+ the nodes.
+ */
+ SignerRegistration signer_registration = 2;
+
+ /*
+ To ensure that the remote signer is still active and alive, the
+ watch-only node can send a Ping message to remote signer. This Pong
+ message should then be sent by the remote signer to respond to the Ping
+ message.
+ */
+ bool pong = 3;
+
+ /*
+ The remote signer's corresponding response to a Shared Key request.
+ */
+ signrpc.SharedKeyResponse shared_key_response = 4;
+
+ /*
+ The remote signer's corresponding response to a Sign Message request.
+ */
+ signrpc.SignMessageResp sign_message_resp = 5;
+
+ /*
+ The remote signer's corresponding response to a Mu Sig2 Session
+ request.
+ */
+ signrpc.MuSig2SessionResponse mu_sig2_session_response = 6;
+
+ /*
+ The remote signer's corresponding response to a Mu Sig2 Register Nonces
+ request.
+ */
+ signrpc.MuSig2RegisterNoncesResponse mu_sig2_register_nonces_response =
+ 7;
+
+ /*
+ The remote signer's corresponding response to a Mu Sig2 Register
+ Combined Nonces request.
+ */
+ signrpc.MuSig2RegisterCombinedNonceResponse mu_sig2_comb_nonces_resp =
+ 8;
+
+ /*
+ The remote signer's corresponding response to a Mu Sig2 Get Combined
+ Nonces request.
+ */
+ signrpc.MuSig2GetCombinedNonceResponse mu_sig2_get_comb_nonces_resp = 9;
+
+ /*
+ The remote signer's corresponding response to a Mu Sig2 Sign request.
+ */
+ signrpc.MuSig2SignResponse mu_sig2_sign_response = 10;
+
+ /*
+ The remote signer's corresponding response to a Mu Sig2 Combine Sig
+ request.
+ */
+ signrpc.MuSig2CombineSigResponse mu_sig2_combine_sig_response = 11;
+
+ /*
+ The remote signer's corresponding response to a Mu Sig2 Cleanup
+ request.
+ */
+ signrpc.MuSig2CleanupResponse mu_sig2_cleanup_response = 12;
+
+ /*
+ The remote signer's corresponding response to a Sign Psbt request.
+ */
+ walletrpc.SignPsbtResponse sign_psbt_response = 13;
+
+ /*
+ If the remote signer encounters an error while processing a request, it
+ will respond with a SignerError message that details the error.
+ */
+ SignerError signer_error = 14;
+ }
+}
+
+message SignerError {
+ // Details an error which occurred on remote signer.
+ string error = 1;
+}
+
+message SignerRegistration {
+ /*
+ The registration challenge allows the remote signer to pass data that will
+ be signed by the watch-only lnd. The resulting signature will be returned in
+ the RegistrationResponse message.
+ */
+ bytes registration_challenge = 1;
+
+ /*
+ The registration info contains details about the remote signer that may be
+ useful for the watch-only lnd.
+ */
+ string registration_info = 2;
+}
+
+message RegistrationResponse {
+ /*
+ The registration response indicates either a successful registration or an
+ error.
+ */
+ oneof registration_response_type {
+ /*
+ Sent by the watch-only lnd when the remote signer registration is
+ successful.
+ */
+ RegistrationComplete registration_complete = 1;
+
+ /*
+ Contains details about any errors that occurred during remote signer
+ registration.
+ */
+ string registration_error = 2;
+ }
+}
+
+message RegistrationComplete {
+ /*
+ Holds the signature generated by the watch-only node when signing the
+ registration_challenge provided by the remote signer in SignerRegistration.
+ */
+ string signature = 1;
+
+ /*
+ Contains information about the watch-only lnd that may be useful for the
+ remote signer.
+ */
+ string registration_info = 2;
+}
### lnrpc/watchonlyrpc/watchonly.swagger.json
@@ -0,0 +1,649 @@
+{
+ "swagger": "2.0",
+ "info": {
+ "title": "watchonlyrpc/watchonly.proto",
+ "version": "version not set"
+ },
+ "tags": [
+ {
+ "name": "WatchOnly"
+ }
+ ],
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "paths": {
+ "/v2/watchonly/stream": {
+ "post": {
+ "summary": "SignCoordinatorStreams dispatches a bi-directional streaming RPC that\nallows a remote signer to connect to a watch-only node and remotely\nprovide signatures and ECDH results on demand.",
+ "operationId": "WatchOnly_SignCoordinatorStreams",
+ "responses": {
+ "200": {
+ "description": "A successful response.(streaming responses)",
+ "schema": {
+ "type": "object",
+ "properties": {
+ "result": {
+ "$ref": "#/definitions/watchonlyrpcSignCoordinatorRequest"
+ },
+ "error": {
+ "$ref": "#/definitions/rpcStatus"
+ }
+ },
+ "title": "Stream result of watchonlyrpcSignCoordinatorRequest"
+ }
+ },
+ "default": {
+ "description": "An unexpected error response.",
+ "schema": {
+ "$ref": "#/definitions/rpcStatus"
+ }
+ }
+ },
+ "parameters": [
+ {
+ "name": "body",
+ "description": " (streaming inputs)",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/watchonlyrpcSignCoordinatorResponse"
+ }
+ }
+ ],
+ "tags": [
+ "WatchOnly"
+ ]
+ }
+ }
+ },
+ "definitions": {
+ "protobufAny": {
+ "type": "object",
+ "properties": {
+ "@type": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": {}
+ },
+ "rpcStatus": {
+ "type": "object",
+ "properties": {
+ "code": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "message": {
+ "type": "string"
+ },
+ "details": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "$ref": "#/definitions/protobufAny"
+ }
+ }
+ }
+ },
+ "signrpcKeyDescriptor": {
+ "type": "object",
+ "properties": {
+ "raw_key_bytes": {
+ "type": "string",
+ "format": "byte",
+ "description": "The raw bytes of the public key in the key pair being identified. Either\nthis or the KeyLocator must be specified."
+ },
+ "key_loc": {
+ "$ref": "#/definitions/signrpcKeyLocator",
+ "description": "The key locator that identifies which private key to use for signing.\nEither this or the raw bytes of the target public key must be specified."
+ }
+ }
+ },
+ "signrpcKeyLocator": {
+ "type": "object",
+ "properties": {
+ "key_family": {
+ "type": "integer",
+ "format": "int32",
+ "description": "The family of key being identified."
+ },
+ "key_index": {
+ "type": "integer",
+ "format": "int32",
+ "description": "The precise index of the key being identified."
+ }
+ }
+ },
+ "signrpcMuSig2CleanupRequest": {
+ "type": "object",
+ "properties": {
+ "session_id": {
+ "type": "string",
+ "format": "byte",
+ "description": "The unique ID of the signing session that should be removed/cleaned up."
+ }
+ }
+ },
+ "signrpcMuSig2CleanupResponse": {
+ "type": "object"
+ },
+ "signrpcMuSig2CombineSigRequest": {
+ "type": "object",
+ "properties": {
+ "session_id": {
+ "type": "string",
+ "format": "byte",
+ "description": "The unique ID of the signing session to combine the signatures for."
+ },
+ "other_partial_signatures": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "format": "byte"
+ },
+ "description": "The list of all other participants' partial signatures to add to the current\nsession."
+ }
+ }
+ },
+ "signrpcMuSig2CombineSigResponse": {
+ "type": "object",
+ "properties": {
+ "have_all_signatures": {
+ "type": "boolean",
+ "description": "Indicates whether all partial signatures required to create a final, full\nsignature are known yet. If this is true, then the final_signature field is\nset, otherwise it is empty."
+ },
+ "final_signature": {
+ "type": "string",
+ "format": "byte",
+ "description": "The final, full signature that is valid for the combined public key."
+ }
+ }
+ },
+ "signrpcMuSig2GetCombinedNonceRequest": {
+ "type": "object",
+ "properties": {
+ "session_id": {
+ "type": "string",
+ "format": "byte",
+ "description": "The unique ID of the signing session to get the combined nonce for."
+ }
+ }
+ },
+ "signrpcMuSig2GetCombinedNonceResponse": {
+ "type": "object",
+ "properties": {
+ "combined_public_nonce": {
+ "type": "string",
+ "format": "byte",
+ "description": "The 66-byte combined public nonce. This is a concatenation of two 33-byte\ncompressed public keys (R1 || R2)."
+ }
+ }
+ },
+ "signrpcMuSig2RegisterCombinedNonceRequest": {
+ "type": "object",
+ "properties": {
+ "session_id": {
+ "type": "string",
+ "format": "byte",
+ "description": "The unique ID of the signing session the combined nonce should be registered\nwith."
+ },
+ "combined_public_nonce": {
+ "type": "string",
+ "format": "byte",
+ "description": "The 66-byte combined public nonce that was aggregated externally. This is a\nconcatenation of two 33-byte compressed public keys (R1 || R2)."
+ }
+ }
+ },
+ "signrpcMuSig2RegisterCombinedNonceResponse": {
+ "type": "object"
+ },
+ "signrpcMuSig2RegisterNoncesRequest": {
+ "type": "object",
+ "properties": {
+ "session_id": {
+ "type": "string",
+ "format": "byte",
+ "description": "The unique ID of the signing session those nonces should be registered with."
+ },
+ "other_signer_public_nonces": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "format": "byte"
+ },
+ "description": "A list of all public nonces of other signing participants that should be\nregistered."
+ }
+ }
+ },
+ "signrpcMuSig2RegisterNoncesResponse": {
+ "type": "object",
+ "properties": {
+ "have_all_nonces": {
+ "type": "boolean",
+ "description": "Indicates whether all nonces required to start the signing process are known\nnow."
+ }
+ }
+ },
+ "signrpcMuSig2SessionRequest": {
+ "type": "object",
+ "properties": {
+ "key_loc": {
+ "$ref": "#/definitions/signrpcKeyLocator",
+ "description": "The key locator that identifies which key to use for signing."
+ },
+ "all_signer_pubkeys": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "format": "byte"
+ },
+ "description": "A list of all public keys (serialized in 32-byte x-only format for v0.4.0\nand 33-byte compressed format for v1.0.0rc2!) participating in the signing\nsession. The list will always be sorted lexicographically internally. This\nmust include the local key which is described by the above key_loc."
+ },
+ "other_signer_public_nonces": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "format": "byte"
+ },
+ "description": "An optional list of all public nonces of other signing participants that\nmight already be known."
+ },
+ "tweaks": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "$ref": "#/definitions/signrpcTweakDesc"
+ },
+ "description": "A series of optional generic tweaks to be applied to the aggregated\npublic key."
+ },
+ "taproot_tweak": {
+ "$ref": "#/definitions/signrpcTaprootTweakDesc",
+ "description": "An optional taproot specific tweak that must be specified if the MuSig2\ncombined key will be used as the main taproot key of a taproot output\non-chain."
+ },
+ "version": {
+ "$ref": "#/definitions/signrpcMuSig2Version",
+ "description": "The mandatory version of the MuSig2 BIP draft to use. This is necessary to\ndifferentiate between the changes that were made to the BIP while this\nexperimental RPC was already released. Some of those changes affect how the\ncombined key and nonces are created."
+ },
+ "pregenerated_local_nonce": {
+ "type": "string",
+ "format": "byte",
+ "description": "A set of pre generated secret local nonces to use in the musig2 session.\nThis field is optional. This can be useful for protocols that need to send\nnonces ahead of time before the set of signer keys are known. This value\nMUST be 97 bytes and be the concatenation of two CSPRNG generated 32 byte\nvalues and local public key used for signing as specified in the key_loc\nfield."
+ }
+ }
+ },
+ "signrpcMuSig2SessionResponse": {
+ "type": "object",
+ "properties": {
+ "session_id": {
+ "type": "string",
+ "format": "byte",
+ "description": "The unique ID that represents this signing session. A session can be used\nfor producing a signature a single time. If the signing fails for any\nreason, a new session with the same participants needs to be created."
+ },
+ "combined_key": {
+ "type": "string",
+ "format": "byte",
+ "description": "The combined public key (in the 32-byte x-only format) with all tweaks\napplied to it. If a taproot tweak is specified, this corresponds to the\ntaproot key that can be put into the on-chain output."
+ },
+ "taproot_internal_key": {
+ "type": "string",
+ "format": "byte",
+ "description": "The raw combined public key (in the 32-byte x-only format) before any tweaks\nare applied to it. If a taproot tweak is specified, this corresponds to the\ninternal key that needs to be put into the witness if the script spend path\nis used."
+ },
+ "local_public_nonces": {
+ "type": "string",
+ "format": "byte",
+ "description": "The two public nonces the local signer uses, combined into a single value\nof 66 bytes. Can be split into the two 33-byte points to get the individual\nnonces."
+ },
+ "have_all_nonces": {
+ "type": "boolean",
+ "description": "Indicates whether all nonces required to start the signing process are known\nnow."
+ },
+ "version": {
+ "$ref": "#/definitions/signrpcMuSig2Version",
+ "description": "The version of the MuSig2 BIP that was used to create the session."
+ }
+ }
+ },
+ "signrpcMuSig2SignRequest": {
+ "type": "object",
+ "properties": {
+ "session_id": {
+ "type": "string",
+ "format": "byte",
+ "description": "The unique ID of the signing session to use for signing."
+ },
+ "message_digest": {
+ "type": "string",
+ "format": "byte",
+ "description": "The 32-byte SHA256 digest of the message to sign."
+ },
+ "cleanup": {
+ "type": "boolean",
+ "description": "Cleanup indicates that after signing, the session state can be cleaned up,\nsince another participant is going to be responsible for combining the\npartial signatures."
+ }
+ }
+ },
+ "signrpcMuSig2SignResponse": {
+ "type": "object",
+ "properties": {
+ "local_partial_signature": {
+ "type": "string",
+ "format": "byte",
+ "description": "The partial signature created by the local signer."
+ }
+ }
+ },
+ "signrpcMuSig2Version": {
+ "type": "string",
+ "enum": [
+ "MUSIG2_VERSION_UNDEFINED",
+ "MUSIG2_VERSION_V040",
+ "MUSIG2_VERSION_V100RC2"
+ ],
+ "default": "MUSIG2_VERSION_UNDEFINED",
+ "description": " - MUSIG2_VERSION_UNDEFINED: The default value on the RPC is zero for enums so we need to represent an\ninvalid/undefined version by default to make sure clients upgrade their\nsoftware to set the version explicitly.\n - MUSIG2_VERSION_V040: The version of MuSig2 that lnd 0.15.x shipped with, which corresponds to the\nversion v0.4.0 of the MuSig2 BIP draft.\n - MUSIG2_VERSION_V100RC2: The current version of MuSig2 which corresponds to the version v1.0.0rc2 of\nthe MuSig2 BIP draft."
+ },
+ "signrpcSharedKeyRequest": {
+ "type": "object",
+ "properties": {
+ "ephemeral_pubkey": {
+ "type": "string",
+ "format": "byte",
+ "description": "The ephemeral public key to use for the DH key derivation."
+ },
+ "key_loc": {
+ "$ref": "#/definitions/signrpcKeyLocator",
+ "description": "Deprecated. The optional key locator of the local key that should be used.\nIf this parameter is not set then the node's identity private key will be\nused."
+ },
+ "key_desc": {
+ "$ref": "#/definitions/signrpcKeyDescriptor",
+ "description": "A key descriptor describes the key used for performing ECDH. Either a key\nlocator or a raw public key is expected, if neither is supplied, defaults to\nthe node's identity private key."
+ }
+ }
+ },
+ "signrpcSharedKeyResponse": {
+ "type": "object",
+ "properties": {
+ "shared_key": {
+ "type": "string",
+ "format": "byte",
+ "description": "The shared public key, hashed with sha256."
+ }
+ }
+ },
+ "signrpcSignMessageReq": {
+ "type": "object",
+ "properties": {
+ "msg": {
+ "type": "string",
+ "format": "byte",
+ "description": "The message to be signed. When using REST, this field must be encoded as\nbase64."
+ },
+ "key_loc": {
+ "$ref": "#/definitions/signrpcKeyLocator",
+ "description": "The key locator that identifies which key to use for signing."
+ },
+ "double_hash": {
+ "type": "boolean",
+ "description": "Double-SHA256 hash instead of just the default single round."
+ },
+ "compact_sig": {
+ "type": "boolean",
+ "description": "Use the compact (pubkey recoverable) format instead of the raw lnwire\nformat. This option cannot be used with Schnorr signatures."
+ },
+ "schnorr_sig": {
+ "type": "boolean",
+ "description": "Use Schnorr signature. This option cannot be used with compact format."
+ },
+ "schnorr_sig_tap_tweak": {
+ "type": "string",
+ "format": "byte",
+ "title": "The optional Taproot tweak bytes to apply to the private key before creating\na Schnorr signature. The private key is tweaked as described in BIP-341:\nprivKey + h_tapTweak(internalKey || tapTweak)"
+ },
+ "tag": {
+ "type": "string",
+ "format": "byte",
+ "description": "An optional tag that can be provided when taking a tagged hash of a\nmessage. This option can only be used when schnorr_sig is true."
+ }
+ }
+ },
+ "signrpcSignMessageResp": {
+ "type": "object",
+ "properties": {
+ "signature": {
+ "type": "string",
+ "format": "byte",
+ "description": "The signature for the given message in the fixed-size LN wire format."
+ }
+ }
+ },
+ "signrpcTaprootTweakDesc": {
+ "type": "object",
+ "properties": {
+ "script_root": {
+ "type": "string",
+ "format": "byte",
+ "description": "The root hash of the tapscript tree if a script path is committed to. If\nthe MuSig2 key put on chain doesn't also commit to a script path (BIP-0086\nkey spend only), then this needs to be empty and the key_spend_only field\nbelow must be set to true. This is required because gRPC cannot\ndifferentiate between a zero-size byte slice and a nil byte slice (both\nwould be serialized the same way). So the extra boolean is required."
+ },
+ "key_spend_only": {
+ "type": "boolean",
+ "description": "Indicates that the above script_root is expected to be empty because this\nis a BIP-0086 key spend only commitment where only the internal key is\ncommitted to instead of also including a script root hash."
+ }
+ }
+ },
+ "signrpcTweakDesc": {
+ "type": "object",
+ "properties": {
+ "tweak": {
+ "type": "string",
+ "format": "byte",
+ "description": "Tweak is the 32-byte value that will modify the public key."
+ },
+ "is_x_only": {
+ "type": "boolean",
+ "description": "Specifies if the target key should be converted to an x-only public key\nbefore tweaking. If true, then the public key will be mapped to an x-only\nkey before the tweaking operation is applied."
+ }
+ }
+ },
+ "walletrpcSignPsbtRequest": {
+ "type": "object",
+ "properties": {
+ "funded_psbt": {
+ "type": "string",
+ "format": "byte",
+ "description": "The PSBT that should be signed. The PSBT must contain all required inputs,\noutputs, UTXO data and custom fields required to identify the signing key."
+ }
+ }
+ },
+ "walletrpcSignPsbtResponse": {
+ "type": "object",
+ "properties": {
+ "signed_psbt": {
+ "type": "string",
+ "format": "byte",
+ "description": "The signed transaction in PSBT format."
+ },
+ "signed_inputs": {
+ "type": "array",
+ "items": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "description": "The indices of signed inputs."
+ }
+ }
+ },
+ "watchonlyrpcRegistrationComplete": {
+ "type": "object",
+ "properties": {
+ "signature": {
+ "type": "string",
+ "description": "Holds the signature generated by the watch-only node when signing the\nregistration_challenge provided by the remote signer in SignerRegistration."
+ },
+ "registration_info": {
+ "type": "string",
+ "description": "Contains information about the watch-only lnd that may be useful for the\nremote signer."
+ }
+ }
+ },
+ "watchonlyrpcRegistrationResponse": {
+ "type": "object",
+ "properties": {
+ "registration_complete": {
+ "$ref": "#/definitions/watchonlyrpcRegistrationComplete",
+ "description": "Sent by the watch-only lnd when the remote signer registration is\nsuccessful."
+ },
+ "registration_error": {
+ "type": "string",
+ "description": "Contains details about any errors that occurred during remote signer\nregistration."
+ }
+ }
+ },
+ "watchonlyrpcSignCoordinatorRequest": {
+ "type": "object",
+ "properties": {
+ "request_id": {
+ "type": "string",
+ "format": "uint64",
+ "description": "A unique request ID of a SignCoordinator gRPC request. Useful for mapping\nrequests to responses.\nNote that request_id 1 is reserved for the handshake with between\nwatch-only node and the remote signer."
+ },
+ "registration_response": {
+ "$ref": "#/definitions/watchonlyrpcRegistrationResponse",
+ "description": "The Registration Response message is returned by the watch-only lnd as\na response to SignerRegistration message."
+ },
+ "ping": {
+ "type": "boolean",
+ "description": "To ensure that the remote signer is still active and alive, the\nwatch-only lnd can send a Ping message to the remote signer, which\nshould then respond with the respective Pong message."
+ },
+ "shared_key_request": {
+ "$ref": "#/definitions/signrpcSharedKeyRequest",
+ "description": "Requests a shared public key from the remote signer."
+ },
+ "sign_message_req": {
+ "$ref": "#/definitions/signrpcSignMessageReq",
+ "description": "Requests that the remote signer signs the passed message."
+ },
+ "mu_sig2_session_request": {
+ "$ref": "#/definitions/signrpcMuSig2SessionRequest",
+ "description": "Requests a MuSig2 Session of the remote signer."
+ },
+ "mu_sig2_register_nonces_request": {
+ "$ref": "#/definitions/signrpcMuSig2RegisterNoncesRequest",
+ "description": "Requests that the remote signer registers a nonce with the referenced\nMuSig2 Session."
+ },
+ "mu_sig2_combined_nonces_req": {
+ "$ref": "#/definitions/signrpcMuSig2RegisterCombinedNonceRequest",
+ "description": "Requests that the remote signer registers a combined nonce with the\nreferenced MuSig2 Session."
+ },
+ "mu_sig2_get_combined_nonces_req": {
+ "$ref": "#/definitions/signrpcMuSig2GetCombinedNonceRequest",
+ "description": "Requests that the remote signer gets a combined nonce with the\nreferenced MuSig2 Session."
+ },
+ "mu_sig2_sign_request": {
+ "$ref": "#/definitions/signrpcMuSig2SignRequest",
+ "description": "Requests that the remote signer signs the passed message digest with\nthe referenced MuSig2 Session."
+ },
+ "mu_sig2_combine_sig_request": {
+ "$ref": "#/definitions/signrpcMuSig2CombineSigRequest",
+ "description": "Requests that the remote signer combines and adds the passed partial\nsignatures for the referenced MuSig2 Session."
+ },
+ "mu_sig2_cleanup_request": {
+ "$ref": "#/definitions/signrpcMuSig2CleanupRequest",
+ "description": "Requests that the remote signer removes/cleans up the referenced\nMuSig2 session."
+ },
+ "sign_psbt_request": {
+ "$ref": "#/definitions/walletrpcSignPsbtRequest",
+ "description": "Requests that the remote signer signs the passed PSBT."
+ }
+ }
+ },
+ "watchonlyrpcSignCoordinatorResponse": {
+ "type": "object",
+ "properties": {
+ "ref_request_id": {
+ "type": "string",
+ "format": "uint64",
+ "description": "The request ID this response refers to."
+ },
+ "signer_registration": {
+ "$ref": "#/definitions/watchonlyrpcSignerRegistration",
+ "description": "The Signer Registration message is sent by the remote signer when it\nconnects to the watch-only lnd node, to initialize a handshake between\nthe nodes."
+ },
+ "pong": {
+ "type": "boolean",
+ "description": "To ensure that the remote signer is still active and alive, the\nwatch-only node can send a Ping message to remote signer. This Pong\nmessage should then be sent by the remote signer to respond to the Ping\nmessage."
+ },
+ "shared_key_response": {
+ "$ref": "#/definitions/signrpcSharedKeyResponse",
+ "description": "The remote signer's corresponding response to a Shared Key request."
+ },
+ "sign_message_resp": {
+ "$ref": "#/definitions/signrpcSignMessageResp",
+ "description": "The remote signer's corresponding response to a Sign Message request."
+ },
+ "mu_sig2_session_response": {
+ "$ref": "#/definitions/signrpcMuSig2SessionResponse",
+ "description": "The remote signer's corresponding response to a Mu Sig2 Session\nrequest."
+ },
+ "mu_sig2_register_nonces_response": {
+ "$ref": "#/definitions/signrpcMuSig2RegisterNoncesResponse",
+ "description": "The remote signer's corresponding response to a Mu Sig2 Register Nonces\nrequest."
+ },
+ "mu_sig2_comb_nonces_resp": {
+ "$ref": "#/definitions/signrpcMuSig2RegisterCombinedNonceResponse",
+ "description": "The remote signer's corresponding response to a Mu Sig2 Register\nCombined Nonces request."
+ },
+ "mu_sig2_get_comb_nonces_resp": {
+ "$ref": "#/definitions/signrpcMuSig2GetCombinedNonceResponse",
+ "description": "The remote signer's corresponding response to a Mu Sig2 Get Combined\nNonces request."
+ },
+ "mu_sig2_sign_response": {
+ "$ref": "#/definitions/signrpcMuSig2SignResponse",
+ "description": "The remote signer's corresponding response to a Mu Sig2 Sign request."
+ },
+ "mu_sig2_combine_sig_response": {
+ "$ref": "#/definitions/signrpcMuSig2CombineSigResponse",
+ "description": "The remote signer's corresponding response to a Mu Sig2 Combine Sig\nrequest."
+ },
+ "mu_sig2_cleanup_response": {
+ "$ref": "#/definitions/signrpcMuSig2CleanupResponse",
+ "description": "The remote signer's corresponding response to a Mu Sig2 Cleanup\nrequest."
+ },
+ "sign_psbt_response": {
+ "$ref": "#/definitions/walletrpcSignPsbtResponse",
+ "description": "The remote signer's corresponding response to a Sign Psbt request."
+ },
+ "signer_error": {
+ "$ref": "#/definitions/watchonlyrpcSignerError",
+ "description": "If the remote signer encounters an error while processing a request, it\nwill respond with a SignerError message that details the error."
+ }
+ }
+ },
+ "watchonlyrpcSignerError": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string",
+ "description": "Details an error which occurred on remote signer."
+ }
+ }
+ },
+ "watchonlyrpcSignerRegistration": {
+ "type": "object",
+ "properties": {
+ "registration_challenge": {
+ "type": "string",
+ "format": "byte",
+ "description": "The registration challenge allows the remote signer to pass data that will\nbe signed by the watch-only lnd. The resulting signature will be returned in\nthe RegistrationResponse message."
+ },
+ "registration_info": {
+ "type": "string",
+ "description": "The registration info contains details about the remote signer that may be\nuseful for the watch-only lnd."
+ }
+ }
+ }
+ }
+}
### lnrpc/watchonlyrpc/watchonly.yaml
@@ -0,0 +1,8 @@
+type: google.api.Service
+config_version: 3
+
+http:
+ rules:
+ - selector: watchonlyrpc.WatchOnly.SignCoordinatorStreams
+ post: "/v2/watchonly/stream"
+ body: "*"
### lnrpc/watchonlyrpc/watchonly_grpc.pb.go
@@ -0,0 +1,139 @@
+// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
+
+package watchonlyrpc
+
+import (
+ context "context"
+ grpc "google.golang.org/grpc"
+ codes "google.golang.org/grpc/codes"
+ status "google.golang.org/grpc/status"
+)
+
+// This is a compile-time assertion to ensure that this generated file
+// is compatible with the grpc package it is being compiled against.
+// Requires gRPC-Go v1.32.0 or later.
+const _ = grpc.SupportPackageIsVersion7
+
+// WatchOnlyClient is the client API for WatchOnly service.
+//
+// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
+type WatchOnlyClient interface {
+ // SignCoordinatorStreams dispatches a bi-directional streaming RPC that
+ // allows a remote signer to connect to a watch-only node and remotely
+ // provide signatures and ECDH results on demand.
+ SignCoordinatorStreams(ctx context.Context, opts ...grpc.CallOption) (WatchOnly_SignCoordinatorStreamsClient, error)
+}
+
+type watchOnlyClient struct {
+ cc grpc.ClientConnInterface
+}
+
+func NewWatchOnlyClient(cc grpc.ClientConnInterface) WatchOnlyClient {
+ return &watchOnlyClient{cc}
+}
+
+func (c *watchOnlyClient) SignCoordinatorStreams(ctx context.Context, opts ...grpc.CallOption) (WatchOnly_SignCoordinatorStreamsClient, error) {
+ stream, err := c.cc.NewStream(ctx, &WatchOnly_ServiceDesc.Streams[0], "/watchonlyrpc.WatchOnly/SignCoordinatorStreams", opts...)
+ if err != nil {
+ return nil, err
+ }
+ x := &watchOnlySignCoordinatorStreamsClient{stream}
+ return x, nil
+}
+
+type WatchOnly_SignCoordinatorStreamsClient interface {
+ Send(*SignCoordinatorResponse) error
+ Recv() (*SignCoordinatorRequest, error)
+ grpc.ClientStream
+}
+
+type watchOnlySignCoordinatorStreamsClient struct {
+ grpc.ClientStream
+}
+
+func (x *watchOnlySignCoordinatorStreamsClient) Send(m *SignCoordinatorResponse) error {
+ return x.ClientStream.SendMsg(m)
+}
+
+func (x *watchOnlySignCoordinatorStreamsClient) Recv() (*SignCoordinatorRequest, error) {
+ m := new(SignCoordinatorRequest)
+ if err := x.ClientStream.RecvMsg(m); err != nil {
+ return nil, err
+ }
+ return m, nil
+}
+
+// WatchOnlyServer is the server API for WatchOnly service.
+// All implementations must embed UnimplementedWatchOnlyServer
+// for forward compatibility
+type WatchOnlyServer interface {
+ // SignCoordinatorStreams dispatches a bi-directional streaming RPC that
+ // allows a remote signer to connect to a watch-only node and remotely
+ // provide signatures and ECDH results on demand.
+ SignCoordinatorStreams(WatchOnly_SignCoordinatorStreamsServer) error
+ mustEmbedUnimplementedWatchOnlyServer()
+}
+
+// UnimplementedWatchOnlyServer must be embedded to have forward compatible implementations.
+type UnimplementedWatchOnlyServer struct {
+}
+
+func (UnimplementedWatchOnlyServer) SignCoordinatorStreams(WatchOnly_SignCoordinatorStreamsServer) error {
+ return status.Errorf(codes.Unimplemented, "method SignCoordinatorStreams not implemented")
+}
+func (UnimplementedWatchOnlyServer) mustEmbedUnimplementedWatchOnlyServer() {}
+
+// UnsafeWatchOnlyServer may be embedded to opt out of forward compatibility for this service.
+// Use of this interface is not recommended, as added methods to WatchOnlyServer will
+// result in compilation errors.
+type UnsafeWatchOnlyServer interface {
+ mustEmbedUnimplementedWatchOnlyServer()
+}
+
+func RegisterWatchOnlyServer(s grpc.ServiceRegistrar, srv WatchOnlyServer) {
+ s.RegisterService(&WatchOnly_ServiceDesc, srv)
+}
+
+func _WatchOnly_SignCoordinatorStreams_Handler(srv interface{}, stream grpc.ServerStream) error {
+ return srv.(WatchOnlyServer).SignCoordinatorStreams(&watchOnlySignCoordinatorStreamsServer{stream})
+}
+
+type WatchOnly_SignCoordinatorStreamsServer interface {
+ Send(*SignCoordinatorRequest) error
+ Recv() (*SignCoordinatorResponse, error)
+ grpc.ServerStream
+}
+
+type watchOnlySignCoordinatorStreamsServer struct {
+ grpc.ServerStream
+}
+
+func (x *watchOnlySignCoordinatorStreamsServer) Send(m *SignCoordinatorRequest) error {
+ return x.ServerStream.SendMsg(m)
+}
+
+func (x *watchOnlySignCoordinatorStreamsServer) Recv() (*SignCoordinatorResponse, error) {
+ m := new(SignCoordinatorResponse)
+ if err := x.ServerStream.RecvMsg(m); err != nil {
+ return nil, err
+ }
+ return m, nil
+}
+
+// WatchOnly_ServiceDesc is the grpc.ServiceDesc for WatchOnly service.
+// It's only intended for direct use with grpc.RegisterService,
+// and not to be introspected or modified (even as a copy)
+var WatchOnly_ServiceDesc = grpc.ServiceDesc{
+ ServiceName: "watchonlyrpc.WatchOnly",
+ HandlerType: (*WatchOnlyServer)(nil),
+ Methods: []grpc.MethodDesc{},
+ Streams: []grpc.StreamDesc{
+ {
+ StreamName: "SignCoordinatorStreams",
+ Handler: _WatchOnly_SignCoordinatorStreams_Handler,
+ ServerStreams: true,
+ ClientStreams: true,
+ },
+ },
+ Metadata: "watchonlyrpc/watchonly.proto",
+}
### lntest/harness.go
@@ -244,7 +244,7 @@ func (h *HarnessTest) setupWatchOnlyNode(name string,
name)
// Create a new watch-only node with remote signer configuration.
- return h.NewNodeRemoteSigner(
+ return h.NewNodeWatchOnly(
name, remoteSignerArgs, password,
&lnrpc.WatchOnly{
MasterKeyBirthdayTimestamp: 0,
@@ -769,15 +769,35 @@ func (h *HarnessTest) NewNodeWithSeedEtcd(name string, etcdCfg *etcd.Config,
return h.newNodeWithSeed(name, extraArgs, req, statelessInit)
}
-// NewNodeRemoteSigner creates a new remote signer node and asserts its
+// NewNodeWatchOnly creates a new watch-only node and asserts its
// creation.
-func (h *HarnessTest) NewNodeRemoteSigner(name string, extraArgs []string,
+func (h *HarnessTest) NewNodeWatchOnly(name string, extraArgs []string,
password []byte, watchOnly *lnrpc.WatchOnly) *node.HarnessNode {
- hn, err := h.manager.newNode(h.T, name, extraArgs, password, true)
+ hn := h.CreateNewNode(name, extraArgs, password, true)
+
+ h.StartWatchOnly(hn, name, password, watchOnly)
+
+ return hn
+}
+
+// CreateNodeWatchOnly creates a new node and asserts its creation. The function
+// will only create the node and will not start it.
+func (h *HarnessTest) CreateNewNode(name string, extraArgs []string,
+ password []byte, noAuth bool) *node.HarnessNode {
+
+ hn, err := h.manager.newNode(h.T, name, extraArgs, password, noAuth)
require.NoErrorf(h, err, "unable to create new node for %s", name)
- err = hn.StartWithNoAuth(h.runCtx)
+ return hn
+}
+
+// StartWatchOnly starts the passed node in watch-only mode. The function will
+// assert that the node is started and that the initialization is successful.
+func (h *HarnessTest) StartWatchOnly(hn *node.HarnessNode, name string,
+ password []byte, watchOnly *lnrpc.WatchOnly) {
+
+ err := hn.StartWithNoAuth(h.runCtx)
require.NoError(h, err, "failed to start node %s", name)
// With the seed created, construct the init request to the node,
@@ -791,8 +811,6 @@ func (h *HarnessTest) NewNodeRemoteSigner(name string, extraArgs []string,
// will also initialize the macaroon-authenticated LightningClient.
_, err = h.manager.initWalletAndNode(hn, initReq)
require.NoErrorf(h, err, "failed to init node %s", name)
-
- return hn
}
// KillNode kills the node and waits for the node process to stop.
### lntest/mock/walletcontroller.go
@@ -1,6 +1,7 @@
package mock
import (
+ "context"
"encoding/hex"
"sync/atomic"
"time"
@@ -295,6 +296,14 @@ func (w *WalletController) Stop() error {
return nil
}
+// ReadySignal currently signals that the wallet is ready instantly.
+func (w *WalletController) ReadySignal(_ context.Context) chan error {
+ readyChan := make(chan error, 1)
+ readyChan <- nil
+
+ return readyChan
+}
+
func (w *WalletController) FetchTx(chainhash.Hash) (*wire.MsgTx, error) {
return nil, nil
}
### lnwallet/btcwallet/btcwallet.go
@@ -413,6 +413,14 @@ func (b *BtcWallet) Stop() error {
return nil
}
+// ReadySignal currently signals that the wallet is ready instantly.
+func (b *BtcWallet) ReadySignal(_ context.Context) chan error {
+ readyChan := make(chan error, 1)
+ readyChan <- nil
+
+ return readyChan
+}
+
// ConfirmedBalance returns the sum of all the wallet's unspent outputs that
// have at least confs confirmations. If confs is set to zero, then all unspent
// outputs, including those currently in the mempool will be included in the
### lnwallet/interface.go
@@ -1,6 +1,7 @@
package lnwallet
import (
+ "context"
"errors"
"fmt"
"sync"
@@ -554,6 +555,10 @@ type WalletController interface {
// starting up required goroutines etc.
Start() error
+ // RequireSignal returns a channel which is sent over with no error,
+ // once the wallet is ready to be used.
+ ReadySignal(ctx context.Context) chan error
+
// Stop signals the wallet for shutdown. Shutdown may entail closing
// any active sockets, database handles, stopping goroutines, etc.
Stop() error
### lnwallet/mock.go
@@ -1,6 +1,7 @@
package lnwallet
import (
+ "context"
"encoding/hex"
"sync/atomic"
"time"
@@ -311,6 +312,14 @@ func (w *mockWalletController) Stop() error {
return nil
}
+// ReadySignal currently signals that the wallet is ready instantly.
+func (w *mockWalletController) ReadySignal(_ context.Context) chan error {
+ readyChan := make(chan error, 1)
+ readyChan <- nil
+
+ return readyChan
+}
+
func (w *mockWalletController) FetchTx(chainhash.Hash) (*wire.MsgTx, error) {
return nil, nil
}
### lnwallet/rpcwallet/healthcheck.go
@@ -1,32 +1,25 @@
package rpcwallet
import (
- "fmt"
+ "context"
"time"
-
- "github.com/lightningnetwork/lnd/lncfg"
)
// HealthCheck returns a health check function for the given remote signing
// configuration.
-func HealthCheck(cfg *lncfg.RemoteSigner, timeout time.Duration) func() error {
+func HealthCheck(ctx context.Context, timeout time.Duration, ping func(
+ ctx context.Context, duration time.Duration) error) func() error {
+
return func() error {
- conn, err := connectRPC(
- cfg.RPCHost, cfg.TLSCertPath, cfg.MacaroonPath, timeout,
- )
+ ctxt, cancel := context.WithTimeout(ctx, timeout)
+ defer cancel()
+
+ err := ping(ctxt, timeout)
if err != nil {
- return fmt.Errorf("error connecting to the remote "+
- "signing node through RPC: %v", err)
- }
+ log.Errorf("Remote signer health check failed: %v", err)
- defer func() {
- err = conn.Close()
- if err != nil {
- log.Warnf("Failed to close health check "+
- "connection to remote signing node: %v",
- err)
- }
- }()
+ return err
+ }
return nil
}
### lnwallet/rpcwallet/remote_signer_client.go
@@ -0,0 +1,923 @@
+package rpcwallet
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "os"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/btcsuite/btclog/v2"
+ "github.com/lightningnetwork/lnd/fn/v2"
+ "github.com/lightningnetwork/lnd/lncfg"
+ "github.com/lightningnetwork/lnd/lnrpc"
+ "github.com/lightningnetwork/lnd/lnrpc/signrpc"
+ "github.com/lightningnetwork/lnd/lnrpc/walletrpc"
+ "github.com/lightningnetwork/lnd/lnrpc/watchonlyrpc"
+ "github.com/lightningnetwork/lnd/macaroons"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/credentials"
+ "google.golang.org/protobuf/reflect/protoreflect"
+ "gopkg.in/macaroon.v2"
+)
+
+type (
+ // RSResponse is a type alias for the SignCoordinator response type,
+ // created to keep line length within 80 characters.
+ RSResponse = watchonlyrpc.SignCoordinatorResponse
+
+ // RSRegistrationResponse is a type alias for the registration response
+ // type, created to keep line length within 80 characters.
+ RSRegistrationResponse = watchonlyrpc.
+ SignCoordinatorRequest_RegistrationResponse
+
+ // RSRegistrationComplete is a type alias for the registration complete
+ // type, created to keep line length within 80 characters.
+ RSRegistrationComplete = watchonlyrpc.
+ RegistrationResponse_RegistrationComplete
+
+ // RSRegistration is a type alias for the signer registration type,
+ // created to keep line length within 80 characters.
+ RSRegistration = watchonlyrpc.SignCoordinatorResponse_SignerRegistration
+)
+
+var (
+ // ErrShuttingDown indicates that the server is in the process of
+ // gracefully exiting.
+ ErrShuttingDown = errors.New("lnd is shutting down")
+
+ // ErrRequestType is returned when the request type by the watch-only
+ // node has not been implemented by remote signer.
+ ErrRequestType = errors.New("unimplemented request by watch-only node")
+)
+
+const (
+ // defaultRetryTimeout is the default timeout used when retrying to
+ // connect to the watch-only node.
+ defaultRetryTimeout = time.Second * 1
+
+ // retryMultiplier is the multiplier used to increase the retry timeout
+ // for every retry.
+ retryMultiplier = 1.5
+
+ // defaultMaxRetryTimeout is the default max value for the
+ // maxRetryTimeout, which defines the maximum backoff period before
+ // attempting to reconnect to the watch-only node.
+ defaultMaxRetryTimeout = time.Minute * 1
+
+ // handshakeRequestID is the request ID reserved for the handshake with
+ // the watch-only node.
+ handshakeRequestID = uint64(1)
+)
+
+// Stream represents the stream to the watch-only node with a Close function
+// that closes the connection.
+type Stream struct {
+ StreamClient
+
+ // Close closes the connection to the watch-only node.
+ Close func() error
+}
+
+// NewStream creates a new Stream instance.
+func NewStream(client StreamClient, closeConn func() error) *Stream {
+ return &Stream{
+ StreamClient: client,
+ Close: closeConn,
+ }
+}
+
+// SignCoordinatorStreamFeeder is an interface that returns a newly created
+// stream to the watch-only node. The stream is used to send and receive
+// messages between the remote signer client and the watch-only node.
+type SignCoordinatorStreamFeeder interface {
+ // GetStream returns a new stream to the watch-only node. The function
+ // also returns a cleanup function that should be called when the stream
+ // is no longer needed.
+ GetStream(ctx context.Context) (*Stream, error)
+
+ // Stop stops the stream feeder.
+ Stop()
+}
+
+// RemoteSignerClient is an interface that defines the methods that a remote
+// signer client should implement.
+type RemoteSignerClient interface {
+ // Start starts the remote signer client.
+ Start(ctx context.Context) error
+
+ // Stop stops the remote signer client.
+ Stop() error
+
+ // MustImplementRemoteSignerClient is a no-op method that makes it
+ // easier to filter structs that implement the RemoteSignerClient
+ // interface.
+ MustImplementRemoteSignerClient()
+}
+
+// StreamFeeder is an implementation of the SignCoordinatorStreamFeeder
+// interface that creates a new stream to the watch-only node, by making an
+// outbound gRPC connection to the watch-only node.
+type StreamFeeder struct {
+ wg sync.WaitGroup
+
+ cfg lncfg.ConnectionCfg
+
+ cg *fn.ContextGuard
+}
+
+// NewStreamFeeder creates a new StreamFeeder instance.
+func NewStreamFeeder(cfg lncfg.ConnectionCfg) *StreamFeeder {
+ return &StreamFeeder{
+ cfg: cfg,
+ cg: fn.NewContextGuard(),
+ }
+}
+
+// Stop stops the StreamFeeder and disables the StreamFeeder from creating any
+// new connections.
+//
+// NOTE: This is part of the SignCoordinatorStreamFeeder interface.
+func (s *StreamFeeder) Stop() {
+ s.cg.Quit()
+
+ s.wg.Wait()
+}
+
+// GetStream returns a new stream to the watch-only node, by making an
+// outbound gRPC connection to the watch-only node. The function also returns a
+// cleanup function that closes the connection, which should be called when the
+// stream is no longer needed.
+//
+// NOTE: This is part of the SignCoordinatorStreamFeeder interface.
+func (s *StreamFeeder) GetStream(ctx context.Context) (*Stream, error) {
+ select {
+ // Don't run if the StreamFeeder has already been shutdown.
+ case <-s.cg.Done():
+ return nil, ErrShuttingDown
+ default:
+ }
+
+ // Create a new outbound gRPC connection to the watch-only node.
+ conn, err := s.getClientConn(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ // Wrap the connection in a RemoteSigner stream client.
+ watchOnlyClient := watchonlyrpc.NewWatchOnlyClient(conn)
+
+ // Create a new stream to the watch-only node.
+ streamClient, err := watchOnlyClient.SignCoordinatorStreams(ctx)
+ if err != nil {
+ connErr := conn.Close()
+ if connErr != nil {
+ log.ErrorS(ctx, "Unable to close watch-only node "+
+ "connection: %v", connErr)
+ }
+
+ return nil, err
+ }
+
+ return NewStream(streamClient, conn.Close), nil
+}
+
+// getClientConn creates a new outbound gRPC connection to the watch-only node.
+func (s *StreamFeeder) getClientConn(
+ ctx context.Context) (*grpc.ClientConn, error) {
+
+ // Ensure that our top level ctx is derived from the context guard.
+ // That way we know that we only need to select on the context guard's
+ // Done channel if the remote signer client is shutting down.
+ // If we fail to connect to the watch-only node within the
+ // configured timeout we should return an error.
+ ctx, cancel := s.cg.Create(ctx, fn.WithCustomTimeoutCG(s.cfg.Timeout))
+ defer cancel()
+
+ // Load the specified macaroon file for the watch-only node.
+ macBytes, err := os.ReadFile(s.cfg.MacaroonPath)
+ if err != nil {
+ return nil, fmt.Errorf("could not read macaroon file: %w", err)
+ }
+
+ mac := &macaroon.Macaroon{}
+
+ err = mac.UnmarshalBinary(macBytes)
+ if err != nil {
+ return nil, fmt.Errorf("could not unmarshal macaroon: %w", err)
+ }
+
+ macCred, err := macaroons.NewMacaroonCredential(mac)
+ if err != nil {
+ return nil, fmt.Errorf(
+ "could not create macaroon credential: %w", err)
+ }
+
+ // Load the specified TLS cert for the watch-only node.
+ tlsCreds, err := credentials.NewClientTLSFromFile(s.cfg.TLSCertPath, "")
+ if err != nil {
+ return nil, fmt.Errorf("could not load TLS cert: %w", err)
+ }
+
+ opts := []grpc.DialOption{
+ grpc.WithBlock(),
+ grpc.WithTransportCredentials(tlsCreds),
+ grpc.WithPerRPCCredentials(macCred),
+ }
+
+ log.InfoS(ctx, "Attempting to connect to the watch-only node",
+ slog.String("rpc_host", s.cfg.RPCHost))
+
+ // Connect to the watch-only node using the new context.
+ return grpc.DialContext(ctx, s.cfg.RPCHost, opts...)
+}
+
+// A compile time assertion to ensure StreamFeeder meets the
+// SignCoordinatorStreamFeeder interface.
+var _ SignCoordinatorStreamFeeder = (*StreamFeeder)(nil)
+
+// NoOpClient is a remote signer client that is a no op, and is used when the
+// configuration doesn't enable the use of a remote signer client.
+type NoOpClient struct{}
+
+// Start is a no-op.
+//
+// NOTE: Part of the RemoteSignerClient interface.
+func (n *NoOpClient) Start(ctx context.Context) error {
+ return nil
+}
+
+// Stop is a no-op.
+//
+// NOTE: Part of the RemoteSignerClient interface.
+func (n *NoOpClient) Stop() error {
+ return nil
+}
+
+// MustImplementRemoteSignerClient is a no-op.
+//
+// NOTE: Part of the RemoteSignerClient interface.
+func (n *NoOpClient) MustImplementRemoteSignerClient() {}
+
+// A compile time assertion to ensure NoOpClient meets the
+// RemoteSignerClient interface.
+var _ RemoteSignerClient = (*NoOpClient)(nil)
+
+// OutboundClient is a remote signer client which will process and respond to
+// sign requests from the watch-only node, which are sent over a stream between
+// the node and a watch-only node.
+type OutboundClient struct {
+ stopped atomic.Bool
+
+ log btclog.Logger
+
+ // walletServer is the WalletKitServer that the remote signer client
+ // will use to process walletrpc requests.
+ walletServer walletrpc.WalletKitServer
+
+ // signerServer is the SignerServer that the remote signer client will
+ // use to process signrpc requests.
+ signerServer signrpc.SignerServer
+
+ // streamFeeder is the stream feeder that will set up a stream to the
+ // watch-only node when requested to do so by the remote signer client.
+ streamFeeder SignCoordinatorStreamFeeder
+
+ // requestTimeout is the timeout used when sending responses to the
+ // watch-only node.
+ requestTimeout time.Duration
+
+ // maxRetryTimeout is the max value for the retry timeout, defining
+ // the maximum backoff period before attempting to reconnect to the
+ // watch-only node.
+ maxRetryTimeout time.Duration
+
+ cg *fn.ContextGuard
+ gManager *fn.GoroutineManager
+}
+
+// NewOutboundClient creates a new instance of the remote signer client.
+// The passed subServers need to include a walletrpc.WalletKitServer and a
+// signrpc.SignerServer, or the OutboundClient will be disabled.
+// Note that the client will only fully start if the configuration
+// enables an outbound remote signer.
+func NewOutboundClient(walletServer walletrpc.WalletKitServer,
+ signerServer signrpc.SignerServer,
+ streamFeeder SignCoordinatorStreamFeeder,
+ requestTimeout time.Duration) (*OutboundClient, error) {
+
+ if walletServer == nil || signerServer == nil {
+ return nil, errors.New("sub-servers cannot be nil when using " +
+ "an outbound remote signer")
+ }
+
+ if streamFeeder == nil {
+ return nil, errors.New("streamFeeder cannot be nil")
+ }
+
+ return &OutboundClient{
+ log: log.WithPrefix("Remote signer client: "),
+ walletServer: walletServer,
+ signerServer: signerServer,
+ streamFeeder: streamFeeder,
+ requestTimeout: requestTimeout,
+ maxRetryTimeout: defaultMaxRetryTimeout,
+ cg: fn.NewContextGuard(),
+ gManager: fn.NewGoroutineManager(),
+ }, nil
+}
+
+// Start starts the remote signer client. The function will continuously try to
+// set up a connection to the configured watch-only node, and retry to connect
+// if the connection fails until we Stop the remote signer client.
+//
+// NOTE: Part of the RemoteSignerClient interface.
+func (r *OutboundClient) Start(ctx context.Context) error {
+ // Ensure that our top level ctx is derived from the context guard.
+ // That way we know that we only need to select on the context guard's
+ // Done channel if the remote signer client is shutting down.
+ ctx, _ = r.cg.Create(ctx)
+
+ success := r.gManager.Go(ctx, r.runForever)
+ if !success {
+ return errors.New("failed to start remote signer client")
+ }
+
+ return nil
+}
+
+// runForever continuously tries to set up a connection to the watch-only node,
+// and retry to connect if the connection fails until we Stop the remote
+// signer client.
+func (r *OutboundClient) runForever(ctx context.Context) {
+ // retryTimeout is the current backoff timeout used when retrying to set
+ // up a connection to the watch-only node, if the previous
+ // connection/attempt failed. The variable is reset to the
+ // defaultRetryTimeout once a successful connection is set up with the
+ // watch-only node.
+ retryTimeout := defaultRetryTimeout
+
+ for {
+ // Check if we are shutting down.
+ select {
+ case <-ctx.Done():
+ return
+ default:
+ }
+
+ connected, err := r.runOnce(ctx)
+ if err != nil {
+ r.log.ErrorS(ctx, "runOnce error", err)
+ }
+
+ // Reset the retry timeout after a successful connection.
+ if connected {
+ retryTimeout = defaultRetryTimeout
+ }
+
+ r.log.InfoS(
+ ctx,
+ "Connection retry to watch-only node scheduled",
+ "retry_after", retryTimeout,
+ )
+
+ // Backoff before retrying to connect to the watch-only node.
+ select {
+ case <-ctx.Done():
+ return
+ case <-time.After(retryTimeout):
+ }
+
+ r.log.InfoS(ctx, "Retrying to connect to watch-only node")
+
+ // Increase the retry timeout by 50% for every retry.
+ retryTimeout = time.Duration(
+ float64(retryTimeout) * retryMultiplier,
+ )
+
+ // But cap the retryTimeout at r.maxRetryTimeout.
+ if retryTimeout > r.maxRetryTimeout {
+ retryTimeout = r.maxRetryTimeout
+ }
+ }
+}
+
+// Stop stops the remote signer client.
+//
+// NOTE: Part of the RemoteSignerClient interface.
+func (r *OutboundClient) Stop() error {
+ if r.stopped.Swap(true) {
+ return errors.New("remote signer client is already shut down")
+ }
+
+ r.log.Info("Shutting down")
+
+ r.cg.Quit()
+
+ r.streamFeeder.Stop()
+
+ r.gManager.Stop()
+
+ r.log.Debugf("Shutdown complete")
+
+ return nil
+}
+
+// MustImplementRemoteSignerClient is a no-op.
+//
+// NOTE: Part of the RemoteSignerClient interface.
+func (r *OutboundClient) MustImplementRemoteSignerClient() {}
+
+// runOnce creates a new stream to the watch-only node, and starts processing
+// and responding to the sign requests that are sent over the stream. The
+// function will continuously run until the remote signer client is either
+// stopped or the stream errors.
+func (r *OutboundClient) runOnce(ctx context.Context) (bool, error) {
+ // Derive a context for the lifetime of the stream.
+ ctx, cancel := r.cg.Create(ctx)
+
+ // Cancel the stream context whenever we return from this function.
+ defer cancel()
+
+ log.InfoS(ctx, "Attempting to setup the watch-only node connection")
+
+ // Try to get a new stream to the watch-only node.
+ stream, err := r.streamFeeder.GetStream(ctx)
+ if err != nil {
+ return false, err
+ }
+ defer func() {
+ err := stream.Close()
+ if err != nil {
+ log.ErrorS(ctx, "Unable to close watch-only node "+
+ "connection", err)
+ }
+ }()
+
+ // Once the stream has been created, we'll need to perform the handshake
+ // process with the watch-only node, before it will start sending us
+ // requests.
+ err = r.handshake(ctx, stream)
+ if err != nil {
+ return false, err
+ }
+
+ log.InfoS(ctx, "Completed setup connection to watch-only node")
+
+ return true, r.processSignRequestsForever(ctx, stream)
+}
+
+// handshake performs the handshake process with the watch-only node. As we are
+// the initiator of the stream, we need to send the first message over the
+// stream. The watch-only node will only proceed to sending us requests after
+// the handshake has been completed.
+func (r *OutboundClient) handshake(ctx context.Context, stream *Stream) error {
+ // Derive a context that times out the handshake process, if it takes
+ // longer than the request timeout.
+ ctxt, cancel := context.WithTimeout(ctx, r.requestTimeout)
+ defer cancel()
+
+ var (
+ msg *watchonlyrpc.SignCoordinatorRequest
+ errChan = make(chan error, 1)
+ )
+
+ // registrationMsg is the message that we send to the watch-only node to
+ // initiate the handshake process.
+ // TODO(viktor): The current authentication model is TLS + macaroon.
+ // This message could later be extended with version information and a
+ // randomized RegistrationChallenge as part of a future mutual-auth
+ // extension, but it does not currently provide signer identity
+ // verification on its own.
+ var registrationMsg = &watchonlyrpc.SignCoordinatorResponse{
+ RefRequestId: handshakeRequestID,
+ SignResponseType: &RSRegistration{
+ SignerRegistration: &watchonlyrpc.SignerRegistration{
+ RegistrationChallenge: []byte(
+ "registrationChallenge",
+ ),
+ RegistrationInfo: "outboundSigner",
+ },
+ },
+ }
+
+ ok := r.gManager.Go(ctxt, func(_ context.Context) {
+ // Send the registration message to the watch-only node.
+ err := stream.Send(registrationMsg)
+ if err != nil {
+ errChan <- err
+ return
+ }
+
+ // After the registration message has been sent, the signer node
+ // will respond with a message indicating that it has accepted
+ // the signer registration request if the registration was
+ // successful.
+ msg, err = stream.Recv()
+ errChan <- err
+ })
+ if !ok {
+ return fmt.Errorf("error sending registration message")
+ }
+
+ // Wait for the response.
+ select {
+ case <-ctxt.Done():
+ return ctxt.Err()
+ case err := <-errChan:
+ if err != nil {
+ return fmt.Errorf("handshake error: %w", err)
+ }
+ }
+
+ // Verify that the request ID of the response is the same as the
+ // request ID of the registration message.
+ if msg.GetRequestId() != handshakeRequestID {
+ return fmt.Errorf("initial response request id must "+
+ "be %d, but is: %d", handshakeRequestID,
+ msg.GetRequestId())
+ }
+
+ // Check the type of the response message.
+ resp, ok := msg.GetSignRequestType().(*RSRegistrationResponse)
+ if !ok {
+ return fmt.Errorf("expected registration response, but got: %T",
+ msg.GetSignRequestType())
+ }
+
+ switch rType := resp.RegistrationResponse.
+ GetRegistrationResponseType().(type) {
+ // The registration was successful.
+ case *RSRegistrationComplete:
+ // TODO(viktor): This should verify that the signature in the
+ // complete message is valid.
+ return nil
+
+ // An error occurred during the registration process.
+ case *watchonlyrpc.RegistrationResponse_RegistrationError:
+ return fmt.Errorf("registration error: %s",
+ rType.RegistrationError)
+
+ default:
+ return fmt.Errorf("unknown registration response type: %T",
+ resp.RegistrationResponse.GetRegistrationResponseType())
+ }
+}
+
+// processSignRequestsForever processes and responds to the sign requests tha
+// are sent over the stream. The function will continuously run until the
+// remote signer client is either stopped or the stream errors.
+func (r *OutboundClient) processSignRequestsForever(ctx context.Context,
+ stream *Stream) error {
+
+ for {
+ err := r.processSingleSignReq(ctx, stream)
+ if err != nil {
+ return err
+ }
+
+ select {
+ case <-ctx.Done():
+ return ErrShuttingDown
+ default:
+ }
+ }
+}
+
+// processSingleSignReq waits for and processes a single request from the
+// watch-only node, and sends the corresponding response back.
+func (r *OutboundClient) processSingleSignReq(ctx context.Context,
+ stream *Stream) error {
+
+ // Wait for a request from the watch-only node.
+ req, err := r.waitForRequest(ctx, stream)
+ if err != nil {
+ return err
+ }
+
+ // Process the received request.
+ resp := r.formResponse(ctx, req)
+
+ // Send the response back to the watch-only node.
+ return r.sendResponse(ctx, resp, stream)
+}
+
+// waitForRequest waits for a request from the watch-only node.
+func (r *OutboundClient) waitForRequest(ctx context.Context, stream *Stream) (
+ *watchonlyrpc.SignCoordinatorRequest, error) {
+
+ var (
+ req *watchonlyrpc.SignCoordinatorRequest
+ err error
+ errChan = make(chan error, 1)
+ )
+
+ // We run the stream.Recv() in a goroutine to ensure we can stop if the
+ // remote signer client is shutting down (i.e. the quit channel is
+ // closed). Shutting down the remote signer client will cancel the ctx,
+ // which will cancel the stream context, which in turn will stop the
+ // goroutine.
+ ok := r.gManager.Go(ctx, func(_ context.Context) {
+ req, err = stream.Recv()
+ errChan <- err
+ })
+ if !ok {
+ return nil, fmt.Errorf("error receiving request")
+ }
+
+ // Wait for the response and then handle it.
+ select {
+ case <-ctx.Done():
+ return nil, ctx.Err()
+
+ case err := <-errChan:
+ if err != nil {
+ return nil, fmt.Errorf("error receiving request: %w",
+ err)
+ }
+ }
+
+ return req, nil
+}
+
+// formResponse processes the received request from the watch-only node, and
+// sends the corresponding response back.
+func (r *OutboundClient) formResponse(ctx context.Context,
+ req *watchonlyrpc.SignCoordinatorRequest) *RSResponse {
+
+ resp, err := r.process(ctx, req)
+ if err != nil {
+ r.log.ErrorS(ctx, "could not process request", err)
+
+ // If we fail to process the request, we will send a SignerError
+ // back to the watch-only node, indicating the nature of the
+ // error.
+ eType := &watchonlyrpc.SignCoordinatorResponse_SignerError{
+ SignerError: &watchonlyrpc.SignerError{
+ Error: "error processing the request in the " +
+ "remote signer: " + err.Error(),
+ },
+ }
+
+ resp = &RSResponse{
+ RefRequestId: req.GetRequestId(),
+ SignResponseType: eType,
+ }
+ }
+
+ return resp
+}
+
+// process sends the passed request on to the appropriate server for processing
+// it, and returns the response.
+func (r *OutboundClient) process(ctx context.Context,
+ req *watchonlyrpc.SignCoordinatorRequest) (*RSResponse, error) {
+
+ r.log.DebugS(ctx, "Processing a request from watch-only",
+ btclog.Fmt("request_type", "%T", req.GetSignRequestType()))
+
+ r.log.TraceS(ctx, "Request content",
+ "content", formatSignCoordinatorMsg(req))
+
+ var (
+ requestID = req.GetRequestId()
+ signResp = &RSResponse{
+ RefRequestId: requestID,
+ }
+ )
+
+ //nolint:ll
+ switch reqType := req.GetSignRequestType().(type) {
+ case *watchonlyrpc.SignCoordinatorRequest_SharedKeyRequest:
+ resp, err := r.signerServer.DeriveSharedKey(
+ ctx, reqType.SharedKeyRequest,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ rType := &watchonlyrpc.SignCoordinatorResponse_SharedKeyResponse{
+ SharedKeyResponse: resp,
+ }
+
+ signResp.SignResponseType = rType
+
+ return signResp, nil
+
+ case *watchonlyrpc.SignCoordinatorRequest_SignMessageReq:
+ resp, err := r.signerServer.SignMessage(
+ ctx, reqType.SignMessageReq,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ rType := &watchonlyrpc.SignCoordinatorResponse_SignMessageResp{
+ SignMessageResp: resp,
+ }
+
+ signResp.SignResponseType = rType
+
+ return signResp, nil
+
+ case *watchonlyrpc.SignCoordinatorRequest_MuSig2SessionRequest:
+ resp, err := r.signerServer.MuSig2CreateSession(
+ ctx, reqType.MuSig2SessionRequest,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ rType := &watchonlyrpc.SignCoordinatorResponse_MuSig2SessionResponse{
+ MuSig2SessionResponse: resp,
+ }
+
+ signResp.SignResponseType = rType
+
+ return signResp, nil
+
+ case *watchonlyrpc.SignCoordinatorRequest_MuSig2RegisterNoncesRequest:
+ resp, err := r.signerServer.MuSig2RegisterNonces(
+ ctx, reqType.MuSig2RegisterNoncesRequest,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ rType := &watchonlyrpc.SignCoordinatorResponse_MuSig2RegisterNoncesResponse{
+ MuSig2RegisterNoncesResponse: resp,
+ }
+
+ signResp.SignResponseType = rType
+
+ return signResp, nil
+
+ case *watchonlyrpc.SignCoordinatorRequest_MuSig2CombinedNoncesReq:
+ resp, err := r.signerServer.MuSig2RegisterCombinedNonce(
+ ctx, reqType.MuSig2CombinedNoncesReq,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ rType := &watchonlyrpc.SignCoordinatorResponse_MuSig2CombNoncesResp{
+ MuSig2CombNoncesResp: resp,
+ }
+
+ signResp.SignResponseType = rType
+
+ return signResp, nil
+
+ case *watchonlyrpc.SignCoordinatorRequest_MuSig2GetCombinedNoncesReq:
+ resp, err := r.signerServer.MuSig2GetCombinedNonce(
+ ctx, reqType.MuSig2GetCombinedNoncesReq,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ rType := &watchonlyrpc.SignCoordinatorResponse_MuSig2GetCombNoncesResp{
+ MuSig2GetCombNoncesResp: resp,
+ }
+
+ signResp.SignResponseType = rType
+
+ return signResp, nil
+
+ case *watchonlyrpc.SignCoordinatorRequest_MuSig2SignRequest:
+ resp, err := r.signerServer.MuSig2Sign(
+ ctx, reqType.MuSig2SignRequest,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ rType := &watchonlyrpc.SignCoordinatorResponse_MuSig2SignResponse{
+ MuSig2SignResponse: resp,
+ }
+
+ signResp.SignResponseType = rType
+
+ return signResp, nil
+
+ case *watchonlyrpc.SignCoordinatorRequest_MuSig2CombineSigRequest:
+ resp, err := r.signerServer.MuSig2CombineSig(
+ ctx, reqType.MuSig2CombineSigRequest,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ rType := &watchonlyrpc.SignCoordinatorResponse_MuSig2CombineSigResponse{
+ MuSig2CombineSigResponse: resp,
+ }
+
+ signResp.SignResponseType = rType
+
+ return signResp, nil
+
+ case *watchonlyrpc.SignCoordinatorRequest_MuSig2CleanupRequest:
+ resp, err := r.signerServer.MuSig2Cleanup(
+ ctx, reqType.MuSig2CleanupRequest,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ rType := &watchonlyrpc.SignCoordinatorResponse_MuSig2CleanupResponse{
+ MuSig2CleanupResponse: resp,
+ }
+
+ signResp.SignResponseType = rType
+
+ return signResp, nil
+
+ case *watchonlyrpc.SignCoordinatorRequest_SignPsbtRequest:
+ resp, err := r.walletServer.SignPsbt(
+ ctx, reqType.SignPsbtRequest,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ rType := &watchonlyrpc.SignCoordinatorResponse_SignPsbtResponse{
+ SignPsbtResponse: resp,
+ }
+
+ signResp.SignResponseType = rType
+
+ return signResp, nil
+
+ case *watchonlyrpc.SignCoordinatorRequest_Ping:
+ // If the received request is a ping, we don't need to pass the
+ // request on to a server, but can respond with a pong directly.
+ rType := &watchonlyrpc.SignCoordinatorResponse_Pong{
+ Pong: true,
+ }
+
+ signResp.SignResponseType = rType
+
+ return signResp, nil
+
+ default:
+ return nil, ErrRequestType
+ }
+}
+
+// sendResponse sends the passed response back to the watch-only node over the
+// stream.
+func (r *OutboundClient) sendResponse(ctx context.Context, resp *RSResponse,
+ stream *Stream) error {
+
+ // Timeout sending the response if it takes too long.
+ ctxt, cancel := r.cg.Create(
+ ctx, fn.WithCustomTimeoutCG(r.requestTimeout),
+ )
+ defer cancel()
+
+ var errChan = make(chan error, 1)
+
+ // We send the response in a goroutine to ensure we can return an error
+ // if the send times out or we shut down. This is done to ensure that
+ // this function won't block indefinitely.
+ ok := r.gManager.Go(ctxt, func(ctxt context.Context) {
+ errChan <- stream.Send(resp)
+ })
+ if !ok {
+ return fmt.Errorf("error sending response")
+ }
+
+ select {
+ case <-ctxt.Done():
+ return ctxt.Err()
+
+ case err := <-errChan:
+ if err != nil {
+ return fmt.Errorf("error sending response: %w", err)
+ }
+ }
+
+ r.log.TraceS(ctxt, "Sent response to watch-only node",
+ btclog.ClosureAttr("response", formatSignCoordinatorMsg(resp)))
+
+ return nil
+}
+
+// A compile time assertion to ensure OutboundClient meets the
+// RemoteSignerClient interface.
+var _ RemoteSignerClient = (*OutboundClient)(nil)
+
+// formatSignCoordinatorMsg formats the passed proto message into a JSON string.
+func formatSignCoordinatorMsg(msg protoreflect.ProtoMessage) btclog.Closure {
+ return func() string {
+ jsonBytes, err := lnrpc.ProtoJSONMarshalOpts.Marshal(msg)
+ if err != nil {
+ return fmt.Sprintf("<err: %v>", err.Error())
+ }
+
+ return string(jsonBytes)
+ }
+}
### lnwallet/rpcwallet/remote_signer_client_builder.go
@@ -0,0 +1,70 @@
+package rpcwallet
+
+import (
+ "github.com/lightningnetwork/lnd/lncfg"
+ "github.com/lightningnetwork/lnd/lnrpc"
+ "github.com/lightningnetwork/lnd/lnrpc/signrpc"
+ "github.com/lightningnetwork/lnd/lnrpc/walletrpc"
+)
+
+type rscBuilder = RemoteSignerClientBuilder
+
+// RemoteSignerClientBuilder creates instances of the RemoteSignerClient
+// interface, based on the provided configuration.
+type RemoteSignerClientBuilder struct {
+ cfg *lncfg.WatchOnlyNode
+}
+
+// NewRemoteSignerClientBuilder creates a new instance of the
+// RemoteSignerClientBuilder.
+func NewRemoteSignerClientBuilder(cfg *lncfg.WatchOnlyNode) *rscBuilder {
+ return &rscBuilder{cfg}
+}
+
+// Build creates a new RemoteSignerClient instance. If the configuration enables
+// an outbound remote signer, a new OutboundRemoteSignerClient will be returned.
+// Else, a NoOpClient will be returned.
+func (b *rscBuilder) Build(subServers []lnrpc.SubServer) (
+ RemoteSignerClient, error) {
+
+ var (
+ walletServer walletrpc.WalletKitServer
+ signerServer signrpc.SignerServer
+ )
+
+ for _, subServer := range subServers {
+ if server, ok := subServer.(walletrpc.WalletKitServer); ok {
+ walletServer = server
+ }
+
+ if server, ok := subServer.(signrpc.SignerServer); ok {
+ signerServer = server
+ }
+ }
+
+ // Check if we have all servers and if the configuration enables an
+ // outbound remote signer. If not, return a NoOpClient.
+ if walletServer == nil || signerServer == nil {
+ log.Debugf("Using a No Op remote signer client due to " +
+ "current sub-server support")
+
+ return &NoOpClient{}, nil
+ }
+
+ if !b.cfg.ExperimentalEnable {
+ log.Debugf("Using a No Op remote signer client due to the " +
+ "current watchonly config")
+
+ return &NoOpClient{}, nil
+ }
+
+ // An outbound remote signer client is enabled, therefore we create one.
+ log.Debugf("Using an outbound remote signer client")
+
+ streamFeeder := NewStreamFeeder(b.cfg.ConnectionCfg)
+
+ return NewOutboundClient(
+ walletServer, signerServer, streamFeeder,
+ b.cfg.ExperimentalRequestTimeout,
+ )
+}
### lnwallet/rpcwallet/remote_signer_client_test.go
@@ -0,0 +1,733 @@
+package rpcwallet
+
+import (
+ "context"
+ "errors"
+ "math"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/lightningnetwork/lnd/lnrpc"
+ "github.com/lightningnetwork/lnd/lnrpc/signrpc"
+ "github.com/lightningnetwork/lnd/lnrpc/walletrpc"
+ "github.com/lightningnetwork/lnd/lnrpc/watchonlyrpc"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/grpc/metadata"
+)
+
+var (
+ // ErrStreamCanceled is returned when the mock stream is canceled by the
+ // remote signer client.
+ ErrStreamCanceled = errors.New("stream canceled")
+
+ // ErrMockResponseErr is a mock error that is returned by the mock
+ // signer server.
+ ErrMockResponseErr = errors.New("mock response error")
+
+ // ErrStreamError is returned when the mock stream creation fails.
+ ErrStreamError = errors.New("stream creation error")
+)
+
+// mockStreamFeeder is a mock implementation of SignCoordinatorStreamFeeder.
+type mockStreamFeeder struct {
+ // stream is the current mock stream instance that gets set when the
+ // GetStream execution is successful.
+ stream *mockStream
+
+ // streamShouldFail is a boolean that indicates if the stream should
+ // fail when GetStream is called.
+ streamShouldFail bool
+
+ // streamCreated is a channel that is used to signal when the stream has
+ // been created. If the stream creation fails, an error is sent over the
+ // channel instead.
+ streamCreated chan error
+
+ quit chan struct{}
+
+ mu sync.Mutex
+}
+
+// newMockStreamFeeder creates a new mock stream feeder instance. If
+// getStreamShouldFail is set to true, the GetStream method will fail and return
+// an error when executed, until the SetStreamFailure method is called to change
+// the behavior.
+func newMockStreamFeeder(getStreamShouldFail bool) *mockStreamFeeder {
+ return &mockStreamFeeder{
+ streamCreated: make(chan error),
+ quit: make(chan struct{}),
+ streamShouldFail: getStreamShouldFail,
+ }
+}
+
+// GetStream returns a mock stream instance. If the stream creation fails, an
+// error is returned instead.
+func (msf *mockStreamFeeder) GetStream(ctx context.Context) (
+ *Stream, error) {
+
+ msf.mu.Lock()
+
+ select {
+ case <-msf.quit:
+ msf.mu.Unlock()
+ return nil, ErrShuttingDown
+ default:
+ }
+
+ // If we've configured the stream feeder to fail, we'll fail the stream
+ // creation and return an error.
+ if msf.streamShouldFail {
+ msf.mu.Unlock()
+
+ // Signal that the stream creation has failed.
+ select {
+ case msf.streamCreated <- ErrStreamError:
+ case <-ctx.Done():
+ case <-msf.quit:
+ }
+
+ return nil, ErrStreamError
+ }
+
+ // Wrap the context in a cancelable context, so that the stream will be
+ // canceled when either party cancels to the context.
+ // If cancel function is executed, that simulates that the stream was
+ // cancelled by the other party (i.e. the watch-only node).
+ // If the parent context is cancelled, the remote signer client has
+ // cancelled the stream.
+ ctxc, cancel := context.WithCancel(ctx)
+
+ // Else create a new mock stream instance.
+ mStream := newMockStream(ctxc)
+
+ msf.stream = mStream
+
+ // cancel the context on the closure of the stream.
+ closeFunc := func() error {
+ cancel()
+
+ return nil
+ }
+
+ returnStream := NewStream(msf.stream, closeFunc)
+
+ msf.mu.Unlock()
+
+ // Signal that the stream creation has succeeded.
+ select {
+ case msf.streamCreated <- nil:
+ case <-ctxc.Done():
+ case <-msf.quit:
+ }
+
+ return returnStream, nil
+}
+
+// SetStreamFailure sets the streamShouldFail boolean to the provided value.
+// If set to true, the GetStream method will fail and return an error when
+// executed. If set to false, the GetStream method will succeed and return a
+// mock stream instance.
+func (msf *mockStreamFeeder) SetStreamFailure(shouldFail bool) {
+ msf.mu.Lock()
+ defer msf.mu.Unlock()
+
+ msf.streamShouldFail = shouldFail
+}
+
+// GetStreamShouldFail returns the current value of the streamShouldFail
+// boolean.
+func (msf *mockStreamFeeder) GetStreamShouldFail() bool {
+ msf.mu.Lock()
+ defer msf.mu.Unlock()
+
+ return msf.streamShouldFail
+}
+
+// Stop signals the mock stream feeder to stop.
+func (msf *mockStreamFeeder) Stop() {
+ close(msf.quit)
+}
+
+// A compile time assertion to ensure mockStreamFeeder meets the
+// SignCoordinatorStreamFeeder interface.
+var _ SignCoordinatorStreamFeeder = (*mockStreamFeeder)(nil)
+
+// Mock implementation of a stream.
+type mockStream struct {
+ sendChan chan *watchonlyrpc.SignCoordinatorResponse
+ recvChan chan *watchonlyrpc.SignCoordinatorRequest
+
+ // recvErrChan can be used to simulate that the stream errors.
+ recvErrChan chan error
+
+ // ctx is the context that the stream was created with.
+ ctx context.Context //nolint:containedctx
+}
+
+// newMockStream creates a new mock stream instance.
+// The second return value is a cancel function that can be used to cancel the
+// stream.
+func newMockStream(ctx context.Context) *mockStream {
+ return &mockStream{
+ sendChan: make(chan *watchonlyrpc.SignCoordinatorResponse),
+ recvChan: make(chan *watchonlyrpc.SignCoordinatorRequest),
+ recvErrChan: make(chan error),
+ ctx: ctx,
+ }
+}
+
+// Send sends a response over the mock stream. This is called by the remote
+// signer client when it responds to a request.
+func (ms *mockStream) Send(resp *watchonlyrpc.SignCoordinatorResponse) error {
+ select {
+ case <-ms.ctx.Done():
+ // If the context is canceled, we return an error to indicate
+ // that the stream has been canceled.
+ return ErrStreamCanceled
+ case ms.sendChan <- resp:
+ }
+
+ return nil
+}
+
+// Recv simulates that a request over is sent over the mock stream to the
+// remote signer client. If a request is sent over the recvChan, the remote
+// signer client will handle the request. If an error is sent over the
+// recvErrChan channel, the error will be received by the remote signer client.
+func (ms *mockStream) Recv() (*watchonlyrpc.SignCoordinatorRequest, error) {
+ select {
+ case resp := <-ms.recvChan:
+ return resp, nil
+ case err := <-ms.recvErrChan:
+ return nil, err
+ case <-ms.ctx.Done():
+ // If the context is canceled, we return an error to indicate
+ // that the stream has been canceled.
+ return nil, ErrStreamCanceled
+ }
+}
+
+// Helper function to simulate requests sent over the mock stream.
+// The function will return an error if the stream is canceled before the
+// request is received.
+func (ms *mockStream) recvRequest(
+ req *watchonlyrpc.SignCoordinatorRequest) error {
+
+ select {
+ case ms.recvChan <- req:
+ return nil
+ case <-ms.ctx.Done():
+ return ErrStreamCanceled
+ }
+}
+
+// Helper function to simulate that the stream errors.
+// The function will return an error if the stream is canceled before the error
+// is received.
+func (ms *mockStream) recvErr(err error) error {
+ select {
+ case ms.recvErrChan <- err:
+ return nil
+ case <-ms.ctx.Done():
+ return ErrStreamCanceled
+ }
+}
+
+// handleHandshake simulates the handshake procedure between the remote signer
+// client and the watch-only node.
+func (ms *mockStream) handleHandshake(t *testing.T) error {
+ var resp *watchonlyrpc.SignCoordinatorResponse
+
+ // Wait for the handshake init from the remote signer client.
+ select {
+ case <-ms.ctx.Done():
+ // If the context is canceled, we return an error to indicate
+ // that the stream has been canceled.
+ return ErrStreamCanceled
+ case resp = <-ms.sendChan:
+ }
+
+ require.Equal(t, handshakeRequestID, resp.GetRefRequestId())
+ require.NotEmpty(t, resp.GetSignerRegistration())
+
+ complete := &watchonlyrpc.RegistrationResponse_RegistrationComplete{
+ RegistrationComplete: &watchonlyrpc.RegistrationComplete{
+ Signature: "",
+ RegistrationInfo: "watch-only registration info",
+ },
+ }
+
+ rType := &watchonlyrpc.SignCoordinatorRequest_RegistrationResponse{
+ RegistrationResponse: &watchonlyrpc.RegistrationResponse{
+ RegistrationResponseType: complete,
+ },
+ }
+
+ // Send a message to the client to simulate that the watch-only node has
+ // accepted the registration and that it's completed.
+ regCompleteMsg := &watchonlyrpc.SignCoordinatorRequest{
+ RequestId: handshakeRequestID,
+ SignRequestType: rType,
+ }
+
+ return ms.recvRequest(regCompleteMsg)
+}
+
+// Mock implementations of various WalletKit_SignCoordinatorStreamsClient
+// methods.
+func (ms *mockStream) Header() (metadata.MD, error) { return nil, nil }
+func (ms *mockStream) SendMsg(m any) error { return nil }
+func (ms *mockStream) Trailer() metadata.MD { return nil }
+func (ms *mockStream) CloseSend() error { return nil }
+func (ms *mockStream) RecvMsg(m any) error { return nil }
+func (ms *mockStream) Context() context.Context { return ms.ctx }
+
+// newTestRemoteSignerClient creates a new outbound remote signer client
+// instance for testing purposes, and inserts the passed streamFeeder together
+// with a mock sub-servers into the created instance.
+func newTestRemoteSignerClient(t *testing.T,
+ streamFeeder *mockStreamFeeder) *OutboundClient {
+
+ client, err := NewOutboundClient(
+ &mockWalletKitServer{}, &mockSignerServer{}, streamFeeder,
+ 1*time.Second,
+ )
+ require.NoError(t, err)
+ require.NoError(t, client.Start(t.Context()))
+
+ // We expect the remote signer client attempt to create a stream during
+ // the start up. So if the stream feeder is configured to succeed, we
+ // need to handle the handshake procedure to finalize the stream set up.
+ if !streamFeeder.GetStreamShouldFail() {
+ // Wait for the stream to be created.
+ err := <-streamFeeder.streamCreated
+ require.NoError(t, err)
+
+ err = streamFeeder.stream.handleHandshake(t)
+ require.NoError(t, err)
+ }
+
+ return client
+}
+
+// TestPingResponse tests that we can send a ping request to the remote signer
+// client, and that it will respond with a pong.
+func TestPingResponse(t *testing.T) {
+ t.Parallel()
+
+ mockFeeder := newMockStreamFeeder(false)
+
+ client := newTestRemoteSignerClient(t, mockFeeder)
+ defer func() {
+ // Ensure that the remote signer client is stopped successfully
+ // after the test.
+ require.NoError(t, client.Stop())
+ }()
+
+ // create the ping request.
+ pingReq := &watchonlyrpc.SignCoordinatorRequest_Ping{
+ Ping: true,
+ }
+
+ requestID := uint64(2)
+
+ req := &watchonlyrpc.SignCoordinatorRequest{
+ RequestId: requestID,
+ SignRequestType: pingReq,
+ }
+
+ // Send the request to the remote signer client.
+ err := mockFeeder.stream.recvRequest(req)
+ require.NoError(t, err)
+
+ // Wait for the response from the remote signer client.
+ resp := <-mockFeeder.stream.sendChan
+
+ // Ensure that the response contains the correct request ID and that
+ // it's a pong response.
+ require.Equal(t, requestID, resp.GetRefRequestId())
+ require.True(t, resp.GetPong())
+}
+
+// TestMultiplePingResponses tests that we can send multiple ping requests to
+// the remote signer client, and that it will respond with a pong for each
+// request.
+func TestMultiplePingResponses(t *testing.T) {
+ t.Parallel()
+
+ mockFeeder := newMockStreamFeeder(false)
+
+ client := newTestRemoteSignerClient(t, mockFeeder)
+ defer func() {
+ // Ensure that the remote signer client is stopped successfully
+ // after the test.
+ require.NoError(t, client.Stop())
+ }()
+
+ // Create the first ping request.
+ pingReq := &watchonlyrpc.SignCoordinatorRequest_Ping{
+ Ping: true,
+ }
+
+ requestID1 := uint64(2)
+
+ req1 := &watchonlyrpc.SignCoordinatorRequest{
+ RequestId: requestID1,
+ SignRequestType: pingReq,
+ }
+
+ // Send the first request to the remote signer client.
+ err := mockFeeder.stream.recvRequest(req1)
+ require.NoError(t, err)
+
+ // Wait for the first response from the remote signer client.
+ resp1 := <-mockFeeder.stream.sendChan
+
+ // Ensure that the response contains the correct request ID and that
+ // it's a pong response.
+ require.Equal(t, requestID1, resp1.GetRefRequestId())
+ require.True(t, resp1.GetPong())
+
+ // Create the second ping request.
+ requestID2 := uint64(3)
+
+ req2 := &watchonlyrpc.SignCoordinatorRequest{
+ RequestId: requestID2,
+ SignRequestType: pingReq,
+ }
+
+ // Send the second request to the remote signer client.
+ err = mockFeeder.stream.recvRequest(req2)
+ require.NoError(t, err)
+
+ // Wait for the second response from the remote signer client.
+ resp2 := <-mockFeeder.stream.sendChan
+
+ // Ensure that the response contains the correct request ID, which
+ // differs from the first request, and that it's a pong response.
+ require.Equal(t, requestID2, resp2.GetRefRequestId())
+ require.True(t, resp2.GetPong())
+}
+
+// TestStreamRecvErrorHandling tests that the remote signer client will cancel
+// the stream if an error is received over the stream.Recv() method.
+// The remote signer client should then proceed to retry to create a new stream.
+func TestStreamRecvErrorHandling(t *testing.T) {
+ t.Parallel()
+
+ msf := newMockStreamFeeder(false)
+
+ client := newTestRemoteSignerClient(t, msf)
+ defer func() {
+ // Ensure that the remote signer client is stopped successfully
+ // after the test.
+ require.NoError(t, client.Stop())
+ }()
+
+ // Fetch the stream context before the stream is canceled.
+ streamCtx := msf.stream.Context()
+
+ // Simulate that the stream errors, which should cause the remote signer
+ // client to cancel the stream.
+ err := msf.stream.recvErr(ErrStreamCanceled)
+ require.NoError(t, err)
+
+ // Ensure that the stream has been canceled, as that should cause the
+ // remote signer client to cancel the stream context.
+ <-streamCtx.Done()
+
+ // Now we expect the remote signer client to retry to create a new
+ // stream. We therefore ensure that the stream creation has been
+ // attempted successfully.
+ err = <-msf.streamCreated
+ require.NoError(t, err)
+}
+
+// TestResponseError tests that the remote signer client will return a
+// SignerError if it cannot process a received request.
+func TestResponseError(t *testing.T) {
+ t.Parallel()
+
+ msf := newMockStreamFeeder(false)
+
+ client := newTestRemoteSignerClient(t, msf)
+ defer func() {
+ // Ensure that the remote signer client is stopped successfully
+ // after the test.
+ require.NoError(t, client.Stop())
+ }()
+
+ // Create a SignMessage request. As the remote signer client has an
+ // mockSignerServer instance as the signrpc server, this request will
+ // thrown an error when the signer server processes it.
+ signMessageReq := &watchonlyrpc.SignCoordinatorRequest_SignMessageReq{
+ SignMessageReq: &signrpc.SignMessageReq{},
+ }
+
+ requestID := uint64(2)
+
+ req := &watchonlyrpc.SignCoordinatorRequest{
+ RequestId: requestID,
+ SignRequestType: signMessageReq,
+ }
+
+ // Send the request to the remote signer client.
+ err := msf.stream.recvRequest(req)
+ require.NoError(t, err)
+
+ // Wait for the response from the remote signer client.
+ resp := <-msf.stream.sendChan
+
+ // Ensure that the response contains the correct request ID.
+ require.Equal(t, requestID, resp.GetRefRequestId())
+
+ // The response should be a SignerError, as the request could not be
+ // processed.
+ signErr := resp.GetSignerError()
+ require.NotNil(t, signErr)
+
+ // The error should contain the error message that was returned by the
+ // mock signer server.
+ require.Contains(t, signErr.GetError(), ErrMockResponseErr.Error())
+}
+
+// TestStreamCreationBackoff tests that the client will retry to create a stream
+// if the stream creation fails, and that the backoff duration before retrying
+// to set up the stream again increases with each failed attempt.
+func TestStreamCreationBackoff(t *testing.T) {
+ t.Parallel()
+
+ msf := newMockStreamFeeder(true)
+
+ client := newTestRemoteSignerClient(t, msf)
+ defer func() {
+ // Ensure that the remote signer client is stopped successfully
+ // after the test.
+ require.NoError(t, client.Stop())
+ }()
+
+ // For testing purposes, we set the max retry timeout to a value that
+ // ensures that the retry timeout will be capped on the fourth backoff.
+ client.maxRetryTimeout = defaultRetryTimeout * 3
+
+ // As we passed false to the newMockStreamFeeder constructor, the stream
+ // creation should fail.
+ err := <-msf.streamCreated
+ require.Equal(t, ErrStreamError, err)
+
+ lastStreamCreationAttempt := time.Now()
+
+ // The first time the client fails to set up a stream, we expect that
+ // the client will retry to create the stream after the default retry
+ // timeout, without any multiplied backoff. Once that happens, the
+ // streamCreated channel should receive the ErrStreamError.
+ err = <-msf.streamCreated
+ require.Equal(t, ErrStreamError, err)
+
+ // Now let's verify that the client waited the default retry timeout
+ // before retrying to recreate the stream.
+ retryBackoff := time.Since(lastStreamCreationAttempt)
+ expectedBackoff := time.Duration(float64(defaultRetryTimeout) *
+ math.Pow(float64(retryMultiplier), 0)) // 0 for no multiplier
+
+ // Verify that the retry backoff is within the expected range. We allow
+ // a small margin of error (100ms) on the range bound to account for the
+ // time it takes to execute the test code. We also allow a small margin
+ // of error (10ms) on the lower bound to account for the time the code
+ // execution takes between the creation of the stream, and when
+ // retryBackoff is set.
+ require.GreaterOrEqual(
+ t, retryBackoff, expectedBackoff-10*time.Millisecond,
+ )
+ require.LessOrEqual(
+ t, retryBackoff, expectedBackoff+100*time.Millisecond,
+ )
+
+ // Reset the last attempt time, so we can check the next retry.
+ lastStreamCreationAttempt = time.Now()
+
+ // Now let's wait until the client retries to create the stream again.
+ // This time we expect that a multiplier of retryMultiplier^1 has been
+ // applied to the backoff duration.
+ err = <-msf.streamCreated
+ require.Equal(t, ErrStreamError, err)
+
+ // Verify that the retry backoff is within the expected range, with the
+ // multiplier applied.
+ retryBackoff = time.Since(lastStreamCreationAttempt)
+
+ // The second backoff should have the multiplier applied once, therefore
+ // the multiplier raised to the power of 1.
+ expectedBackoff = time.Duration(float64(defaultRetryTimeout) *
+ math.Pow(float64(retryMultiplier), 1))
+
+ // Verify that the retry backoff is within the expected range.
+ require.GreaterOrEqual(
+ t, retryBackoff, expectedBackoff-10*time.Millisecond,
+ )
+ require.LessOrEqual(
+ t, retryBackoff, expectedBackoff+100*time.Millisecond,
+ )
+
+ // Reset the last attempt time, so that we can check that the retry
+ // timeout correctly gets multiplied again.
+ lastStreamCreationAttempt = time.Now()
+
+ // Now let's wait until the client retries to create the stream again.
+ // This time we expect that a multiplier of retryMultiplier^2 has been
+ // applied to the backoff duration.
+ err = <-msf.streamCreated
+ require.Equal(t, ErrStreamError, err)
+
+ // Verify that the retry backoff is within the expected range, with the
+ // multiplier applied.
+ retryBackoff = time.Since(lastStreamCreationAttempt)
+
+ // The third backoff should have the multiplier applied twice, therefore
+ // the multiplier raised to the power of 2.
+ expectedBackoff = time.Duration(float64(defaultRetryTimeout) *
+ math.Pow(float64(retryMultiplier), 2))
+
+ // Verify that the retry backoff is within the expected range.
+ require.GreaterOrEqual(
+ t, retryBackoff, expectedBackoff-10*time.Millisecond,
+ )
+ require.LessOrEqual(
+ t, retryBackoff, expectedBackoff+100*time.Millisecond,
+ )
+
+ // For the next retry, we want the stream creation to succeed. This will
+ // reset the retry backoff to the default value, once the stream is
+ // successfully created.
+ msf.SetStreamFailure(false)
+
+ // Reset the last attempt time, so we can check the next retry.
+ lastStreamCreationAttempt = time.Now()
+
+ // Now let's wait until the client retries to create the stream again.
+ // Even though the creation will succeed, it'll still take the expected
+ // backoff time before the client attempts to make the successful stream
+ // creation. However, since we capped the maximum retry timeout, and
+ // this was the third time the retry timeout was multiplied, the maximum
+ // retry timeout should have been reached. Therefore, we expect the
+ // retry timeout to be set to client.maxRetryTimeout.
+ err = <-msf.streamCreated
+
+ // We expect the stream creation to succeed this time.
+ require.NoError(t, err)
+
+ // Verify that the retry backoff is within the expected range, with the
+ // multiplier applied.
+ retryBackoff = time.Since(lastStreamCreationAttempt)
+
+ // The fourth backoff should have the multiplier applied three times,
+ // which would result in a backoff larger than the client’s
+ // maxRetryTimeout. Therefore, the backoff should have been capped at
+ // the client’s maxRetryTimeout.
+ expectedBackoff = client.maxRetryTimeout
+
+ // Verify that the retry backoff was capped.
+ require.GreaterOrEqual(
+ t, retryBackoff, expectedBackoff-10*time.Millisecond,
+ )
+ require.LessOrEqual(
+ t, retryBackoff, expectedBackoff+100*time.Millisecond,
+ )
+
+ // As the steam creation was successful, the client will proceed with
+ // the handshake procedure before the stream creation is considered
+ // successful. We therefore need to simulate the handshake procedure.
+ err = msf.stream.handleHandshake(t)
+ require.NoError(t, err)
+
+ // Now let's cause the stream to fail again, to verify that the client
+ // reset the backoff to the default value, as the last stream creation
+ // attempt was successful.
+ err = msf.stream.recvErr(ErrStreamCanceled)
+ require.NoError(t, err)
+
+ // Reset the last attempt time, so we can check the next retry.
+ lastStreamCreationAttempt = time.Now()
+
+ // Now let's wait till the client retries to create the stream again.
+ err = <-msf.streamCreated
+ // We expect the stream creation to also succeed this time.
+ require.NoError(t, err)
+
+ // As the backoff is reset to the default value, we expect that no
+ // multiplier has been applied to the backoff duration.
+ retryBackoff = time.Since(lastStreamCreationAttempt)
+ expectedBackoff = time.Duration(float64(defaultRetryTimeout) *
+ math.Pow(float64(retryMultiplier), 0))
+
+ // Verify that the retry backoff is within the expected range.
+ require.GreaterOrEqual(
+ t, retryBackoff, expectedBackoff-10*time.Millisecond,
+ )
+ require.LessOrEqual(
+ t, retryBackoff, expectedBackoff+100*time.Millisecond,
+ )
+}
+
+// mockWalletKitServer is a mock walletrpc.WalletKitServer implementation that
+// panics for all request methods.
+type mockWalletKitServer struct {
+ walletrpc.UnimplementedWalletKitServer
+}
+
+var _ walletrpc.WalletKitServer = (*mockWalletKitServer)(nil)
+
+// Name returns a unique string representation of the sub-server. This
+// can be used to identify the sub-server and also de-duplicate them.
+func (m *mockWalletKitServer) Name() string { return walletrpc.SubServerName }
+
+// Start starts the sub-server and all goroutines it needs to operate.
+func (m *mockWalletKitServer) Start() error { return nil }
+
+// Stop signals that the sub-server should wrap up any lingering
+// requests, and being a graceful shutdown.
+func (m *mockWalletKitServer) Stop() error { return nil }
+
+// InjectDependencies populates the sub-server's dependencies. If the
+// finalizeDependencies boolean is true, then the sub-server will finalize its
+// dependencies and return an error if any required dependencies are missing.
+func (m *mockWalletKitServer) InjectDependencies(
+ _ lnrpc.SubServerConfigDispatcher, _ bool) error {
+
+ return nil
+}
+
+// mockSignerServer is a mock signrpc.SignerServer implementation that panics
+// for all request methods except SignMessage.
+type mockSignerServer struct {
+ signrpc.UnimplementedSignerServer
+}
+
+var _ signrpc.SignerServer = (*mockSignerServer)(nil)
+
+func (m *mockSignerServer) SignMessage(_ context.Context,
+ _ *signrpc.SignMessageReq) (*signrpc.SignMessageResp, error) {
+
+ return nil, ErrMockResponseErr
+}
+
+// Name returns a unique string representation of the sub-server. This
+// can be used to identify the sub-server and also de-duplicate them.
+func (m *mockSignerServer) Name() string { return "SignRPC" }
+
+// Start starts the sub-server and all goroutines it needs to operate.
+func (m *mockSignerServer) Start() error { return nil }
+
+// Stop signals that the sub-server should wrap up any lingering
+// requests, and being a graceful shutdown.
+func (m *mockSignerServer) Stop() error { return nil }
+
+// InjectDependencies populates the sub-server's dependencies. If the
+// finalizeDependencies boolean is true, then the sub-server will finalize its
+// dependencies and return an error if any required dependencies are missing.
+func (m *mockSignerServer) InjectDependencies(
+ _ lnrpc.SubServerConfigDispatcher, _ bool) error {
+
+ return nil
+}
### lnwallet/rpcwallet/remote_signer_connection.go
@@ -0,0 +1,397 @@
+package rpcwallet
+
+import (
+ "context"
+ "crypto/x509"
+ "errors"
+ "fmt"
+ "os"
+ "time"
+
+ "github.com/lightningnetwork/lnd/lncfg"
+ "github.com/lightningnetwork/lnd/lnrpc/signrpc"
+ "github.com/lightningnetwork/lnd/lnrpc/walletrpc"
+ "github.com/lightningnetwork/lnd/lnrpc/watchonlyrpc"
+ "github.com/lightningnetwork/lnd/macaroons"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/credentials"
+ "gopkg.in/macaroon.v2"
+)
+
+type (
+ StreamClient = watchonlyrpc.WatchOnly_SignCoordinatorStreamsClient
+ StreamServer = watchonlyrpc.WatchOnly_SignCoordinatorStreamsServer
+)
+
+// RemoteSignerConnection is an interface that abstracts the communication with
+// a remote signer. It extends the RemoteSignerRequests interface, and adds some
+// additional methods to manage the connection and verify the health of the
+// remote signer.
+type RemoteSignerConnection interface {
+ // RemoteSignerRequests is an interface that defines the requests that
+ // can be sent to a remote signer.
+ RemoteSignerRequests
+
+ // Timeout returns the set connection timeout for the remote signer.
+ Timeout() time.Duration
+
+ // RequestTimeout returns the timeout that should be used for
+ // per-request RPC calls to the remote signer.
+ RequestTimeout() time.Duration
+
+ // Ready returns a channel that nil gets sent over once the remote
+ // signer is ready to accept requests. Note that an error will be sent
+ // over the returned channel if the passed context expires before the
+ // remote signer is ready to accept requests.
+ Ready(ctx context.Context) chan error
+
+ // Stop gracefully disconnects from the remote signer.
+ Stop()
+
+ // Ping verifies that the remote signer is still responsive.
+ Ping(ctx context.Context, timeout time.Duration) error
+}
+
+// RemoteSignerRequests is an interface that defines the requests that can be
+// sent to a remote signer. It's a subset of the signrpc.SignerClient and
+// wallet/signing RPC interfaces used for remote signer coordination.
+type RemoteSignerRequests interface {
+ // DeriveSharedKey sends a SharedKeyRequest to the remote signer and
+ // waits for the corresponding response.
+ DeriveSharedKey(ctx context.Context,
+ in *signrpc.SharedKeyRequest,
+ opts ...grpc.CallOption) (*signrpc.SharedKeyResponse, error)
+
+ // MuSig2Cleanup sends a MuSig2CleanupRequest to the remote signer and
+ // waits for the corresponding response.
+ MuSig2Cleanup(ctx context.Context,
+ in *signrpc.MuSig2CleanupRequest,
+ opts ...grpc.CallOption) (*signrpc.MuSig2CleanupResponse, error)
+
+ // MuSig2CombineSig sends a MuSig2CombineSigRequest to the remote signer
+ // and waits for the corresponding response.
+ MuSig2CombineSig(ctx context.Context,
+ in *signrpc.MuSig2CombineSigRequest,
+ opts ...grpc.CallOption) (*signrpc.MuSig2CombineSigResponse,
+ error)
+
+ // MuSig2CreateSession sends a MuSig2SessionRequest to the remote signer
+ // and waits for the corresponding response.
+ MuSig2CreateSession(ctx context.Context,
+ in *signrpc.MuSig2SessionRequest,
+ opts ...grpc.CallOption) (*signrpc.MuSig2SessionResponse, error)
+
+ // MuSig2RegisterNonces sends a MuSig2RegisterNoncesRequest to the
+ // remote signer and waits for the corresponding response.
+ MuSig2RegisterNonces(ctx context.Context,
+ in *signrpc.MuSig2RegisterNoncesRequest,
+ opts ...grpc.CallOption) (*signrpc.MuSig2RegisterNoncesResponse,
+ error)
+
+ // MuSig2Sign sends a MuSig2SignRequest to the remote signer and waits
+ // for the corresponding response.
+ MuSig2Sign(ctx context.Context,
+ in *signrpc.MuSig2SignRequest,
+ opts ...grpc.CallOption) (*signrpc.MuSig2SignResponse, error)
+
+ // SignMessage sends a SignMessageReq to the remote signer and waits for
+ // the corresponding response.
+ SignMessage(ctx context.Context,
+ in *signrpc.SignMessageReq,
+ opts ...grpc.CallOption) (*signrpc.SignMessageResp, error)
+
+ // SignPsbt sends a SignPsbtRequest to the remote signer and waits for
+ // the corresponding response.
+ SignPsbt(ctx context.Context, in *walletrpc.SignPsbtRequest,
+ opts ...grpc.CallOption) (*walletrpc.SignPsbtResponse, error)
+
+ // MuSig2RegisterCombinedNonce sends a MuSig2RegisterCombinedNonce to
+ // the remote signer and waits for the corresponding response.
+ MuSig2RegisterCombinedNonce(ctx context.Context,
+ in *signrpc.MuSig2RegisterCombinedNonceRequest,
+ opts ...grpc.CallOption) (
+ *signrpc.MuSig2RegisterCombinedNonceResponse, error)
+
+ // MuSig2GetCombinedNonce sends a MuSig2GetCombinedNonceRequest to the
+ // remote signer and waits for the corresponding response.
+ MuSig2GetCombinedNonce(ctx context.Context,
+ in *signrpc.MuSig2GetCombinedNonceRequest,
+ opts ...grpc.CallOption) (
+ *signrpc.MuSig2GetCombinedNonceResponse, error)
+}
+
+// OutboundConnection is an abstraction of the outbound connection made to an
+// inbound remote signer. An inbound remote signer is a remote signer that
+// allows the watch-only node to connect to it via an inbound GRPC connection.
+type OutboundConnection struct {
+ // Embedded signrpc.SignerClient and walletrpc.WalletKitClient to
+ // implement the RemoteSigner interface.
+ signrpc.SignerClient
+ walletrpc.WalletKitClient
+ watchonlyrpc.WatchOnlyClient
+
+ // The ConnectionCfg containing connection details of the remote signer.
+ cfg lncfg.ConnectionCfg
+
+ // conn represents the connection to the remote signer.
+ conn *grpc.ClientConn
+}
+
+// NewOutboundConnection creates a new OutboundConnection instance.
+// The function sets up a connection to the remote signer node.
+func NewOutboundConnection(ctx context.Context,
+ cfg lncfg.ConnectionCfg) (*OutboundConnection, error) {
+
+ remoteSigner := &OutboundConnection{
+ cfg: cfg,
+ }
+
+ err := remoteSigner.connect(ctx, cfg)
+ if err != nil {
+ return nil, fmt.Errorf("error connecting to the remote "+
+ "signing node through RPC: %w", err)
+ }
+
+ return remoteSigner, nil
+}
+
+// Ready returns a channel that nil gets sent over once the connection to the
+// remote signer is set up and the remote signer is ready to accept requests.
+//
+// NOTE: This is part of the RemoteSignerConnection interface.
+func (r *OutboundConnection) Ready(_ context.Context) chan error {
+ // The inbound remote signer is ready as soon we have connected to the
+ // remote signer node in the constructor. Therefore, we always send
+ // nil here to signal that we are ready.
+ readyChan := make(chan error, 1)
+ readyChan <- nil
+
+ return readyChan
+}
+
+// Ping verifies that the remote signer is still responsive.
+//
+// NOTE: This is part of the RemoteSignerConnection interface.
+func (r *OutboundConnection) Ping(ctx context.Context,
+ timeout time.Duration) error {
+
+ newConn, err := r.connectRPC(ctx, timeout)
+ if err != nil {
+ return fmt.Errorf("error connecting to the remote "+
+ "signing node through RPC: %v", err)
+ }
+
+ defer func() {
+ err = newConn.Close()
+ if err != nil {
+ log.Warnf("Failed to ping connection check to remote "+
+ "signing node: %v", err)
+ }
+ }()
+
+ return nil
+}
+
+// Timeout returns the set connection timeout for the remote signer.
+//
+// NOTE: This is part of the RemoteSignerConnection interface.
+func (r *OutboundConnection) Timeout() time.Duration {
+ return r.cfg.Timeout
+}
+
+// RequestTimeout returns the timeout that should be used for per-request RPC
+// calls to the remote signer.
+//
+// NOTE: This is part of the RemoteSignerConnection interface.
+func (r *OutboundConnection) RequestTimeout() time.Duration {
+ return r.cfg.Timeout
+}
+
+// Stop closes the connection to the remote signer.
+//
+// NOTE: This is part of the RemoteSignerConnection interface.
+func (r *OutboundConnection) Stop() {
+ if r.conn != nil {
+ err := r.conn.Close()
+ if err != nil {
+ log.Errorf("error closing remote signer connection: %v",
+ err)
+ }
+ }
+}
+
+// connect tries to establish an RPC connection to the configured host:port with
+// the supplied certificate and macaroon.
+func (r *OutboundConnection) connect(ctx context.Context,
+ cfg lncfg.ConnectionCfg) error {
+
+ conn, err := r.connectRPC(ctx, cfg.Timeout)
+ if err != nil {
+ return fmt.Errorf("unable to connect to remote signer: %w", err)
+ }
+
+ // If we were able to connect to the remote signer, we store the
+ // connection in the OutboundConnection struct.
+ r.conn = conn
+ r.SignerClient = signrpc.NewSignerClient(conn)
+ r.WalletKitClient = walletrpc.NewWalletKitClient(conn)
+ r.WatchOnlyClient = watchonlyrpc.NewWatchOnlyClient(conn)
+
+ return nil
+}
+
+// connectRPC tries to establish an RPC connection to the given host:port with
+// the supplied certificate and macaroon.
+func (r *OutboundConnection) connectRPC(ctx context.Context,
+ timeout time.Duration) (*grpc.ClientConn, error) {
+
+ certBytes, err := os.ReadFile(r.cfg.TLSCertPath)
+ if err != nil {
+ return nil, fmt.Errorf("error reading TLS cert file %v: %w",
+ r.cfg.TLSCertPath, err)
+ }
+
+ cp := x509.NewCertPool()
+ if !cp.AppendCertsFromPEM(certBytes) {
+ return nil, fmt.Errorf("credentials: failed to append " +
+ "certificate")
+ }
+
+ macBytes, err := os.ReadFile(r.cfg.MacaroonPath)
+ if err != nil {
+ return nil, fmt.Errorf("error reading macaroon file %v: %w",
+ r.cfg.MacaroonPath, err)
+ }
+ mac := &macaroon.Macaroon{}
+ if err := mac.UnmarshalBinary(macBytes); err != nil {
+ return nil, fmt.Errorf("error decoding macaroon: %w", err)
+ }
+
+ macCred, err := macaroons.NewMacaroonCredential(mac)
+ if err != nil {
+ return nil, fmt.Errorf("error creating creds: %w", err)
+ }
+
+ opts := []grpc.DialOption{
+ grpc.WithTransportCredentials(credentials.NewClientTLSFromCert(
+ cp, "",
+ )),
+ grpc.WithPerRPCCredentials(macCred),
+ grpc.WithBlock(),
+ }
+
+ ctxt, cancel := context.WithTimeout(ctx, timeout)
+ defer cancel()
+
+ conn, err := grpc.DialContext(ctxt, r.cfg.RPCHost, opts...)
+ if err != nil {
+ return nil, fmt.Errorf("unable to connect to RPC server: %w",
+ err)
+ }
+
+ return conn, nil
+}
+
+// A compile time assertion to ensure OutboundConnection meets the
+// RemoteSignerConnection interface.
+var _ RemoteSignerConnection = (*OutboundConnection)(nil)
+
+// InboundRemoteSignerConnection is an interface that abstracts the
+// communication with an outbound remote signer. It extends the
+// RemoteSignerConnection insterface.
+type InboundRemoteSignerConnection interface {
+ RemoteSignerConnection
+
+ // AddConnection feeds the inbound connection handler with the incoming
+ // stream set up by an outbound remote signer and then blocks until the
+ // stream is closed. Lnd can then send any requests to the remote signer
+ // through the stream.
+ AddConnection(stream StreamServer) error
+}
+
+// InboundConnection is an abstraction that manages the inbound connection that
+// is set up by an outbound remote signer that connects to the watch-only node.
+type InboundConnection struct {
+ *SignCoordinator
+
+ connectionTimeout time.Duration
+}
+
+// NewInboundConnection creates a new InboundConnection instance.
+func NewInboundConnection(requestTimeout time.Duration,
+ connectionTimeout time.Duration) *InboundConnection {
+
+ return &InboundConnection{
+ connectionTimeout: connectionTimeout,
+ SignCoordinator: NewSignCoordinator(
+ requestTimeout, connectionTimeout,
+ ),
+ }
+}
+
+// Timeout returns the set connection timeout for the remote signer.
+//
+// NOTE: This is part of the RemoteSignerConnection interface.
+func (r *InboundConnection) Timeout() time.Duration {
+ return r.connectionTimeout
+}
+
+// RequestTimeout returns the timeout that should be used for per-request RPC
+// calls to the remote signer.
+//
+// NOTE: This is part of the RemoteSignerConnection interface.
+func (r *InboundConnection) RequestTimeout() time.Duration {
+ return r.SignCoordinator.requestTimeout
+}
+
+// Ready returns a channel that nil gets sent over once the remote signer
+// connected and is ready to accept requests.
+//
+// NOTE: This is part of the RemoteSignerConnection interface.
+func (r *InboundConnection) Ready(ctx context.Context) chan error {
+ readyChan := make(chan error, 1)
+
+ // We wait for the remote signer to connect in a go func and signal
+ // over the channel once it's ready.
+ go func() {
+ log.Infof("Waiting for the remote signer to connect")
+
+ readyChan <- r.SignCoordinator.WaitUntilConnected(ctx)
+ close(readyChan)
+ }()
+
+ return readyChan
+}
+
+// Ping verifies that the remote signer is still responsive.
+//
+// NOTE: This is part of the RemoteSignerConnection interface.
+func (r *InboundConnection) Ping(ctx context.Context,
+ timeout time.Duration) error {
+
+ pong, err := r.SignCoordinator.Ping(ctx, timeout)
+ if err != nil {
+ return fmt.Errorf("ping request to remote signer "+
+ "errored: %w", err)
+ }
+
+ if !pong {
+ return errors.New("incorrect Pong response from remote signer")
+ }
+
+ return nil
+}
+
+// AddConnection feeds the inbound connection handler with the incoming stream
+// set up by an outbound remote signer and then blocks until the stream is
+// closed. Lnd can then send any requests to the remote signer through the
+// stream.
+//
+// NOTE: This is part of the InboundRemoteSignerConnection interface.
+func (r *InboundConnection) AddConnection(stream StreamServer) error {
+ return r.SignCoordinator.Run(stream)
+}
+
+// A compile time assertion to ensure InboundConnection meets the
+// RemoteSigner interface.
+var _ InboundRemoteSignerConnection = (*InboundConnection)(nil)
### lnwallet/rpcwallet/remote_signer_connection_builder.go
@@ -0,0 +1,37 @@
+package rpcwallet
+
+import (
+ "context"
+ "errors"
+
+ "github.com/lightningnetwork/lnd/lncfg"
+)
+
+// BuildRemoteSignerConnection creates a new RemoteSignerConnection instance.
+// If the configuration specifies that an inbound remote signer should be used,
+// a new OutboundConnection is created. If the configuration specifies that an
+// outbound remote signer should be used, a new InboundConnection is created.
+// The function returns the created RemoteSignerConnection instance, and a
+// cleanup function that should be called when the RemoteSignerConnection is no
+// longer needed.
+func BuildRemoteSignerConnection(ctx context.Context,
+ cfg *lncfg.RemoteSigner) (RemoteSignerConnection, error) {
+
+ if !cfg.Enable {
+ // This should be unreachable, but this is an extra sanity check
+ return nil, errors.New("remote signer not enabled in " +
+ "config")
+ }
+
+ // Create the remote signer based on the configuration.
+ if !cfg.ExperimentalAllowInboundConnection {
+ return NewOutboundConnection(ctx, cfg.ConnectionCfg)
+ }
+
+ inboundConnection := NewInboundConnection(
+ cfg.ConnectionCfg.ExperimentalRequestTimeout,
+ cfg.ExperimentalStartupTimeout,
+ )
+
+ return inboundConnection, nil
+}
### lnwallet/rpcwallet/rpcwallet.go
@@ -4,10 +4,8 @@ import (
"bytes"
"context"
"crypto/sha256"
- "crypto/x509"
"errors"
"fmt"
- "os"
"time"
"github.com/btcsuite/btcd/address/v2"
@@ -25,19 +23,14 @@ import (
"github.com/lightningnetwork/lnd/fn/v2"
"github.com/lightningnetwork/lnd/input"
"github.com/lightningnetwork/lnd/keychain"
- "github.com/lightningnetwork/lnd/lncfg"
"github.com/lightningnetwork/lnd/lnrpc/signrpc"
"github.com/lightningnetwork/lnd/lnrpc/walletrpc"
"github.com/lightningnetwork/lnd/lnwallet"
"github.com/lightningnetwork/lnd/lnwallet/btcwallet"
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
"github.com/lightningnetwork/lnd/lnwire"
- "github.com/lightningnetwork/lnd/macaroons"
- "google.golang.org/grpc"
"google.golang.org/grpc/codes"
- "google.golang.org/grpc/credentials"
"google.golang.org/grpc/status"
- "gopkg.in/macaroon.v2"
)
var (
@@ -63,8 +56,7 @@ type RPCKeyRing struct {
rpcTimeout time.Duration
- signerClient signrpc.SignerClient
- walletClient walletrpc.WalletKitClient
+ remoteSignerConn RemoteSignerConnection
}
var _ keychain.SecretKeyRing = (*RPCKeyRing)(nil)
@@ -77,25 +69,15 @@ var _ lnwallet.WalletController = (*RPCKeyRing)(nil)
// delegates any signing or ECDH operations to the remove signer through RPC.
func NewRPCKeyRing(watchOnlyKeyRing keychain.SecretKeyRing,
watchOnlyWalletController lnwallet.WalletController,
- remoteSigner *lncfg.RemoteSigner,
+ remoteSignerConn RemoteSignerConnection,
netParams *chaincfg.Params) (*RPCKeyRing, error) {
- rpcConn, err := connectRPC(
- remoteSigner.RPCHost, remoteSigner.TLSCertPath,
- remoteSigner.MacaroonPath, remoteSigner.Timeout,
- )
- if err != nil {
- return nil, fmt.Errorf("error connecting to the remote "+
- "signing node through RPC: %v", err)
- }
-
return &RPCKeyRing{
WalletController: watchOnlyWalletController,
watchOnlyKeyRing: watchOnlyKeyRing,
netParams: netParams,
- rpcTimeout: remoteSigner.Timeout,
- signerClient: signrpc.NewSignerClient(rpcConn),
- walletClient: walletrpc.NewWalletKitClient(rpcConn),
+ rpcTimeout: remoteSignerConn.RequestTimeout(),
+ remoteSignerConn: remoteSignerConn,
}, nil
}
@@ -206,9 +188,9 @@ func (r *RPCKeyRing) SignPsbt(packet *psbt.Packet) ([]uint32, error) {
return nil, fmt.Errorf("error serializing PSBT: %w", err)
}
- resp, err := r.walletClient.SignPsbt(ctxt, &walletrpc.SignPsbtRequest{
- FundedPsbt: buf.Bytes(),
- })
+ resp, err := r.remoteSignerConn.SignPsbt(ctxt,
+ &walletrpc.SignPsbtRequest{FundedPsbt: buf.Bytes()},
+ )
if err != nil {
considerShutdown(err)
return nil, fmt.Errorf("error signing PSBT in remote signer "+
@@ -419,7 +401,7 @@ func (r *RPCKeyRing) ECDH(keyDesc keychain.KeyDescriptor,
req.KeyDesc.RawKeyBytes = keyDesc.PubKey.SerializeCompressed()
}
- resp, err := r.signerClient.DeriveSharedKey(ctxt, req)
+ resp, err := r.remoteSignerConn.DeriveSharedKey(ctxt, req)
if err != nil {
considerShutdown(err)
return key, fmt.Errorf("error deriving shared key in remote "+
@@ -442,14 +424,16 @@ func (r *RPCKeyRing) SignMessage(keyLoc keychain.KeyLocator,
ctxt, cancel := context.WithTimeout(context.Background(), r.rpcTimeout)
defer cancel()
- resp, err := r.signerClient.SignMessage(ctxt, &signrpc.SignMessageReq{
- Msg: msg,
- KeyLoc: &signrpc.KeyLocator{
- KeyFamily: int32(keyLoc.Family),
- KeyIndex: int32(keyLoc.Index),
+ resp, err := r.remoteSignerConn.SignMessage(ctxt,
+ &signrpc.SignMessageReq{
+ Msg: msg,
+ KeyLoc: &signrpc.KeyLocator{
+ KeyFamily: int32(keyLoc.Family),
+ KeyIndex: int32(keyLoc.Index),
+ },
+ DoubleHash: doubleHash,
},
- DoubleHash: doubleHash,
- })
+ )
if err != nil {
considerShutdown(err)
return nil, fmt.Errorf("error signing message in remote "+
@@ -488,15 +472,17 @@ func (r *RPCKeyRing) SignMessageCompact(keyLoc keychain.KeyLocator,
ctxt, cancel := context.WithTimeout(context.Background(), r.rpcTimeout)
defer cancel()
- resp, err := r.signerClient.SignMessage(ctxt, &signrpc.SignMessageReq{
- Msg: msg,
- KeyLoc: &signrpc.KeyLocator{
- KeyFamily: int32(keyLoc.Family),
- KeyIndex: int32(keyLoc.Index),
+ resp, err := r.remoteSignerConn.SignMessage(ctxt,
+ &signrpc.SignMessageReq{
+ Msg: msg,
+ KeyLoc: &signrpc.KeyLocator{
+ KeyFamily: int32(keyLoc.Family),
+ KeyIndex: int32(keyLoc.Index),
+ },
+ DoubleHash: doubleHash,
+ CompactSig: true,
},
- DoubleHash: doubleHash,
- CompactSig: true,
- })
+ )
if err != nil {
considerShutdown(err)
return nil, fmt.Errorf("error signing message in remote "+
@@ -521,17 +507,19 @@ func (r *RPCKeyRing) SignMessageSchnorr(keyLoc keychain.KeyLocator,
ctxt, cancel := context.WithTimeout(context.Background(), r.rpcTimeout)
defer cancel()
- resp, err := r.signerClient.SignMessage(ctxt, &signrpc.SignMessageReq{
- Msg: msg,
- KeyLoc: &signrpc.KeyLocator{
- KeyFamily: int32(keyLoc.Family),
- KeyIndex: int32(keyLoc.Index),
+ resp, err := r.remoteSignerConn.SignMessage(ctxt,
+ &signrpc.SignMessageReq{
+ Msg: msg,
+ KeyLoc: &signrpc.KeyLocator{
+ KeyFamily: int32(keyLoc.Family),
+ KeyIndex: int32(keyLoc.Index),
+ },
+ DoubleHash: doubleHash,
+ SchnorrSig: true,
+ SchnorrSigTapTweak: taprootTweak,
+ Tag: tag,
},
- DoubleHash: doubleHash,
- SchnorrSig: true,
- SchnorrSigTapTweak: taprootTweak,
- Tag: tag,
- })
+ )
if err != nil {
considerShutdown(err)
return nil, fmt.Errorf("error signing message in remote "+
@@ -716,7 +704,7 @@ func (r *RPCKeyRing) MuSig2CreateSession(bipVersion input.MuSig2Version,
ctxt, cancel := context.WithTimeout(context.Background(), r.rpcTimeout)
defer cancel()
- resp, err := r.signerClient.MuSig2CreateSession(ctxt, req)
+ resp, err := r.remoteSignerConn.MuSig2CreateSession(ctxt, req)
if err != nil {
considerShutdown(err)
return nil, fmt.Errorf("error creating MuSig2 session in "+
@@ -770,7 +758,7 @@ func (r *RPCKeyRing) MuSig2RegisterNonces(sessionID input.MuSig2SessionID,
ctxt, cancel := context.WithTimeout(context.Background(), r.rpcTimeout)
defer cancel()
- resp, err := r.signerClient.MuSig2RegisterNonces(ctxt, req)
+ resp, err := r.remoteSignerConn.MuSig2RegisterNonces(ctxt, req)
if err != nil {
considerShutdown(err)
return false, fmt.Errorf("error registering MuSig2 nonces in "+
@@ -795,7 +783,7 @@ func (r *RPCKeyRing) MuSig2RegisterCombinedNonce(
ctxt, cancel := context.WithTimeout(context.Background(), r.rpcTimeout)
defer cancel()
- _, err := r.signerClient.MuSig2RegisterCombinedNonce(ctxt, req)
+ _, err := r.remoteSignerConn.MuSig2RegisterCombinedNonce(ctxt, req)
if err != nil {
considerShutdown(err)
@@ -818,7 +806,7 @@ func (r *RPCKeyRing) MuSig2GetCombinedNonce(sessionID input.MuSig2SessionID) (
ctxt, cancel := context.WithTimeout(context.Background(), r.rpcTimeout)
defer cancel()
- resp, err := r.signerClient.MuSig2GetCombinedNonce(ctxt, req)
+ resp, err := r.remoteSignerConn.MuSig2GetCombinedNonce(ctxt, req)
if err != nil {
considerShutdown(err)
@@ -854,7 +842,7 @@ func (r *RPCKeyRing) MuSig2Sign(sessionID input.MuSig2SessionID,
ctxt, cancel := context.WithTimeout(context.Background(), r.rpcTimeout)
defer cancel()
- resp, err := r.signerClient.MuSig2Sign(ctxt, req)
+ resp, err := r.remoteSignerConn.MuSig2Sign(ctxt, req)
if err != nil {
considerShutdown(err)
return nil, fmt.Errorf("error signing MuSig2 session in "+
@@ -898,7 +886,7 @@ func (r *RPCKeyRing) MuSig2CombineSig(sessionID input.MuSig2SessionID,
ctxt, cancel := context.WithTimeout(context.Background(), r.rpcTimeout)
defer cancel()
- resp, err := r.signerClient.MuSig2CombineSig(ctxt, req)
+ resp, err := r.remoteSignerConn.MuSig2CombineSig(ctxt, req)
if err != nil {
considerShutdown(err)
return nil, false, fmt.Errorf("error combining MuSig2 "+
@@ -920,6 +908,21 @@ func (r *RPCKeyRing) MuSig2CombineSig(sessionID input.MuSig2SessionID,
return finalSig, resp.HaveAllSignatures, nil
}
+// Ping verifies that the remote signer is still responsive.
+func (r *RPCKeyRing) Ping(ctx context.Context, timeout time.Duration) error {
+ return r.remoteSignerConn.Ping(ctx, timeout)
+}
+
+// ReadySignal returns a channel that signals once the wallet is ready to be
+// used, i.e. once the remote signer is connected. If we time out while waiting,
+// an error gets sent over the channel. This method overrides/shadows the
+// default implementation of the WalletController interface.
+//
+// NOTE: This method is part of the WalletController interface.
+func (r *RPCKeyRing) ReadySignal(ctx context.Context) chan error {
+ return r.remoteSignerConn.Ready(ctx)
+}
+
// MuSig2Cleanup removes a session from memory to free up resources.
func (r *RPCKeyRing) MuSig2Cleanup(sessionID input.MuSig2SessionID) error {
req := &signrpc.MuSig2CleanupRequest{
@@ -929,7 +932,7 @@ func (r *RPCKeyRing) MuSig2Cleanup(sessionID input.MuSig2SessionID) error {
ctxt, cancel := context.WithTimeout(context.Background(), r.rpcTimeout)
defer cancel()
- _, err := r.signerClient.MuSig2Cleanup(ctxt, req)
+ _, err := r.remoteSignerConn.MuSig2Cleanup(ctxt, req)
if err != nil {
considerShutdown(err)
return fmt.Errorf("error cleaning up MuSig2 session in remote "+
@@ -1195,7 +1198,7 @@ func (r *RPCKeyRing) remoteSign(tx *wire.MsgTx, signDesc *input.SignDescriptor,
return nil, fmt.Errorf("error serializing PSBT: %w", err)
}
- resp, err := r.walletClient.SignPsbt(
+ resp, err := r.remoteSignerConn.SignPsbt(
ctxt, &walletrpc.SignPsbtRequest{FundedPsbt: buf.Bytes()},
)
if err != nil {
@@ -1289,56 +1292,6 @@ func extractSignature(in *psbt.PInput,
}
}
-// connectRPC tries to establish an RPC connection to the given host:port with
-// the supplied certificate and macaroon.
-func connectRPC(hostPort, tlsCertPath, macaroonPath string,
- timeout time.Duration) (*grpc.ClientConn, error) {
-
- certBytes, err := os.ReadFile(tlsCertPath)
- if err != nil {
- return nil, fmt.Errorf("error reading TLS cert file %v: %w",
- tlsCertPath, err)
- }
-
- cp := x509.NewCertPool()
- if !cp.AppendCertsFromPEM(certBytes) {
- return nil, fmt.Errorf("credentials: failed to append " +
- "certificate")
- }
-
- macBytes, err := os.ReadFile(macaroonPath)
- if err != nil {
- return nil, fmt.Errorf("error reading macaroon file %v: %w",
- macaroonPath, err)
- }
- mac := &macaroon.Macaroon{}
- if err := mac.UnmarshalBinary(macBytes); err != nil {
- return nil, fmt.Errorf("error decoding macaroon: %w", err)
- }
-
- macCred, err := macaroons.NewMacaroonCredential(mac)
- if err != nil {
- return nil, fmt.Errorf("error creating creds: %w", err)
- }
-
- opts := []grpc.DialOption{
- grpc.WithTransportCredentials(credentials.NewClientTLSFromCert(
- cp, "",
- )),
- grpc.WithPerRPCCredentials(macCred),
- grpc.WithBlock(),
- }
- ctxt, cancel := context.WithTimeout(context.Background(), timeout)
- defer cancel()
- conn, err := grpc.DialContext(ctxt, hostPort, opts...)
- if err != nil {
- return nil, fmt.Errorf("unable to connect to RPC server: %w",
- err)
- }
-
- return conn, nil
-}
-
// fetchOutpointInfoFn looks up the wallet's local knowledge of an outpoint.
// Mirrors lnwallet.WalletController.FetchOutpointInfo so the helper below can
// be exercised in unit tests without standing up a full wallet.
### lnwallet/rpcwallet/rpcwallet_test.go
@@ -2,14 +2,21 @@ package rpcwallet
import (
"bytes"
+ "crypto/sha256"
"errors"
"testing"
+ "time"
+ "github.com/btcsuite/btcd/btcec/v2"
+ "github.com/btcsuite/btcd/btcec/v2/ecdsa"
"github.com/btcsuite/btcd/chainhash/v2"
"github.com/btcsuite/btcd/psbt/v2"
"github.com/btcsuite/btcd/txscript/v2"
"github.com/btcsuite/btcd/wire/v2"
"github.com/lightningnetwork/lnd/input"
+ "github.com/lightningnetwork/lnd/keychain"
+ "github.com/lightningnetwork/lnd/lnrpc/signrpc"
+ "github.com/lightningnetwork/lnd/lnrpc/watchonlyrpc"
"github.com/lightningnetwork/lnd/lnwallet"
"github.com/stretchr/testify/require"
)
@@ -229,3 +236,69 @@ func TestPopulateNonSignedInputWitnessUtxosEmptyPkScript(t *testing.T) {
require.Nil(t, packet.Inputs[0].WitnessUtxo)
}
+
+// TestRPCKeyRingUsesRequestTimeoutForInboundSigner verifies that a zero startup
+// timeout for inbound signers does not leak into per-request RPC contexts.
+func TestRPCKeyRingUsesRequestTimeoutForInboundSigner(t *testing.T) {
+ t.Parallel()
+
+ const requestTimeout = 2 * time.Second
+
+ // Set the inbound connection's startup timeout to 0 to model the
+ // supported "wait forever for the signer to connect" configuration.
+ // This test then verifies that this value remains scoped to startup
+ // waiting and is not used as the per-request RPC timeout.
+ conn := NewInboundConnection(requestTimeout, 0)
+ stream, runErrChan := setupNewStream(t, conn.SignCoordinator)
+
+ keyRing, err := NewRPCKeyRing(nil, nil, conn, nil)
+ require.NoError(t, err)
+
+ // Prove the constructor copied the request timeout, not the startup
+ // timeout, into the RPC key ring's per-request timeout field.
+ require.Equal(t, requestTimeout, keyRing.rpcTimeout)
+
+ msg := []byte("rpcwallet request timeout check")
+ msgDigest := sha256.Sum256(msg)
+
+ privKey, err := btcec.NewPrivateKey()
+ require.NoError(t, err)
+
+ expectedSig := ecdsa.Sign(privKey, msgDigest[:]).Serialize()
+
+ signErrChan := make(chan error, 1)
+ go func() {
+ _, err := keyRing.SignMessage(
+ keychain.KeyLocator{}, msg, false,
+ )
+ signErrChan <- err
+ }()
+
+ // If rpcTimeout were 0 here, the SignMessage call above would create a
+ // context that is already expired and would return before any request
+ // could be sent over the stream. Receiving a request here therefore
+ // proves the call is using a non-zero per-request timeout.
+ req, err := getRequest(stream)
+ require.NoError(t, err)
+ require.NotNil(t, req.GetSignMessageReq())
+
+ sResp := &watchonlyrpc.SignCoordinatorResponse_SignMessageResp{
+ SignMessageResp: &signrpc.SignMessageResp{
+ Signature: expectedSig,
+ },
+ }
+
+ // Complete the request successfully. A nil error below proves the
+ // signing path did not fail early with context.DeadlineExceeded.
+ stream.sendResponse(&watchonlyrpc.SignCoordinatorResponse{
+ RefRequestId: 2,
+ SignResponseType: sResp,
+ })
+
+ require.NoError(t, <-signErrChan)
+
+ stream.Cancel()
+ require.Equal(t, ErrStreamCanceled, <-runErrChan)
+
+ conn.Stop()
+}
### lnwallet/rpcwallet/sign_coordinator.go
@@ -0,0 +1,1121 @@
+package rpcwallet
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/lightningnetwork/lnd/lnrpc/signrpc"
+ "github.com/lightningnetwork/lnd/lnrpc/walletrpc"
+ "github.com/lightningnetwork/lnd/lnrpc/watchonlyrpc"
+ "github.com/lightningnetwork/lnd/lnutils"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/status"
+)
+
+var (
+ // ErrRequestTimeout is the error that's returned if we time out while
+ // waiting for a response from the remote signer.
+ ErrRequestTimeout = errors.New("remote signer response timeout reached")
+
+ // ErrConnectTimeout is the error that's returned if we time out while
+ // waiting for the remote signer to connect.
+ ErrConnectTimeout = errors.New("timed out when waiting for remote " +
+ "signer to connect")
+
+ // ErrMultipleConnections is the error that's returned if another
+ // remote signer attempts to connect while we already have one
+ // connected.
+ ErrMultipleConnections = errors.New("only one remote signer can be " +
+ "connected")
+
+ // ErrNotConnected is the error that's returned if the remote signer
+ // closes the stream or we encounter an error when receiving over the
+ // stream.
+ ErrNotConnected = errors.New("the remote signer is no longer connected")
+
+ // ErrUnexpectedResponse is the error that's returned if the response
+ // with the expected request ID from the remote signer is of an
+ // unexpected type.
+ ErrUnexpectedResponse = errors.New("unexpected response type")
+)
+
+// requestRetryPolicy controls whether a sign coordinator request may be
+// replayed after a transient disconnect.
+type requestRetryPolicy uint8
+
+const (
+ // noRequestRetry marks requests that must not be replayed
+ // automatically after a disconnect because they may be stateful.
+ noRequestRetry requestRetryPolicy = iota
+
+ // retryOnDisconnect marks requests that are safe to replay after a
+ // disconnect before their response is received.
+ retryOnDisconnect
+)
+
+// requestInfo tracks the response delivery state for a single in-flight
+// request sent to the remote signer.
+type requestInfo struct {
+ // respChan is the per-request response channel that StartReceiving uses
+ // to deliver the remote signer response back to the waiting request.
+ respChan chan *RSResponse
+
+ // respQuit is closed when the waiting request context is canceled or
+ // completed. StartReceiving uses this to avoid sending a response to a
+ // request that is no longer waiting for it.
+ respQuit <-chan struct{}
+}
+
+// SignCoordinator is an implementation of the signrpc.SignerClient and the
+// walletrpc.WalletKitClient interfaces that passes on all requests to a remote
+// signer. It is used by the watch-only wallet to delegate any signing or ECDH
+// operations to a remote node over a
+// watchonlyrpc.WatchOnly_SignCoordinatorStreamsServer stream. The stream is set
+// up by the remote signer when it connects to the watch-only wallet, which
+// should execute the Run method.
+type SignCoordinator struct {
+ // nextRequestID keeps track of the next request ID that should
+ // be used when sending a request to the remote signer.
+ nextRequestID atomic.Uint64
+
+ // stream is a bi-directional stream between us and the remote signer.
+ stream StreamServer
+
+ // responses is a map of request IDs to response channels and request
+ // lifecycle channels. This map
+ // should be populated with a response channel for each request that has
+ // been sent to the remote signer. The response channel should be
+ // inserted into the map before the request is sent.
+ // Any response received over the stream that does not have an
+ // associated response channel in this map is ignored.
+ // The response channel should be removed from the map when the response
+ // has been received and processed.
+ responses *lnutils.SyncMap[uint64, requestInfo]
+
+ // receiveErrChan is used to signal that the stream with the remote
+ // signer has errored, and we can no longer process responses.
+ receiveErrChan chan error
+
+ // disconnected is closed when either party terminates and signals to
+ // any pending requests that we'll no longer process the response for
+ // that request.
+ disconnected chan struct{}
+
+ // quit is closed when lnd is shutting down.
+ quit chan struct{}
+
+ // clientReady is closed and sent over when the remote signer is
+ // connected and ready to accept requests (after the initial handshake).
+ clientReady chan struct{}
+
+ // clientConnected is true if a remote signer is currently connected.
+ clientConnected bool
+
+ // requestTimeout is the maximum time we will wait for a response from
+ // the remote signer.
+ requestTimeout time.Duration
+
+ // connectionTimeout is the maximum time we will wait for the remote
+ // signer to connect.
+ connectionTimeout time.Duration
+
+ // sendMu serializes stream send operations, since gRPC stream Send is
+ // not safe for concurrent use from multiple goroutines. We use a
+ // separate mutex to keep the locking scope minimal and avoid locking
+ // the rest of the struct while sending messages.
+ sendMu sync.Mutex
+
+ mu sync.Mutex
+
+ wg sync.WaitGroup
+}
+
+// A compile time assertion to ensure SignCoordinator meets the
+// RemoteSignerRequests interface.
+var _ RemoteSignerRequests = (*SignCoordinator)(nil)
+
+// NewSignCoordinator creates a new instance of the SignCoordinator.
+func NewSignCoordinator(requestTimeout time.Duration,
+ connectionTimeout time.Duration) *SignCoordinator {
+
+ respsMap := &lnutils.SyncMap[uint64, requestInfo]{}
+
+ s := &SignCoordinator{
+ responses: respsMap,
+ receiveErrChan: make(chan error, 1),
+ clientReady: make(chan struct{}),
+ clientConnected: false,
+ quit: make(chan struct{}),
+ requestTimeout: requestTimeout,
+ connectionTimeout: connectionTimeout,
+ // Note that the disconnected channel is not initialized here,
+ // as no code listens to it until the Run method has been called
+ // and set the field.
+ }
+
+ // We initialize the atomic nextRequestID to the handshakeRequestID, as
+ // requestID 1 is reserved for the initial handshake by the remote
+ // signer.
+ s.nextRequestID.Store(handshakeRequestID)
+
+ return s
+}
+
+// Run starts the SignCoordinator and blocks until the remote signer either
+// disconnects, the SignCoordinator is shut down, or an error occurs.
+func (s *SignCoordinator) Run(stream StreamServer) error {
+ s.mu.Lock()
+
+ select {
+ case <-s.quit:
+ s.mu.Unlock()
+ return ErrShuttingDown
+
+ default:
+ }
+
+ if s.clientConnected {
+ s.mu.Unlock()
+
+ // If we already have a stream, we error out as we can only have
+ // one connection at a time.
+ return ErrMultipleConnections
+ }
+
+ s.wg.Add(1)
+ defer s.wg.Done()
+
+ s.clientConnected = true
+ defer func() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ // When `Run` returns, we set the clientConnected field to false
+ // to allow a new remote signer connection to be set up.
+ s.clientConnected = false
+ s.stream = nil
+ }()
+
+ s.stream = stream
+
+ s.disconnected = make(chan struct{})
+ defer close(s.disconnected)
+
+ s.mu.Unlock()
+
+ // The handshake must be completed before we can start sending requests
+ // to the remote signer.
+ err := s.handshake(stream)
+ if err != nil {
+ return err
+ }
+
+ log.Infof("Remote signer connected and ready")
+
+ close(s.clientReady)
+ defer func() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ // We create a new clientReady channel, once this function
+ // has exited, to ensure that a new remote signer connection can
+ // be set up.
+ s.clientReady = make(chan struct{})
+ }()
+
+ // Now let's start the main receiving loop, which will receive all
+ // responses to our requests from the remote signer!
+ // We start the receiving loop in a goroutine to ensure that this
+ // function exits if the SignCoordinator is shut down (i.e. the s.quit
+ // channel is closed). Returning from this function will cause the
+ // stream to be closed, which in turn will cause the receiving loop to
+ // exit.
+ s.wg.Add(1)
+ go s.StartReceiving()
+
+ select {
+ case err := <-s.receiveErrChan:
+ return err
+
+ case <-s.quit:
+ return ErrShuttingDown
+ }
+}
+
+// Stop shuts down the SignCoordinator and waits until the main receiving loop
+// has exited and all pending requests have been terminated.
+func (s *SignCoordinator) Stop() {
+ log.Infof("Stopping Sign Coordinator")
+ defer log.Debugf("Sign coordinator stopped")
+
+ // We lock the mutex before closing the quit channel to ensure that we
+ // can't get a concurrent request into the SignCoordinator while we're
+ // stopping it. That will ensure that the s.wg.Wait() call below will
+ // always wait for any ongoing requests to finish before we return.
+ s.mu.Lock()
+
+ close(s.quit)
+
+ s.mu.Unlock()
+
+ s.wg.Wait()
+}
+
+// handshake performs the initial handshake with the remote signer. This must
+// be done before any other requests are sent to the remote signer.
+func (s *SignCoordinator) handshake(stream StreamServer) error {
+ var (
+ registerChan = make(chan *watchonlyrpc.SignerRegistration)
+ registerDoneChan = make(chan struct{})
+ errChan = make(chan error)
+ )
+
+ // Create a context with a timeout using the context from the stream as
+ // the parent context. This ensures that we'll exit if either the stream
+ // is closed by the remote signer or if we time out.
+ ctxt, cancel := context.WithTimeout(
+ stream.Context(), s.requestTimeout,
+ )
+ defer cancel()
+
+ // Read the first message in a goroutine because the Recv method blocks
+ // until the message arrives.
+ s.wg.Add(1)
+ go func() {
+ defer s.wg.Done()
+
+ msg, err := stream.Recv()
+ if err != nil {
+ select {
+ case errChan <- err:
+ case <-ctxt.Done():
+ }
+
+ return
+ }
+
+ if msg.GetRefRequestId() != handshakeRequestID {
+ err = fmt.Errorf("initial request ID must be %d, "+
+ "but is: %d", handshakeRequestID,
+ msg.GetRefRequestId())
+
+ select {
+ case errChan <- err:
+ case <-ctxt.Done():
+ }
+
+ return
+ }
+
+ switch req := msg.GetSignResponseType().(type) {
+ case *watchonlyrpc.SignCoordinatorResponse_SignerRegistration:
+ select {
+ case registerChan <- req.SignerRegistration:
+ case <-ctxt.Done():
+ }
+
+ return
+
+ default:
+ err := fmt.Errorf("expected registration message, "+
+ "but got: %T", req)
+
+ select {
+ case errChan <- err:
+ case <-ctxt.Done():
+ }
+
+ return
+ }
+ }()
+
+ // Wait for the initial message to arrive or time out if it takes too
+ // long. The initial message must be a registration message from the
+ // remote signer.
+ select {
+ case signerRegistration := <-registerChan:
+ // TODO(viktor): This could be extended to validate the version
+ // of the remote signer in the future.
+ if signerRegistration.GetRegistrationInfo() == "" {
+ return errors.New("invalid remote signer " +
+ "registration info")
+ }
+
+ // Todo(viktor): The RegistrationChallenge in the
+ // signerRegistration should likely also be signed here.
+
+ case err := <-errChan:
+ return fmt.Errorf("error receiving initial remote signer "+
+ "registration message: %v", err)
+
+ case <-s.quit:
+ return ErrShuttingDown
+
+ case <-ctxt.Done():
+ return ctxt.Err()
+ }
+
+ complete := &watchonlyrpc.RegistrationResponse_RegistrationComplete{
+ // TODO(viktor): The signature should be generated by signing
+ // the RegistrationChallenge contained in the SignerRegistration
+ // message in the future.
+ // The RegistrationInfo could also be extended to include info
+ // about the watch-only node in the future.
+ RegistrationComplete: &watchonlyrpc.RegistrationComplete{
+ Signature: "",
+ RegistrationInfo: "watch-only registration info",
+ },
+ }
+ // Send a message to the client to indicate that the registration has
+ // successfully completed.
+ req := &watchonlyrpc.SignCoordinatorRequest_RegistrationResponse{
+ RegistrationResponse: &watchonlyrpc.RegistrationResponse{
+ RegistrationResponseType: complete,
+ },
+ }
+
+ regCompleteMsg := &watchonlyrpc.SignCoordinatorRequest{
+ RequestId: handshakeRequestID,
+ SignRequestType: req,
+ }
+
+ // Send the message in a goroutine because the Send method blocks until
+ // the message is read by the client.
+ s.wg.Add(1)
+ go func() {
+ defer s.wg.Done()
+
+ err := stream.Send(regCompleteMsg)
+ if err != nil {
+ select {
+ case errChan <- err:
+ case <-ctxt.Done():
+ }
+
+ return
+ }
+
+ close(registerDoneChan)
+ }()
+
+ select {
+ case err := <-errChan:
+ return fmt.Errorf("error sending registration complete "+
+ " message to remote signer: %v", err)
+
+ case <-ctxt.Done():
+ return ctxt.Err()
+
+ case <-s.quit:
+ return ErrShuttingDown
+
+ case <-registerDoneChan:
+ }
+
+ return nil
+}
+
+// StartReceiving is the main receive loop that receives responses from the
+// remote signer. Responses must have a RequestID that corresponds to requests
+// which are waiting for a response; otherwise, the response is ignored.
+func (s *SignCoordinator) StartReceiving() {
+ defer s.wg.Done()
+
+ stream, disconnected, err := s.activeStreamSnapshot()
+ if err != nil {
+ // Send the error over the error channel, so that the
+ // main Run method can return the error.
+ s.receiveErrChan <- err
+
+ return
+ }
+
+ for {
+ // If we've been disconnected, we can't receive any longer.
+ select {
+ case <-disconnected:
+ return
+ default:
+ }
+
+ resp, err := stream.Recv()
+ if err != nil {
+ select {
+ // If we've already shut down, the main Run method will
+ // not be able to receive any error sent over the error
+ // channel. So we just return.
+ case <-s.quit:
+
+ // Send the error over the error channel, so that the
+ // main Run method can return the error.
+ case s.receiveErrChan <- err:
+ }
+
+ return
+ }
+
+ reqInfo, ok := s.responses.Load(resp.GetRefRequestId())
+
+ if ok {
+ select {
+ // We should always be able to send over the response
+ // channel, as the channel allows for a buffer of 1, and
+ // we shouldn't have multiple requests and responses for
+ // the same request ID.
+ case reqInfo.respChan <- resp:
+
+ case <-s.quit:
+ return
+
+ // This request was canceled, do not try to send any
+ // response for this request ID.
+ case <-reqInfo.respQuit:
+
+ // The timeout case should be unreachable, as we should
+ // always be able to send 1 response over the response
+ // channel.
+ // We keep this case just to avoid a scenario where the
+ // receive loop would be blocked if we receive multiple
+ // responses for the same request ID.
+ case <-time.After(s.requestTimeout):
+ }
+ }
+
+ // If there's no response channel, the thread waiting for the
+ // response has most likely timed out. We therefore ignore the
+ // response. The other scenario where we don't have a response
+ // channel would be if we received a response for a request that
+ // we didn't send. This should never happen, but if it does, we
+ // ignore the response.
+
+ select {
+ case <-s.quit:
+ return
+ default:
+ }
+ }
+}
+
+// WaitUntilConnected waits until the remote signer has connected. If the remote
+// signer does not connect within the configured connection timeout, or if the
+// passed context is canceled, an error is returned.
+func (s *SignCoordinator) WaitUntilConnected(ctx context.Context) error {
+ // As the Run method will redefine the clientReady channel once it
+ // returns, we need copy the pointer to the current clientReady channel
+ // to ensure that we're waiting for the correct channel, and to avoid
+ // a data race.
+ s.mu.Lock()
+ currentClientReady := s.clientReady
+ s.mu.Unlock()
+
+ var timeout <-chan time.Time
+ if s.connectionTimeout > 0 {
+ timer := time.NewTimer(s.connectionTimeout)
+ defer timer.Stop()
+ timeout = timer.C
+ }
+
+ select {
+ case <-currentClientReady:
+ return nil
+
+ case <-s.quit:
+ return ErrShuttingDown
+
+ case <-ctx.Done():
+ return ctx.Err()
+
+ case <-timeout:
+ return ErrConnectTimeout
+ }
+}
+
+// activeStreamSnapshot returns the currently active stream together with the
+// disconnect signal that belongs to the same stream generation.
+func (s *SignCoordinator) activeStreamSnapshot() (StreamServer,
+ <-chan struct{}, error) {
+
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ select {
+ case <-s.quit:
+ return nil, nil, ErrShuttingDown
+ default:
+ }
+
+ if !s.clientConnected || s.stream == nil || s.disconnected == nil {
+ return nil, nil, ErrNotConnected
+ }
+
+ return s.stream, s.disconnected, nil
+}
+
+// createResponseChannel creates a response channel for the given request ID and
+// inserts it into the responses map. The function returns a cleanup function
+// which removes the channel from the responses map, and the caller must ensure
+// that this cleanup function is executed once the thread that's waiting for
+// the response is done.
+func (s *SignCoordinator) createResponseChannel(requestID uint64,
+ respQuit <-chan struct{}) func() {
+
+ // Create a new response channel.
+ respChan := make(chan *RSResponse, 1)
+
+ // Insert the response channel and request lifecycle channel into the
+ // map.
+ s.responses.Store(requestID, requestInfo{
+ respChan: respChan,
+ respQuit: respQuit,
+ })
+
+ // Create a cleanup function that will delete the response channel.
+ return func() {
+ select {
+ // If we have timed out, there could be a very unlikely
+ // scenario where we did receive a response before we managed to
+ // grab the lock in the cleanup func. In that case, we'll just
+ // ignore the response. We should still clean up the response
+ // channel though.
+ case <-respChan:
+ default:
+ }
+
+ s.responses.Delete(requestID)
+ }
+}
+
+// getResponse waits for a response with the given request ID and returns the
+// response if it is received. If the corresponding response from the remote
+// signer is a SignerError, the error message is returned. If the response is
+// not received within the given timeout, an error is returned.
+//
+// Note: Before calling this function, the caller must have created a response
+// channel for the request ID.
+func (s *SignCoordinator) getResponse(ctx context.Context, requestID uint64,
+ disconnected <-chan struct{}) (*RSResponse, error) {
+
+ respChan, ok := s.responses.Load(requestID)
+
+ // Verify that we have a response channel for the request ID.
+ if !ok {
+ // It should be impossible to reach this case, as we create the
+ // response channel before sending the request.
+ return nil, fmt.Errorf("no response channel found for "+
+ "request ID %d", requestID)
+ }
+
+ // Wait for the response to arrive.
+ select {
+ case resp, ok := <-respChan.respChan:
+ if !ok {
+ // If the response channel was closed, we return an
+ // error as the receiving thread must have timed out
+ // before we managed to grab the response.
+ return nil, ErrRequestTimeout
+ }
+
+ // a temp type alias to limit the length of the line below.
+ type sErr = watchonlyrpc.SignCoordinatorResponse_SignerError
+
+ // If the response is an error, we return the error message.
+ if errorResp, ok := resp.GetSignResponseType().(*sErr); ok {
+ errStr := errorResp.SignerError.GetError()
+
+ log.Debugf("Received an error response from remote "+
+ "signer for request ID %d. Error: %v",
+ requestID, errStr)
+
+ return nil, errors.New(errStr)
+ }
+
+ log.Debugf("Received remote signer %T response for request "+
+ "ID %d", resp.GetSignResponseType(), requestID)
+
+ log.Tracef("Remote signer response content: %v",
+ formatSignCoordinatorMsg(resp))
+
+ return resp, nil
+
+ case <-disconnected:
+ log.Debugf("Stopped waiting for remote signer response for "+
+ "request ID %d as the stream has been closed",
+ requestID)
+
+ return nil, ErrNotConnected
+
+ case <-s.quit:
+ log.Debugf("Stopped waiting for remote signer response for "+
+ "request ID %d as we're shutting down", requestID)
+
+ return nil, ErrShuttingDown
+
+ case <-ctx.Done():
+ log.Debugf("Context cancelled while waiting for remote signer "+
+ "response for request ID %d", requestID)
+
+ return nil, ctx.Err()
+
+ case <-time.After(s.requestTimeout):
+ log.Debugf("Remote signer response timed out for request ID %d",
+ requestID)
+
+ return nil, ErrRequestTimeout
+ }
+}
+
+// registerRequest registers a new request with the SignCoordinator, ensuring it
+// awaits the handling of the request before shutting down. The function returns
+// a Done function that must be executed once the request has been handled.
+func (s *SignCoordinator) registerRequest() (func(), error) {
+ // We lock the mutex to ensure that we can't have a race where we'd
+ // register a request while shutting down.
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ select {
+ case <-s.quit:
+ return nil, ErrShuttingDown
+ default:
+ }
+
+ s.wg.Add(1)
+
+ return func() {
+ s.wg.Done()
+ }, nil
+}
+
+// Ping sends a ping request to the remote signer and waits for a pong response.
+func (s *SignCoordinator) Ping(ctx context.Context,
+ timeout time.Duration) (bool, error) {
+
+ req := &watchonlyrpc.SignCoordinatorRequest_Ping{
+ Ping: true,
+ }
+
+ // As we're pinging, we will time out the request if we don't receive a
+ // response within the timeout.
+ ctxt, cancel := context.WithTimeout(ctx, timeout)
+ defer cancel()
+
+ return processRequest(
+ ctxt, s,
+ func(reqId uint64) watchonlyrpc.SignCoordinatorRequest {
+ return watchonlyrpc.SignCoordinatorRequest{
+ RequestId: reqId,
+ SignRequestType: req,
+ }
+ },
+ func(resp *RSResponse) bool {
+ return resp.GetPong()
+ },
+ retryOnDisconnect,
+ )
+}
+
+// DeriveSharedKey sends a SharedKeyRequest to the remote signer and waits for
+// the corresponding response.
+//
+// NOTE: This is part of the RemoteSignerRequests interface.
+func (s *SignCoordinator) DeriveSharedKey(ctx context.Context,
+ in *signrpc.SharedKeyRequest,
+ _ ...grpc.CallOption) (*signrpc.SharedKeyResponse, error) {
+
+ req := &watchonlyrpc.SignCoordinatorRequest_SharedKeyRequest{
+ SharedKeyRequest: in,
+ }
+
+ return processRequest(
+ ctx, s,
+ func(reqId uint64) watchonlyrpc.SignCoordinatorRequest {
+ return watchonlyrpc.SignCoordinatorRequest{
+ RequestId: reqId,
+ SignRequestType: req,
+ }
+ },
+ func(resp *RSResponse) *signrpc.SharedKeyResponse {
+ return resp.GetSharedKeyResponse()
+ },
+ noRequestRetry,
+ )
+}
+
+// MuSig2Cleanup sends a MuSig2CleanupRequest to the remote signer and waits for
+// the corresponding response.
+//
+// NOTE: This is part of the RemoteSignerRequests interface.
+func (s *SignCoordinator) MuSig2Cleanup(ctx context.Context,
+ in *signrpc.MuSig2CleanupRequest,
+ _ ...grpc.CallOption) (*signrpc.MuSig2CleanupResponse, error) {
+
+ req := &watchonlyrpc.SignCoordinatorRequest_MuSig2CleanupRequest{
+ MuSig2CleanupRequest: in,
+ }
+
+ return processRequest(
+ ctx, s,
+ func(reqId uint64) watchonlyrpc.SignCoordinatorRequest {
+ return watchonlyrpc.SignCoordinatorRequest{
+ RequestId: reqId,
+ SignRequestType: req,
+ }
+ },
+ func(resp *RSResponse) *signrpc.MuSig2CleanupResponse {
+ return resp.GetMuSig2CleanupResponse()
+ },
+ noRequestRetry,
+ )
+}
+
+// MuSig2CombineSig sends a MuSig2CombineSigRequest to the remote signer and
+// waits for the corresponding response.
+//
+// NOTE: This is part of the RemoteSignerRequests interface.
+func (s *SignCoordinator) MuSig2CombineSig(ctx context.Context,
+ in *signrpc.MuSig2CombineSigRequest,
+ _ ...grpc.CallOption) (*signrpc.MuSig2CombineSigResponse, error) {
+
+ req := &watchonlyrpc.SignCoordinatorRequest_MuSig2CombineSigRequest{
+ MuSig2CombineSigRequest: in,
+ }
+
+ return processRequest(
+ ctx, s,
+ func(reqId uint64) watchonlyrpc.SignCoordinatorRequest {
+ return watchonlyrpc.SignCoordinatorRequest{
+ RequestId: reqId,
+ SignRequestType: req,
+ }
+ },
+ func(resp *RSResponse) *signrpc.MuSig2CombineSigResponse {
+ return resp.GetMuSig2CombineSigResponse()
+ },
+ noRequestRetry,
+ )
+}
+
+// MuSig2CreateSession sends a MuSig2SessionRequest to the remote signer and
+// waits for the corresponding response.
+//
+// NOTE: This is part of the RemoteSignerRequests interface.
+func (s *SignCoordinator) MuSig2CreateSession(ctx context.Context,
+ in *signrpc.MuSig2SessionRequest,
+ _ ...grpc.CallOption) (*signrpc.MuSig2SessionResponse, error) {
+
+ req := &watchonlyrpc.SignCoordinatorRequest_MuSig2SessionRequest{
+ MuSig2SessionRequest: in,
+ }
+
+ return processRequest(
+ ctx, s,
+ func(reqId uint64) watchonlyrpc.SignCoordinatorRequest {
+ return watchonlyrpc.SignCoordinatorRequest{
+ RequestId: reqId,
+ SignRequestType: req,
+ }
+ },
+ func(resp *RSResponse) *signrpc.MuSig2SessionResponse {
+ return resp.GetMuSig2SessionResponse()
+ },
+ noRequestRetry,
+ )
+}
+
+// MuSig2RegisterNonces sends a MuSig2RegisterNoncesRequest to the remote signer
+// and waits for the corresponding response.
+//
+// NOTE: This is part of the RemoteSignerRequests interface.
+func (s *SignCoordinator) MuSig2RegisterNonces(ctx context.Context,
+ in *signrpc.MuSig2RegisterNoncesRequest,
+ _ ...grpc.CallOption) (*signrpc.MuSig2RegisterNoncesResponse,
+ error) {
+
+ req := &watchonlyrpc.SignCoordinatorRequest_MuSig2RegisterNoncesRequest{
+ MuSig2RegisterNoncesRequest: in,
+ }
+
+ type muSig2RegisterNoncesResp = *signrpc.MuSig2RegisterNoncesResponse
+
+ return processRequest(
+ ctx, s,
+ func(reqId uint64) watchonlyrpc.SignCoordinatorRequest {
+ return watchonlyrpc.SignCoordinatorRequest{
+ RequestId: reqId,
+ SignRequestType: req,
+ }
+ },
+ func(resp *RSResponse) muSig2RegisterNoncesResp {
+ return resp.GetMuSig2RegisterNoncesResponse()
+ },
+ noRequestRetry,
+ )
+}
+
+// MuSig2RegisterCombinedNonce sends a MuSig2RegisterCombinedNonce to
+// the remote signer and waits for the corresponding response.
+func (s *SignCoordinator) MuSig2RegisterCombinedNonce(ctx context.Context,
+ in *signrpc.MuSig2RegisterCombinedNonceRequest,
+ _ ...grpc.CallOption) (*signrpc.MuSig2RegisterCombinedNonceResponse,
+ error) {
+
+ req := &watchonlyrpc.SignCoordinatorRequest_MuSig2CombinedNoncesReq{
+ MuSig2CombinedNoncesReq: in,
+ }
+
+ type muSig2RegCombNResp = *signrpc.MuSig2RegisterCombinedNonceResponse
+
+ return processRequest(
+ ctx, s,
+ func(reqId uint64) watchonlyrpc.SignCoordinatorRequest {
+ return watchonlyrpc.SignCoordinatorRequest{
+ RequestId: reqId,
+ SignRequestType: req,
+ }
+ },
+ func(resp *RSResponse) muSig2RegCombNResp {
+ return resp.GetMuSig2CombNoncesResp()
+ },
+ noRequestRetry,
+ )
+}
+
+// MuSig2GetCombinedNonce sends a MuSig2GetCombinedNonceRequest to the
+// remote signer and waits for the corresponding response.
+func (s *SignCoordinator) MuSig2GetCombinedNonce(ctx context.Context,
+ in *signrpc.MuSig2GetCombinedNonceRequest,
+ _ ...grpc.CallOption) (*signrpc.MuSig2GetCombinedNonceResponse, error) {
+
+ req := &watchonlyrpc.SignCoordinatorRequest_MuSig2GetCombinedNoncesReq{
+ MuSig2GetCombinedNoncesReq: in,
+ }
+
+ type muSig2GetCombNonceResp = *signrpc.MuSig2GetCombinedNonceResponse
+
+ return processRequest(
+ ctx, s,
+ func(reqId uint64) watchonlyrpc.SignCoordinatorRequest {
+ return watchonlyrpc.SignCoordinatorRequest{
+ RequestId: reqId,
+ SignRequestType: req,
+ }
+ },
+ func(resp *RSResponse) muSig2GetCombNonceResp {
+ return resp.GetMuSig2GetCombNoncesResp()
+ },
+ noRequestRetry,
+ )
+}
+
+// MuSig2Sign sends a MuSig2SignRequest to the remote signer and waits for the
+// corresponding response.
+//
+// NOTE: This is part of the RemoteSignerRequests interface.
+func (s *SignCoordinator) MuSig2Sign(ctx context.Context,
+ in *signrpc.MuSig2SignRequest,
+ _ ...grpc.CallOption) (*signrpc.MuSig2SignResponse, error) {
+
+ req := &watchonlyrpc.SignCoordinatorRequest_MuSig2SignRequest{
+ MuSig2SignRequest: in,
+ }
+
+ return processRequest(
+ ctx, s,
+ func(reqId uint64) watchonlyrpc.SignCoordinatorRequest {
+ return watchonlyrpc.SignCoordinatorRequest{
+ RequestId: reqId,
+ SignRequestType: req,
+ }
+ },
+ func(resp *RSResponse) *signrpc.MuSig2SignResponse {
+ return resp.GetMuSig2SignResponse()
+ },
+ noRequestRetry,
+ )
+}
+
+// SignMessage sends a SignMessageReq to the remote signer and waits for the
+// corresponding response.
+//
+// NOTE: This is part of the RemoteSignerRequests interface.
+func (s *SignCoordinator) SignMessage(ctx context.Context,
+ in *signrpc.SignMessageReq,
+ _ ...grpc.CallOption) (*signrpc.SignMessageResp, error) {
+
+ req := &watchonlyrpc.SignCoordinatorRequest_SignMessageReq{
+ SignMessageReq: in,
+ }
+
+ return processRequest(
+ ctx, s,
+ func(reqId uint64) watchonlyrpc.SignCoordinatorRequest {
+ return watchonlyrpc.SignCoordinatorRequest{
+ RequestId: reqId,
+ SignRequestType: req,
+ }
+ },
+ func(resp *RSResponse) *signrpc.SignMessageResp {
+ return resp.GetSignMessageResp()
+ },
+ retryOnDisconnect,
+ )
+}
+
+// SignPsbt sends a SignPsbtRequest to the remote signer and waits for the
+// corresponding response.
+//
+// NOTE: This is part of the RemoteSignerRequests interface.
+func (s *SignCoordinator) SignPsbt(ctx context.Context,
+ in *walletrpc.SignPsbtRequest,
+ _ ...grpc.CallOption) (*walletrpc.SignPsbtResponse, error) {
+
+ req := &watchonlyrpc.SignCoordinatorRequest_SignPsbtRequest{
+ SignPsbtRequest: in,
+ }
+
+ return processRequest(
+ ctx, s,
+ func(reqId uint64) watchonlyrpc.SignCoordinatorRequest {
+ return watchonlyrpc.SignCoordinatorRequest{
+ RequestId: reqId,
+ SignRequestType: req,
+ }
+ },
+ func(resp *RSResponse) *walletrpc.SignPsbtResponse {
+ return resp.GetSignPsbtResponse()
+ },
+ retryOnDisconnect,
+ )
+}
+
+// processRequestAttempt performs one attempt to send a request to the remote
+// signer and receive its response.
+//
+// Return values:
+// 1. shouldRetry: true if the caller may safely retry this request after
+// a transient disconnect.
+// 2. R: the extracted RPC response when the request succeeded. Undefined
+// when shouldRetry is true or err is non-nil.
+// 3. error: a terminal error for this request attempt. If non-nil, the
+// caller should stop retrying and return the error.
+func processRequestAttempt[R comparable](ctx context.Context,
+ s *SignCoordinator,
+ generateRequest func(uint64) watchonlyrpc.SignCoordinatorRequest,
+ extractResponse func(*RSResponse) R, retryPolicy requestRetryPolicy) (
+ bool, R, error) {
+
+ var zero R
+
+ // Wait for the remote signer to connect. If the remote signer doesn't
+ // connect within the configured connection timeout, or before the ctx
+ // times out, we will return an error.
+ err := s.WaitUntilConnected(ctx)
+ if err != nil {
+ return false, zero, err
+ }
+
+ stream, disconnected, err := s.activeStreamSnapshot()
+ if retryPolicy == retryOnDisconnect && errors.Is(err, ErrNotConnected) {
+ return true, zero, nil
+ }
+ if err != nil {
+ return false, zero, err
+ }
+
+ reqID := s.nextRequestID.Add(1)
+ req := generateRequest(reqID)
+
+ reqCtx, cancelReq := context.WithCancel(ctx)
+ cleanUpChannel := s.createResponseChannel(reqID, reqCtx.Done())
+ defer func() {
+ cancelReq()
+ cleanUpChannel()
+ }()
+
+ log.Debugf("Sending a %T to the remote signer with request ID %d",
+ req.SignRequestType, reqID)
+
+ log.Tracef("Request content: %v", formatSignCoordinatorMsg(&req))
+
+ // Send the request to the remote signer. Note that stream.Send is not
+ // safe for concurrent use and that we specifically lock the sendMu
+ // below and not general struct mutex, to keep the locking scope
+ // minimal.
+ s.sendMu.Lock()
+ err = stream.Send(&req)
+ s.sendMu.Unlock()
+
+ if err != nil {
+ st, isStatusError := status.FromError(err)
+ if retryPolicy == retryOnDisconnect &&
+ isStatusError && st.Code() == codes.Unavailable {
+
+ log.Debugf("Remote signer disconnected while sending "+
+ "request ID %d. Retrying request...", reqID)
+
+ return true, zero, nil
+ }
+
+ return false, zero, err
+ }
+
+ // Wait for the remote signer response for the given request. We will
+ // wait for the configured request timeout, or until the context is
+ // cancelled/timed out.
+ resp, err := s.getResponse(reqCtx, reqID, disconnected)
+ if retryPolicy == retryOnDisconnect && errors.Is(err, ErrNotConnected) {
+ log.Debugf("Remote signer disconnected while waiting for "+
+ "response for request ID %d. Retrying "+
+ "request...", reqID)
+
+ return true, zero, nil
+ }
+ if err != nil {
+ return false, zero, err
+ }
+
+ rpcResp := extractResponse(resp)
+ if rpcResp == zero {
+ return false, zero, ErrUnexpectedResponse
+ }
+
+ return false, rpcResp, nil
+}
+
+// processRequest is a generic function that sends a request to the remote
+// signer and waits for the corresponding response. If a timeout is set, the
+// function will limit the execution time of the entire function to the
+// specified timeout. If it is not set, configured timeouts will be used for
+// the individual operations within the function.
+func processRequest[R comparable](ctx context.Context, s *SignCoordinator,
+ generateRequest func(uint64) watchonlyrpc.SignCoordinatorRequest,
+ extractResponse func(*RSResponse) R,
+ retryPolicy requestRetryPolicy) (R, error) {
+
+ var zero R
+
+ done, err := s.registerRequest()
+ if err != nil {
+ return zero, err
+ }
+ defer done()
+
+ for {
+ shouldRetry, rpcResp, err := processRequestAttempt(
+ ctx, s, generateRequest, extractResponse, retryPolicy,
+ )
+ if err != nil {
+ return zero, err
+ }
+ if shouldRetry {
+ continue
+ }
+
+ return rpcResp, nil
+ }
+}
### lnwallet/rpcwallet/sign_coordinator_test.go
@@ -0,0 +1,1243 @@
+package rpcwallet
+
+import (
+ "context"
+ "errors"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/lightningnetwork/lnd/lnrpc/signrpc"
+ "github.com/lightningnetwork/lnd/lnrpc/watchonlyrpc"
+ "github.com/lightningnetwork/lnd/lntest/wait"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/metadata"
+ "google.golang.org/grpc/status"
+)
+
+// mockSCStream is a mock implementation of the
+// watchonlyrpc.WatchOnly_SignCoordinatorStreamsServer stream interface.
+type mockSCStream struct {
+ // sendChan is used to simulate requests sent over the stream from the
+ // sign coordinator to the remote signer.
+ sendChan chan *watchonlyrpc.SignCoordinatorRequest
+
+ // sendErrorChan is used to simulate requests sent over the stream from
+ // the sign coordinator to the remote signer.
+ sendErrorChan chan error
+
+ // recvChan is used to simulate responses sent over the stream from the
+ // remote signer to the sign coordinator.
+ recvChan chan *watchonlyrpc.SignCoordinatorResponse
+
+ // cancelChan is used to simulate a canceled stream.
+ cancelChan chan struct{}
+
+ ctx context.Context //nolint:containedctx
+}
+
+// newMockSCStream creates a new mock stream.
+func newMockSCStream() *mockSCStream {
+ return &mockSCStream{
+ sendChan: make(chan *watchonlyrpc.SignCoordinatorRequest),
+ sendErrorChan: make(chan error, 1),
+ recvChan: make(
+ chan *watchonlyrpc.SignCoordinatorResponse, 1,
+ ),
+ cancelChan: make(chan struct{}),
+ ctx: context.Background(),
+ }
+}
+
+// Send simulates a sent request from the sign coordinator to the remote signer
+// over the mock stream.
+func (ms *mockSCStream) Send(req *watchonlyrpc.SignCoordinatorRequest) error {
+ select {
+ case ms.sendChan <- req:
+ return nil
+
+ case err := <-ms.sendErrorChan:
+ return err
+ }
+}
+
+// Recv simulates a received response from the remote signer to the sign
+// coordinator over the mock stream.
+func (ms *mockSCStream) Recv() (*watchonlyrpc.SignCoordinatorResponse,
+ error) {
+
+ select {
+ case resp := <-ms.recvChan:
+ return resp, nil
+
+ case <-ms.cancelChan:
+ // To simulate a canceled stream, we return an error when the
+ // cancelChan is closed.
+ return nil, ErrStreamCanceled
+ }
+}
+
+// Mock implementations of various WatchOnly_SignCoordinatorStreamsServer
+// methods.
+func (ms *mockSCStream) RecvMsg(msg any) error { return nil }
+func (ms *mockSCStream) SendHeader(metadata.MD) error { return nil }
+func (ms *mockSCStream) SendMsg(m any) error { return nil }
+func (ms *mockSCStream) SetHeader(metadata.MD) error { return nil }
+func (ms *mockSCStream) SetTrailer(metadata.MD) {}
+
+// Context returns the context of the mock stream.
+func (ms *mockSCStream) Context() context.Context {
+ return ms.ctx
+}
+
+// Cancel closes the cancelChan to simulate a canceled stream.
+func (ms *mockSCStream) Cancel() {
+ close(ms.cancelChan)
+}
+
+// Helper function to simulate responses sent over the mock stream.
+func (ms *mockSCStream) sendResponse(
+ resp *watchonlyrpc.SignCoordinatorResponse) {
+
+ ms.recvChan <- resp
+}
+
+// setupSignCoordinator sets up a new SignCoordinator instance with a mock
+// stream to simulate communication with a remote signer. It also simulates the
+// handshake between the sign coordinator and the remote signer.
+func setupSignCoordinator(t *testing.T) (*SignCoordinator, *mockSCStream,
+ chan error) {
+
+ coordinator := NewSignCoordinator(2*time.Second, 3*time.Second)
+ stream, errChan := setupNewStream(t, coordinator)
+
+ return coordinator, stream, errChan
+}
+
+// setupNewStream sets up a new mock stream to simulate a communication with a
+// remote signer. It also simulates the handshake between the passed sign
+// coordinator and the remote signer.
+func setupNewStream(t *testing.T,
+ coordinator *SignCoordinator) (*mockSCStream, chan error) {
+
+ stream := newMockSCStream()
+
+ errChan := make(chan error)
+ go func() {
+ err := coordinator.Run(stream)
+ if err != nil {
+ errChan <- err
+ }
+ }()
+
+ signReg := &watchonlyrpc.SignerRegistration{
+ RegistrationChallenge: []byte("registrationChallenge"),
+ RegistrationInfo: "outboundSigner",
+ }
+
+ regType := &watchonlyrpc.SignCoordinatorResponse_SignerRegistration{
+ SignerRegistration: signReg,
+ }
+
+ registrationMsg := &watchonlyrpc.SignCoordinatorResponse{
+ RefRequestId: 1, // Request ID is always 1 for registration.
+ SignResponseType: regType,
+ }
+
+ stream.sendResponse(registrationMsg)
+
+ // Ensure that the sign coordinator responds with a registration
+ // complete message.
+ select {
+ case req := <-stream.sendChan:
+ require.Equal(t, uint64(1), req.GetRequestId())
+
+ comp := req.GetRegistrationResponse().GetRegistrationComplete()
+ require.NotNil(t, comp)
+
+ case <-time.After(time.Second):
+ require.Fail(
+ t, "registration complete message not received",
+ )
+ }
+
+ return stream, errChan
+}
+
+// getRequest is a helper function to get a request that has been sent from
+// the sign coordinator over the mock stream.
+func getRequest(s *mockSCStream) (*watchonlyrpc.SignCoordinatorRequest,
+ error) {
+
+ select {
+ case req := <-s.sendChan:
+ return req, nil
+
+ case <-time.After(time.Second):
+ return nil, ErrRequestTimeout
+ }
+}
+
+// TestPingRequests tests that the sign coordinator correctly sends a Ping
+// request to the remote signer and handles the received Pong response
+// correctly.
+func TestPingRequests(t *testing.T) {
+ t.Parallel()
+
+ coordinator, stream, _ := setupSignCoordinator(t)
+
+ var wg sync.WaitGroup
+
+ // Send a Ping request in a goroutine so that we can pick up the request
+ // sent over the mock stream, and respond accordingly.
+ wg.Add(1)
+
+ go func() {
+ defer wg.Done()
+
+ // The Ping method will return true if the response is a Pong
+ // response.
+ success, err := coordinator.Ping(t.Context(), 2*time.Second)
+ require.NoError(t, err)
+ require.True(t, success)
+ }()
+
+ // Get the request sent over the mock stream.
+ req, err := getRequest(stream)
+ require.NoError(t, err)
+
+ // Verify that the request has the expected request ID and that it's a
+ // Ping request.
+ require.Equal(t, uint64(2), req.GetRequestId())
+ require.True(t, req.GetPing())
+
+ // Verify that the coordinator has correctly set up a single response
+ // channel for the Ping request with the specific request ID.
+ require.Equal(t, coordinator.responses.Len(), 1)
+ _, ok := coordinator.responses.Load(uint64(2))
+ require.True(t, ok)
+
+ // Now we simulate the response from the remote signer by sending a Pong
+ // response over the mock stream.
+ stream.sendResponse(&watchonlyrpc.SignCoordinatorResponse{
+ RefRequestId: 2,
+ SignResponseType: &watchonlyrpc.SignCoordinatorResponse_Pong{
+ Pong: true,
+ },
+ })
+
+ // Wait for the goroutines to finish, which should only happen after the
+ // requests have had their expected responses processed.
+ wg.Wait()
+
+ // Verify the responses map is empty after all responses are received
+ // to ensure that no memory leaks occur.
+ require.Equal(t, coordinator.responses.Len(), 0)
+}
+
+// TestConcurrentPingRequests tests that the sign coordinator correctly handles
+// concurrent Ping requests and responses, and that the order in which responses
+// are sent back over the stream doesn't matter.
+func TestConcurrentPingRequests(t *testing.T) {
+ t.Parallel()
+
+ coordinator, stream, _ := setupSignCoordinator(t)
+
+ var wg sync.WaitGroup
+
+ // Let's first start by sending two concurrent Ping requests and sending
+ // the respective responses back in order.
+ wg.Add(1)
+
+ go func() {
+ defer wg.Done()
+ success, err := coordinator.Ping(t.Context(), 2*time.Second)
+
+ require.NoError(t, err)
+ require.True(t, success)
+ }()
+
+ // Get the first request sent over the mock stream.
+ req1, err := getRequest(stream)
+ require.NoError(t, err)
+
+ // Verify that the request has the expected request ID and that it's a
+ // Ping request.
+ require.Equal(t, uint64(2), req1.GetRequestId())
+ require.True(t, req1.GetPing())
+
+ // Verify that the coordinator has correctly set up a single response
+ // channel for the Ping request with the specific request ID.
+ require.Equal(t, coordinator.responses.Len(), 1)
+ _, ok := coordinator.responses.Load(uint64(2))
+ require.True(t, ok)
+
+ // Now we send the second Ping request.
+ wg.Add(1)
+
+ go func() {
+ defer wg.Done()
+ success, err := coordinator.Ping(t.Context(), 2*time.Second)
+
+ require.NoError(t, err)
+ require.True(t, success)
+ }()
+
+ // Get the second request sent over the mock stream.
+ req2, err := getRequest(stream)
+ require.NoError(t, err)
+
+ // Verify that the request has the expected request ID and that it's a
+ // Ping request.
+ require.Equal(t, uint64(3), req2.GetRequestId())
+ require.True(t, req2.GetPing())
+
+ // Verify that the coordinator has correctly set up two response
+ // channels for the Ping requests with their specific request IDs.
+ require.Equal(t, coordinator.responses.Len(), 2)
+ _, ok = coordinator.responses.Load(uint64(3))
+ require.True(t, ok)
+
+ // Send responses for both Ping requests in order.
+ stream.sendResponse(&watchonlyrpc.SignCoordinatorResponse{
+ RefRequestId: 2,
+ SignResponseType: &watchonlyrpc.SignCoordinatorResponse_Pong{
+ Pong: true,
+ },
+ })
+ stream.sendResponse(&watchonlyrpc.SignCoordinatorResponse{
+ RefRequestId: 3,
+ SignResponseType: &watchonlyrpc.SignCoordinatorResponse_Pong{
+ Pong: true,
+ },
+ })
+
+ // Wait for the goroutines to finish, which should only happen after the
+ // requests have had their expected responses processed.
+ wg.Wait()
+
+ // Verify the responses map is empty after all responses are received.
+ require.Equal(t, coordinator.responses.Len(), 0)
+
+ // Now let's verify that the sign coordinator can correctly process
+ // responses that are sent back in a different order than the requests
+ // were sent.
+
+ // Send a new set of concurrent Ping requests.
+ wg.Add(1)
+
+ go func() {
+ defer wg.Done()
+
+ success, err := coordinator.Ping(t.Context(), 2*time.Second)
+ require.NoError(t, err)
+ require.True(t, success)
+ }()
+
+ req3, err := getRequest(stream)
+ require.NoError(t, err)
+
+ require.Equal(t, uint64(4), req3.GetRequestId())
+ require.True(t, req3.GetPing())
+
+ // Verify that the coordinator has removed the response channels for the
+ // previous Ping requests and set up a new one for the new request.
+ require.Equal(t, coordinator.responses.Len(), 1)
+ _, ok = coordinator.responses.Load(uint64(4))
+ require.True(t, ok)
+
+ wg.Add(1)
+
+ go func() {
+ defer wg.Done()
+
+ success, err := coordinator.Ping(t.Context(), 2*time.Second)
+ require.NoError(t, err)
+ require.True(t, success)
+ }()
+
+ req4, err := getRequest(stream)
+ require.NoError(t, err)
+
+ require.Equal(t, uint64(5), req4.GetRequestId())
+ require.True(t, req4.GetPing())
+
+ require.Equal(t, coordinator.responses.Len(), 2)
+ _, ok = coordinator.responses.Load(uint64(5))
+ require.True(t, ok)
+
+ // Send the responses back in reverse order.
+ stream.sendResponse(&watchonlyrpc.SignCoordinatorResponse{
+ RefRequestId: 5,
+ SignResponseType: &watchonlyrpc.SignCoordinatorResponse_Pong{
+ Pong: true,
+ },
+ })
+ stream.sendResponse(&watchonlyrpc.SignCoordinatorResponse{
+ RefRequestId: 4,
+ SignResponseType: &watchonlyrpc.SignCoordinatorResponse_Pong{
+ Pong: true,
+ },
+ })
+
+ // Wait for the goroutines to finish, which should only happen after the
+ // requests have had their expected responses processed.
+ wg.Wait()
+
+ // Verify the responses map is empty after all responses are received
+ // to ensure that no memory leaks occur.
+ require.Equal(t, coordinator.responses.Len(), 0)
+}
+
+// TestPingTimeout tests that the sign coordinator correctly handles a Ping
+// request that times out.
+func TestPingTimeout(t *testing.T) {
+ t.Parallel()
+
+ coordinator, stream, _ := setupSignCoordinator(t)
+
+ var wg sync.WaitGroup
+
+ // Simulate a Ping request that is expected to time out.
+ wg.Add(1)
+
+ go func() {
+ defer wg.Done()
+
+ // Note that the timeout is set to 1 second.
+ success, err := coordinator.Ping(t.Context(), 1*time.Second)
+ require.Equal(t, context.DeadlineExceeded, err)
+ require.False(t, success)
+ }()
+
+ // Get the request sent over the mock stream.
+ req1, err := getRequest(stream)
+ require.NoError(t, err)
+
+ // Verify that the request has the expected request ID and that it's a
+ // Ping request.
+ require.Equal(t, uint64(2), req1.GetRequestId())
+ require.True(t, req1.GetPing())
+
+ // Verify that the coordinator has correctly set up a single response
+ // channel for the Ping request with the specific request ID.
+ require.Equal(t, coordinator.responses.Len(), 1)
+ _, ok := coordinator.responses.Load(uint64(2))
+ require.True(t, ok)
+
+ // Now wait for the request to time out.
+ wg.Wait()
+
+ // Verify that the responses map is empty after the timeout.
+ require.Equal(t, coordinator.responses.Len(), 0)
+
+ // Now let's simulate that the response is sent back after the request
+ // has timed out.
+ stream.sendResponse(&watchonlyrpc.SignCoordinatorResponse{
+ RefRequestId: 2,
+ SignResponseType: &watchonlyrpc.SignCoordinatorResponse_Pong{
+ Pong: true,
+ },
+ })
+
+ // Verify that the responses map still remains empty, as responses for
+ // timed out requests are ignored.
+ require.Equal(t, coordinator.responses.Len(), 0)
+}
+
+// TestConcurrentPingTimeout tests that the sign coordinator correctly handles a
+// Ping request that times out, while another Ping request is still pending
+// and then receives a response.
+func TestConcurrentPingTimeout(t *testing.T) {
+ t.Parallel()
+
+ coordinator, stream, _ := setupSignCoordinator(t)
+
+ var wg sync.WaitGroup
+
+ timeoutChan := make(chan struct{})
+
+ // Send a Ping request that is expected to time out.
+ wg.Add(1)
+
+ go func() {
+ defer wg.Done()
+
+ // Note that the timeout is set to 1 second.
+ success, err := coordinator.Ping(t.Context(), 1*time.Second)
+ require.Equal(t, context.DeadlineExceeded, err)
+ require.False(t, success)
+
+ // Signal that the request has timed out.
+ close(timeoutChan)
+ }()
+
+ // Get the request sent over the mock stream.
+ req1, err := getRequest(stream)
+ require.NoError(t, err)
+
+ // Verify that the request has the expected request ID and that it's a
+ // Ping request.
+ require.Equal(t, uint64(2), req1.GetRequestId())
+ require.True(t, req1.GetPing())
+
+ // Verify that the coordinator has correctly set up a single response
+ // channel for the Ping request with the specific request ID.
+ require.Equal(t, coordinator.responses.Len(), 1)
+ _, ok := coordinator.responses.Load(uint64(2))
+ require.True(t, ok)
+
+ // Now let's send another Ping request that will receive a response.
+ wg.Add(1)
+
+ go func() {
+ defer wg.Done()
+
+ // Note that the timeout is set to 2 seconds and will therefore
+ // time out later than the first request.
+ success, err := coordinator.Ping(t.Context(), 2*time.Second)
+ require.NoError(t, err)
+ require.True(t, success)
+ }()
+
+ // Get the second request sent over the mock stream.
+ req2, err := getRequest(stream)
+ require.NoError(t, err)
+
+ // Verify that the request has the expected request ID and that it's a
+ // Ping request.
+ require.Equal(t, uint64(3), req2.GetRequestId())
+ require.True(t, req2.GetPing())
+
+ // Verify that the coordinator has correctly set up two response
+ // channels for the Ping requests with their specific request IDs.
+ require.Equal(t, coordinator.responses.Len(), 2)
+ _, ok = coordinator.responses.Load(uint64(3))
+ require.True(t, ok)
+
+ // Now let's wait for the first request to time out.
+ <-timeoutChan
+
+ // Ensure that this leads to the sign coordinator removing the response
+ // channel for the timed-out request.
+ require.Equal(t, coordinator.responses.Len(), 1)
+
+ // The second request should still be pending, so the responses map
+ // should contain the response channel for the second request.
+ _, ok = coordinator.responses.Load(uint64(3))
+ require.True(t, ok)
+
+ // Send responses for the second Ping request.
+ stream.sendResponse(&watchonlyrpc.SignCoordinatorResponse{
+ RefRequestId: 3,
+ SignResponseType: &watchonlyrpc.SignCoordinatorResponse_Pong{
+ Pong: true,
+ },
+ })
+
+ // Wait for the goroutines to finish, which should only happen after the
+ // second request has had its expected response processed.
+ wg.Wait()
+
+ // Verify the responses map is empty after all responses have been
+ // handled, to ensure that no memory leaks occur.
+ require.Equal(t, coordinator.responses.Len(), 0)
+}
+
+// TestIncorrectResponseRequestId tests that the sign coordinator correctly
+// ignores responses with an unknown request ID.
+func TestIncorrectResponseRequestId(t *testing.T) {
+ t.Parallel()
+
+ coordinator, stream, _ := setupSignCoordinator(t)
+
+ var wg sync.WaitGroup
+
+ // Save the start time of the test.
+ startTime := time.Now()
+
+ // The coordinator uses a 2s request timeout (see setupSignCoordinator).
+ // Make the ping timeout longer to avoid racing context cancellation
+ // against the request timeout.
+ pingTimeout := coordinator.requestTimeout + time.Second
+
+ wg.Add(1)
+
+ // Send a Ping request that times out in 3 seconds. As the request
+ // timeout is lower than the ping timeout, the request should error
+ // after 2 seconds with a ErrRequestTimeout.
+ go func() {
+ defer wg.Done()
+
+ success, err := coordinator.Ping(t.Context(), pingTimeout)
+ require.Equal(t, ErrRequestTimeout, err)
+ require.False(t, success)
+ }()
+
+ // Get the request sent over the mock stream.
+ req, err := getRequest(stream)
+ require.NoError(t, err)
+
+ // Verify that the request has the expected request ID and that it's a
+ // Ping request.
+ require.Equal(t, uint64(2), req.GetRequestId())
+ require.True(t, req.GetPing())
+
+ // Verify that the coordinator has correctly set up a single response
+ // channel for the Ping request with the specific request ID.
+ require.Equal(t, coordinator.responses.Len(), 1)
+ _, ok := coordinator.responses.Load(uint64(2))
+ require.True(t, ok)
+
+ // Now let's send a response with another request ID than the Ping
+ // request.
+ stream.sendResponse(&watchonlyrpc.SignCoordinatorResponse{
+ RefRequestId: 3, // Incorrect request ID
+ SignResponseType: &watchonlyrpc.SignCoordinatorResponse_Pong{
+ Pong: true,
+ },
+ })
+
+ // Ensure that the response is ignored and that the responses map still
+ // contains the response channel for the Ping request until the request
+ // times out. We allow a small margin of error to account for the time
+ // it takes to execute the Invariant function.
+ invariantWaitTime := coordinator.requestTimeout -
+ time.Since(startTime) - 100*time.Millisecond
+
+ err = wait.Invariant(func() bool {
+ correctLen := coordinator.responses.Len() == 1
+ _, ok = coordinator.responses.Load(uint64(2))
+
+ return correctLen && ok
+ }, invariantWaitTime)
+ require.NoError(t, err)
+
+ // Wait for the goroutines to finish, which should only happen after the
+ // request has timed out and verified the error.
+ wg.Wait()
+
+ // Verify the responses map is empty after all responses are received
+ require.Equal(t, coordinator.responses.Len(), 0)
+}
+
+// TestSignerErrorResponse tests that the sign coordinator correctly handles a
+// SignerError response from the remote signer.
+func TestSignerErrorResponse(t *testing.T) {
+ t.Parallel()
+
+ coordinator, stream, _ := setupSignCoordinator(t)
+
+ var wg sync.WaitGroup
+
+ // Send a Ping request that will receive a SignerError response.
+ wg.Add(1)
+
+ go func() {
+ defer wg.Done()
+
+ success, err := coordinator.Ping(t.Context(), 1*time.Second)
+ // Ensure that the result from the Ping method is an error,
+ // which is the expected result when a SignerError response is
+ // received.
+ require.Equal(t, "mock error", err.Error())
+ require.False(t, success)
+ }()
+
+ // Get the request sent over the mock stream.
+ req, err := getRequest(stream)
+ require.NoError(t, err)
+
+ // Verify that the request has the expected request ID and that it's a
+ // Ping request.
+ require.Equal(t, uint64(2), req.GetRequestId())
+ require.True(t, req.GetPing())
+
+ // Verify that the coordinator has correctly set up a single response
+ // channel for the Ping request with the specific request ID.
+ require.Equal(t, coordinator.responses.Len(), 1)
+ _, ok := coordinator.responses.Load(uint64(2))
+ require.True(t, ok)
+
+ // Now let's send a SignerError response instead of a Pong back over the
+ // mock stream.
+ rType := &watchonlyrpc.SignCoordinatorResponse_SignerError{
+ SignerError: &watchonlyrpc.SignerError{
+ Error: "mock error",
+ },
+ }
+
+ stream.sendResponse(&watchonlyrpc.SignCoordinatorResponse{
+ RefRequestId: 2,
+ SignResponseType: rType,
+ })
+
+ // Wait for the goroutines to finish, which should only happen after the
+ // request has had its expected response processed.
+ wg.Wait()
+
+ // Verify the responses map is empty after all responses have been
+ // processed, to ensure that no memory leaks occur.
+ require.Equal(t, coordinator.responses.Len(), 0)
+}
+
+// TestStopCoordinator tests that the sign coordinator correctly stops
+// processing responses for any pending requests when the sign coordinator is
+// stopped.
+func TestStopCoordinator(t *testing.T) {
+ t.Parallel()
+
+ coordinator, stream, runErrChan := setupSignCoordinator(t)
+
+ pingTimeout := 3 * time.Second
+ startTime := time.Now()
+
+ var wg sync.WaitGroup
+
+ // Send a Ping request with a long timeout to ensure that the request
+ // will not time out before the coordinator is stopped.
+ wg.Add(1)
+
+ go func() {
+ defer wg.Done()
+
+ success, err := coordinator.Ping(t.Context(), pingTimeout)
+ require.Equal(t, ErrShuttingDown, err)
+ require.False(t, success)
+ }()
+
+ // Get the request sent over the mock stream.
+ req, err := getRequest(stream)
+ require.NoError(t, err)
+
+ // Verify that the request has the expected request ID and that it's a
+ // Ping request.
+ require.Equal(t, uint64(2), req.GetRequestId())
+ require.True(t, req.GetPing())
+
+ // Verify that the coordinator has correctly set up a single response
+ // channel for the Ping request with the specific request ID.
+ require.Equal(t, coordinator.responses.Len(), 1)
+ _, ok := coordinator.responses.Load(uint64(2))
+ require.True(t, ok)
+
+ // Now let's stop the sign coordinator.
+ wg.Add(1)
+
+ go func() {
+ defer wg.Done()
+
+ coordinator.Stop()
+ }()
+
+ // When the coordinator is stopped, the Run function will return an
+ // error that gets sent over the runErrChan.
+ err = <-runErrChan
+
+ // Ensure that the Run function returned the expected error that lnd is
+ // shutting down.
+ require.Equal(t, ErrShuttingDown, err)
+
+ // As the coordinator Run function returned the ErrShuttingDown error,
+ // lnd would normally cancel the stream. We simulate this by calling the
+ // Cancel method on the mock stream.
+ stream.Cancel()
+
+ // Ensure that both the Ping request goroutine and the sign coordinator
+ // Stop goroutine have finished.
+ wg.Wait()
+
+ // Ensure that the Ping request goroutine returned before the timeout
+ // was reached, which indicates that the request was canceled because
+ // the sign coordinator was stopped.
+ require.Less(t, time.Since(startTime), pingTimeout)
+
+ // Verify the responses map is empty after all responses are received
+ require.Equal(t, coordinator.responses.Len(), 0)
+}
+
+// TestRemoteSignerDisconnects tests that the sign coordinator correctly handles
+// the remote signer disconnecting, which closes the stream.
+func TestRemoteSignerDisconnects(t *testing.T) {
+ t.Parallel()
+
+ coordinator, stream, runErrChan := setupSignCoordinator(t)
+
+ // Use a timeout longer than the connection timeout so the coordinator
+ // returns ErrConnectTimeout because of a disconnect, instead of the
+ // context deadline error for the ping request.
+ pingTimeout := coordinator.connectionTimeout + (1 * time.Second)
+ startTime := time.Now()
+
+ var wg sync.WaitGroup
+
+ // Send a Ping request with a long timeout to ensure that the request
+ // will not time out before the remote signer disconnects.
+ wg.Add(1)
+
+ go func() {
+ defer wg.Done()
+
+ success, err := coordinator.Ping(t.Context(), pingTimeout)
+ require.Equal(t, ErrConnectTimeout, err)
+ require.False(t, success)
+ }()
+
+ // Get the request sent over the mock stream.
+ req, err := getRequest(stream)
+ require.NoError(t, err)
+
+ // Verify that the request has the expected request ID and that it's a
+ // Ping request.
+ require.Equal(t, uint64(2), req.GetRequestId())
+ require.True(t, req.GetPing())
+
+ // Verify that the coordinator has correctly set up a single response
+ // channel for the Ping request with the specific request ID.
+ require.Equal(t, coordinator.responses.Len(), 1)
+ _, ok := coordinator.responses.Load(uint64(2))
+ require.True(t, ok)
+
+ // We simulate the remote signer disconnecting by canceling the
+ // stream.
+ stream.Cancel()
+
+ // This should cause the Run function to return the error that the
+ // stream was canceled.
+ err = <-runErrChan
+ require.Equal(t, ErrStreamCanceled, err)
+
+ // Ensure that the Ping request goroutine has finished.
+ wg.Wait()
+
+ // Verify that the coordinator signals that it's done receiving
+ // responses after the stream is canceled, i.e. the StartReceiving
+ // function is no longer running.
+ <-coordinator.disconnected
+
+ // Ensure that the Ping request goroutine returned after the connection
+ // timeout, but before the ping timeout was reached, which indicates
+ // that the request was canceled because the remote signer disconnected.
+ require.Greater(t, time.Since(startTime), coordinator.connectionTimeout)
+ require.Less(t, time.Since(startTime), pingTimeout)
+
+ // Verify the responses map is empty after all responses are received
+ require.Equal(t, coordinator.responses.Len(), 0)
+}
+
+// TestWaitUntilConnectedNoTimeout verifies that setting the connection
+// timeout to zero completely disables the internal connect timeout
+// (i.e., no timer is started). As a result, WaitUntilConnected can only
+// return when the signer connects, the coordinator shuts down, or the
+// caller’s context is canceled. This ensures that no fallback or default
+// timeout is applied when the value is set to 0.
+func TestWaitUntilConnectedNoTimeout(t *testing.T) {
+ t.Parallel()
+
+ coordinator := NewSignCoordinator(2*time.Second, 0)
+ stream := newMockSCStream()
+
+ runErrChan := make(chan error, 1)
+ go func() {
+ err := coordinator.Run(stream)
+ if err != nil {
+ runErrChan <- err
+ }
+ }()
+
+ ctx, cancel := context.WithTimeout(t.Context(), 500*time.Millisecond)
+ defer cancel()
+
+ waitErrChan := make(chan error, 1)
+ go func() {
+ waitErrChan <- coordinator.WaitUntilConnected(ctx)
+ }()
+
+ // With connectionTimeout == 0, there is no internal timeout path, so
+ // WaitUntilConnected must stay blocked until the handshake completes or
+ // the ctx is canceled.
+ select {
+ case err := <-waitErrChan:
+ t.Fatalf("WaitUntilConnected returned early: %v", err)
+ case <-time.After(50 * time.Millisecond):
+ }
+
+ signReg := &watchonlyrpc.SignerRegistration{
+ RegistrationChallenge: []byte("registrationChallenge"),
+ RegistrationInfo: "outboundSigner",
+ }
+
+ regType := &watchonlyrpc.SignCoordinatorResponse_SignerRegistration{
+ SignerRegistration: signReg,
+ }
+
+ registrationMsg := &watchonlyrpc.SignCoordinatorResponse{
+ RefRequestId: 1,
+ SignResponseType: regType,
+ }
+
+ stream.sendResponse(registrationMsg)
+
+ // Drain the registration complete message to avoid blocking Send in
+ // the handshake.
+ select {
+ case req := <-stream.sendChan:
+ require.Equal(t, handshakeRequestID, req.GetRequestId())
+ case <-time.After(2 * time.Second):
+ t.Fatalf("registration complete was not sent")
+ }
+
+ require.NoError(t, <-waitErrChan)
+
+ // Cancel the stream to unblock Run, then shut down the coordinator.
+ // Either shutdown path can win the race, so we accept either error.
+ stream.Cancel()
+ coordinator.Stop()
+
+ select {
+ case err := <-runErrChan:
+ require.True(
+ t, errors.Is(err, ErrShuttingDown) ||
+ errors.Is(err, ErrStreamCanceled),
+ )
+ case <-time.After(2 * time.Second):
+ t.Fatalf("Run did not exit after shutdown")
+ }
+}
+
+// TestRemoteSignerReconnectsDuringResponseWait verifies that the sign
+// coordinator correctly handles the scenario where the remote signer
+// disconnects while a request is being processed and then reconnects. In this
+// case, the sign coordinator should establish a new stream, reprocess the
+// request, and ultimately receive a response.
+func TestRemoteSignerReconnectsDuringResponseWait(t *testing.T) {
+ t.Parallel()
+
+ coordinator, stream, runErrChan := setupSignCoordinator(t)
+
+ pingTimeout := 3 * time.Second
+ startTime := time.Now()
+
+ var wg sync.WaitGroup
+
+ // Send a Ping request with a long timeout to ensure that the request
+ // will not time out before the remote signer disconnects.
+ wg.Add(1)
+
+ go func() {
+ defer wg.Done()
+
+ success, err := coordinator.Ping(t.Context(), pingTimeout)
+ require.NoError(t, err)
+ require.True(t, success)
+ }()
+
+ // Get the request sent over the mock stream.
+ req, err := getRequest(stream)
+ require.NoError(t, err)
+
+ // Verify that the request has the expected request ID and that it's a
+ // Ping request.
+ originalReqID := req.GetRequestId()
+ require.Equal(t, uint64(2), originalReqID)
+ require.True(t, req.GetPing())
+
+ // Verify that the coordinator has correctly set up a single response
+ // channel for the Ping request with the specific request ID.
+ require.Equal(t, coordinator.responses.Len(), 1)
+ _, ok := coordinator.responses.Load(uint64(2))
+ require.True(t, ok)
+
+ // Now, lets simulate that the remote signer disconnects by canceling
+ // the stream, while the sign coordinator is still waiting for the Pong
+ // response for the request it sent.
+ stream.Cancel()
+
+ // This should cause the Run function to return the error that the
+ // stream was canceled.
+ err = <-runErrChan
+ require.Equal(t, ErrStreamCanceled, err)
+
+ // Verify that the coordinator signals that it's done receiving
+ // responses after the stream is canceled, i.e. the StartReceiving
+ // function is no longer running.
+ <-coordinator.disconnected
+
+ // Now let's simulate that the remote signer reconnects with a new
+ // stream.
+ stream, _ = setupNewStream(t, coordinator)
+
+ // This should lead to that the sign coordinator resends the Ping
+ // request it still needs a response for over the new stream.
+ req, err = getRequest(stream)
+ require.NoError(t, err)
+
+ // Verify that the resent request is explicitly another Ping from the
+ // watch-only node, not some unrelated follow-up request. The request
+ // ID must also be new, as the coordinator no longer waits for the
+ // response to the disconnected request.
+ require.NotEqual(t, originalReqID, req.GetRequestId())
+ require.Equal(t, uint64(3), req.GetRequestId())
+ require.True(t, req.GetPing())
+
+ // Verify that the coordinator cleaned up the response channel for the
+ // disconnected request before retrying and now only tracks the resent
+ // request.
+ require.Equal(t, coordinator.responses.Len(), 1)
+
+ _, ok = coordinator.responses.Load(uint64(2))
+ require.False(t, ok)
+ _, ok = coordinator.responses.Load(uint64(3))
+ require.True(t, ok)
+
+ // Now let's send the Pong response for the resent Ping request.
+ stream.sendResponse(&watchonlyrpc.SignCoordinatorResponse{
+ RefRequestId: 3,
+ SignResponseType: &watchonlyrpc.SignCoordinatorResponse_Pong{
+ Pong: true,
+ },
+ })
+
+ // Ensure that the Ping request goroutine has finished.
+ wg.Wait()
+
+ // Ensure that the Ping request goroutine returned before the timeout
+ // was reached, which indicates that the request didn't time out as
+ // the remote signer reconnected in time and sent a response.
+ require.Less(t, time.Since(startTime), pingTimeout)
+
+ // Verify the responses map is empty after all responses are received
+ require.Equal(t, coordinator.responses.Len(), 0)
+}
+
+// TestMuSig2RequestDoesNotRetryOnReconnect verifies that stateful MuSig2
+// requests are not replayed after the remote signer disconnects and later
+// reconnects.
+func TestMuSig2RequestDoesNotRetryOnReconnect(t *testing.T) {
+ t.Parallel()
+
+ coordinator, stream, runErrChan := setupSignCoordinator(t)
+
+ var (
+ wg sync.WaitGroup
+ reqErr error
+ )
+
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+
+ _, reqErr = coordinator.MuSig2CreateSession(
+ t.Context(), &signrpc.MuSig2SessionRequest{},
+ )
+ }()
+
+ req, err := getRequest(stream)
+ require.NoError(t, err)
+ require.Equal(t, uint64(2), req.GetRequestId())
+ require.NotNil(t, req.GetMuSig2SessionRequest())
+
+ // Verify that the coordinator tracks the outstanding MuSig2 request
+ // while it is waiting for the signer response.
+ require.Equal(t, coordinator.responses.Len(), 1)
+ _, ok := coordinator.responses.Load(uint64(2))
+ require.True(t, ok)
+
+ // Simulate that the signer disconnects before it responds.
+ stream.Cancel()
+
+ err = <-runErrChan
+ require.Equal(t, ErrStreamCanceled, err)
+
+ <-coordinator.disconnected
+
+ // Reconnect the signer. Since MuSig2 requests are stateful, the
+ // coordinator must not replay the request on the new stream.
+ stream, _ = setupNewStream(t, coordinator)
+
+ wg.Wait()
+ require.Equal(t, ErrNotConnected, reqErr)
+
+ select {
+ case req := <-stream.sendChan:
+ t.Fatalf("unexpected replayed request after reconnect: %T",
+ req.GetSignRequestType())
+
+ case <-time.After(1 * time.Second):
+ // No new request was sent.
+ }
+
+ require.Equal(t, coordinator.responses.Len(), 0)
+}
+
+// TestRemoteSignerDisconnectsMidSend verifies that the sign coordinator
+// correctly handles the scenario in which the remote signer disconnects while
+// the sign coordinator is sending data over the stream (i.e., during the
+// execution of the `Send` function) and then reconnects. In such a case, the
+// sign coordinator should establish a new stream, reprocess the request, and
+// eventually receive a response.
+func TestRemoteSignerDisconnectsMidSend(t *testing.T) {
+ t.Parallel()
+
+ coordinator, stream, runErrChan := setupSignCoordinator(t)
+
+ pingTimeout := 3 * time.Second
+ startTime := time.Now()
+
+ var wg sync.WaitGroup
+
+ // Send a Ping request with a long timeout to ensure that the request
+ // will not time out before the remote signer disconnects.
+ wg.Add(1)
+
+ go func() {
+ defer wg.Done()
+
+ success, err := coordinator.Ping(t.Context(), pingTimeout)
+ require.NoError(t, err)
+ require.True(t, success)
+ }()
+
+ // Just wait slightly, to ensure that the Ping requests starts getting
+ // processed before we simulate the remote signer disconnecting.
+ <-time.After(10 * time.Millisecond)
+
+ // We simulate the remote signer disconnecting by canceling the
+ // stream.
+ stream.Cancel()
+
+ // This should cause the Run function to return the error that the
+ // stream was canceled with.
+ err := <-runErrChan
+ require.Equal(t, ErrStreamCanceled, err)
+
+ // Verify that the coordinator signals that it's done receiving
+ // responses after the stream is canceled, i.e. the StartReceiving
+ // function is no longer running.
+ <-coordinator.disconnected
+
+ // Now since the sign coordinator is still processing the requests, and
+ // we never extracted the request sent over the stream, the sign
+ // coordinator is stuck at the steam.Send function. We simulate this
+ // function now errors with the codes.Unavailable error, which is what
+ // the function would error with if the signer was disconnected during
+ // the send operation in a real scenario.
+ stream.sendErrorChan <- status.Errorf(
+ codes.Unavailable, "simulated unavailable error",
+ )
+
+ // Verify that the coordinator has correctly set up a single response
+ // channel for the Ping request with the specific request ID.
+ require.Equal(t, 1, coordinator.responses.Len())
+
+ // Now let's simulate that the remote signer reconnects with a new
+ // stream.
+ stream, _ = setupNewStream(t, coordinator)
+
+ // This should lead to that the sign coordinator resends the Ping
+ // request it's needs a response for over the new stream.
+ req, err := getRequest(stream)
+ require.NoError(t, err)
+
+ // Note that the request ID will be 3 for the resent request, as the
+ // coordinator will no longer wait for the response for the request with
+ // request ID 2.
+ require.Equal(t, uint64(3), req.GetRequestId())
+ require.True(t, req.GetPing())
+
+ // Verify that the coordinator cleaned up the response channel for the
+ // disconnected request before retrying and now only tracks the resent
+ // request.
+ require.Equal(t, coordinator.responses.Len(), 1)
+
+ _, ok := coordinator.responses.Load(uint64(2))
+ require.False(t, ok)
+ _, ok = coordinator.responses.Load(uint64(3))
+ require.True(t, ok)
+
+ // Now let's send the Pong response for the resent Ping request.
+ stream.sendResponse(&watchonlyrpc.SignCoordinatorResponse{
+ RefRequestId: 3,
+ SignResponseType: &watchonlyrpc.SignCoordinatorResponse_Pong{
+ Pong: true,
+ },
+ })
+
+ // Ensure that the Ping request goroutine has finished.
+ wg.Wait()
+
+ // Ensure that the Ping request goroutine returned before the timeout
+ // was reached, which indicates that the request didn't time out as
+ // the remote signer reconnected in time and sent a response.
+ require.Less(t, time.Since(startTime), pingTimeout)
+
+ // Verify the responses map is empty after all responses are received
+ require.Equal(t, coordinator.responses.Len(), 0)
+}
+
+// TestRemoteSignerReconnectsBeforeSend verifies that a request that has already
+// observed readiness still succeeds if the signer disconnects and reconnects
+// before the request can send on the snapshotted stream.
+func TestRemoteSignerReconnectsBeforeSend(t *testing.T) {
+ t.Parallel()
+
+ coordinator, stream1, runErrChan := setupSignCoordinator(t)
+
+ // Hold the send mutex so the request can pass readiness, snapshot the
+ // current stream, and register its response channel before it reaches
+ // the actual Send call.
+ coordinator.sendMu.Lock()
+
+ var wg sync.WaitGroup
+
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+
+ success, err := coordinator.Ping(t.Context(), 2*time.Second)
+ require.NoError(t, err)
+ require.True(t, success)
+ }()
+
+ require.Eventually(t, func() bool {
+ _, ok := coordinator.responses.Load(uint64(2))
+ return ok
+ }, time.Second, 10*time.Millisecond)
+
+ // Disconnect the current signer after the request has observed
+ // readiness and snapshotted the old stream, but before it can actually
+ // send.
+ stream1.Cancel()
+
+ err := <-runErrChan
+ require.Equal(t, ErrStreamCanceled, err)
+
+ <-coordinator.disconnected
+
+ // Reconnect the signer before letting the blocked request proceed.
+ stream2, _ := setupNewStream(t, coordinator)
+
+ // Once the blocked send continues, the old stream should return an
+ // unavailable error so the request retries on the new connection.
+ stream1.sendErrorChan <- status.Errorf(
+ codes.Unavailable, "simulated unavailable error",
+ )
+
+ coordinator.sendMu.Unlock()
+
+ req, err := getRequest(stream2)
+ require.NoError(t, err)
+ require.Equal(t, uint64(3), req.GetRequestId())
+ require.True(t, req.GetPing())
+
+ stream2.sendResponse(&watchonlyrpc.SignCoordinatorResponse{
+ RefRequestId: 3,
+ SignResponseType: &watchonlyrpc.SignCoordinatorResponse_Pong{
+ Pong: true,
+ },
+ })
+
+ wg.Wait()
+ require.Equal(t, coordinator.responses.Len(), 0)
+}
### rpcperms/interceptor.go
@@ -98,6 +98,18 @@ var (
"/lnrpc.State/SubscribeState": {},
"/lnrpc.State/GetState": {},
}
+
+ // walletUnlockedStartupPermissions defines the methods and macaroon
+ // permissions that remain available while the wallet is unlocked but
+ // the full RPC server is still starting. As the user may be waiting
+ // for a remote signer to connect during this state, transitioning to
+ // the next state may take some time.
+ walletUnlockedStartupPermissions = map[string][]bakery.Op{
+ "/lnrpc.Lightning/StopDaemon": {{
+ Entity: "info",
+ Action: "write",
+ }},
+ }
)
// InterceptorChain is a struct that can be added to the running GRPC server,
@@ -280,7 +292,7 @@ func (r *InterceptorChain) SetRPCActive() {
_ = r.ntfnServer.SendUpdate(r.state)
}
-// SetServerActive moves the RPC state from walletUnlocked to rpcActive.
+// SetServerActive moves the RPC state from rpcActive to serverActive.
func (r *InterceptorChain) SetServerActive() {
r.Lock()
defer r.Unlock()
@@ -811,10 +823,23 @@ func (r *InterceptorChain) checkMacaroon(ctx context.Context,
r.RLock()
uriPermissions, ok := r.permissionMap[fullMethod]
+ state := r.state
r.RUnlock()
if !ok {
- return fmt.Errorf("%s: unknown permissions required for method",
- fullMethod)
+ if state == walletUnlocked {
+ // The wallet-unlocked startup window can expose a
+ // small list of methods before the full RPC permission
+ // map is populated. Fall back to those known
+ // permissions so macaroon validation can still succeed
+ // for them.
+ uriPermissions, ok =
+ walletUnlockedStartupPermissions[fullMethod]
+ }
+
+ if !ok {
+ return fmt.Errorf("%s: unknown permissions required "+
+ "for method", fullMethod)
+ }
}
// Find out if there is an external validator registered for
@@ -862,7 +887,9 @@ func (r *InterceptorChain) MacaroonStreamServerInterceptor() grpc.StreamServerIn
// checkRPCState checks whether a call to the given server is allowed in the
// current RPC state.
-func (r *InterceptorChain) checkRPCState(srv interface{}) error {
+func (r *InterceptorChain) checkRPCState(srv interface{},
+ fullMethod string) error {
+
// The StateService is being accessed, we allow the call regardless of
// the current state.
_, ok := srv.(lnrpc.StateServer)
@@ -903,7 +930,12 @@ func (r *InterceptorChain) checkRPCState(srv interface{}) error {
return ErrWalletUnlocked
}
- return ErrRPCStarting
+ // Only allow the small set of startup methods that remain
+ // available until the full RPC server is active.
+ _, ok = walletUnlockedStartupPermissions[fullMethod]
+ if !ok {
+ return ErrRPCStarting
+ }
// If the RPC server or lnd server is active, we allow calls to any
// service except the WalletUnlocker.
@@ -926,9 +958,10 @@ func (r *InterceptorChain) rpcStateUnaryServerInterceptor() grpc.UnaryServerInte
return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler) (interface{}, error) {
- r.rpcsLog.Debugf("[%v] requested", info.FullMethod)
+ method := info.FullMethod
+ r.rpcsLog.Debugf("[%v] requested", method)
- if err := r.checkRPCState(info.Server); err != nil {
+ if err := r.checkRPCState(info.Server, method); err != nil {
return nil, err
}
@@ -944,7 +977,7 @@ func (r *InterceptorChain) rpcStateStreamServerInterceptor() grpc.StreamServerIn
r.rpcsLog.Debugf("[%v] requested", info.FullMethod)
- if err := r.checkRPCState(srv); err != nil {
+ if err := r.checkRPCState(srv, info.FullMethod); err != nil {
return err
}
### rpcserver.go
@@ -186,6 +186,10 @@ var (
Entity: "macaroon",
Action: "write",
},
+ {
+ Entity: "remotesigner",
+ Action: "generate",
+ },
}
// invoicePermissions is a slice of all the entities that allows a user
@@ -220,7 +224,7 @@ var (
// implemented.
validActions = []string{"read", "write", "generate"}
validEntities = []string{
- "onchain", "offchain", "address", "message",
+ "onchain", "offchain", "address", "message", "remotesigner",
"peers", "info", "invoices", "signer", "macaroon",
macaroons.PermissionEntityCustomURI,
}
@@ -6786,19 +6790,28 @@ func (r *rpcServer) GetNetworkInfo(ctx context.Context,
func (r *rpcServer) StopDaemon(_ context.Context,
_ *lnrpc.StopRequest) (*lnrpc.StopResponse, error) {
- // Before we even consider a shutdown, are we currently in recovery
- // mode? We don't want to allow shutting down during recovery because
- // that would mean the user would have to manually continue the rescan
- // process next time by using `lncli unlock --recovery_window X`
- // otherwise some funds wouldn't be picked up.
- isRecoveryMode, progress, err := r.server.cc.Wallet.GetRecoveryInfo()
- if err != nil {
- return nil, fmt.Errorf("unable to get wallet recovery info: %w",
- err)
- }
- if isRecoveryMode && progress < 1 {
- return nil, fmt.Errorf("wallet recovery in progress, cannot " +
- "shut down, please wait until rescan finishes")
+ // StopDaemon is one of the few methods that can be called while the
+ // wallet is unlocked but the full RPC server is still starting. In
+ // that window addDeps has not yet populated r.server, so only perform
+ // the recovery-mode shutdown guard once those dependencies exist.
+ if r.server != nil && r.server.cc != nil && r.server.cc.Wallet != nil {
+ // Before we even consider a shutdown, are we currently in
+ // recovery mode? We don't want to allow shutting down during
+ // recovery because that would mean the user would have to
+ // manually continue the rescan process next time by using
+ // `lncli unlock --recovery_window X` otherwise some funds
+ // wouldn't be picked up.
+ isRecoveryMode, progress, err := r.server.cc.Wallet.
+ GetRecoveryInfo()
+ if err != nil {
+ return nil, fmt.Errorf("unable to get wallet recovery "+
+ "info: %w", err)
+ }
+ if isRecoveryMode && progress < 1 {
+ return nil, fmt.Errorf("wallet recovery in progress, " +
+ "cannot shut down, please wait until rescan " +
+ "finishes")
+ }
}
r.interceptor.RequestShutdown()
### rpcserver_test.go
@@ -3,10 +3,12 @@ package lnd
import (
"fmt"
"testing"
+ "time"
"github.com/lightningnetwork/lnd/channeldb"
"github.com/lightningnetwork/lnd/fn/v2"
"github.com/lightningnetwork/lnd/lnrpc"
+ "github.com/lightningnetwork/lnd/signal"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/proto"
@@ -74,6 +76,34 @@ func TestAuxDataParser(t *testing.T) {
require.Equal(t, []byte{0x00, 0x00}, resp.CustomChannelData)
}
+// TestStopDaemonBeforeRPCStartup makes sure StopDaemon can be called during
+// the wallet-unlocked startup window, before addDeps has populated the
+// rpcServer's server dependencies and while r.server is still nil. That
+// startup state can last for an extended period when lnd is waiting for an
+// outbound remote signer to connect before the full RPC server becomes active
+// when lnd is configured to use an outbound remote signer.
+func TestStopDaemonBeforeRPCStartup(t *testing.T) {
+ interceptor, err := signal.Intercept()
+ require.NoError(t, err)
+
+ r := &rpcServer{
+ interceptor: interceptor,
+ server: nil,
+ }
+
+ resp, err := r.StopDaemon(t.Context(), &lnrpc.StopRequest{})
+ require.NoError(t, err)
+ require.Equal(t, "shutdown initiated, check logs for progress",
+ resp.Status)
+
+ select {
+ case <-interceptor.ShutdownChannel():
+
+ case <-time.After(time.Second):
+ t.Fatal("expected shutdown request to be delivered")
+ }
+}
+
// TestRpcCommitmentType tests the rpcCommitmentType returns the corect
// commitment type given a channel type.
func TestRpcCommitmentType(t *testing.T) {
### sample-lnd.conf
@@ -1833,27 +1833,70 @@
; private keys.
; remotesigner.enable=false
-; The remote signer's RPC host:port.
+; EXPERIMENTAL: Signals that we allow an inbound connection from a remote
+; signer to this node when the local node acts as a watch-only node.
+; Default:
+; remotesigner.experimentalallowinboundconnection=false
+; Example:
+; remotesigner.experimentalallowinboundconnection=true
+
+; EXPERIMENTAL: Optional dedicated RPC listen address(es) for inbound remote
+; signer connections. If `remotesigner.experimentalallowinboundconnection` is
+; set to true, this must be set and lnd starts a separate gRPC server that
+; serves only the SignCoordinatorStreams RPC. If no port is specified, the
+; default remote signer RPC port 10019 is used.
+; Default:
+; remotesigner.experimentalrpclisten=
+; Example:
+; remotesigner.experimentalrpclisten=localhost:10019
+; remotesigner.experimentalrpclisten=[::1]:10020
+; remotesigner.experimentalrpclisten=unix:///var/run/lnd/remotesigner-rpc.sock
+
+; The remote signer's RPC host:port. Should not be set if
+; `remotesigner.experimentalallowinboundconnection` is set to true.
; Default:
; remotesigner.rpchost=
; Example:
; remotesigner.rpchost=remote.signer.lnd.host:10009
-; The macaroon to use for authenticating with the remote signer.
+; The macaroon to use for authenticating with the remote signer. Should not be
+; set if `remotesigner.experimentalallowinboundconnection` is set to true.
; Default:
; remotesigner.macaroonpath=
; Example:
; remotesigner.macaroonpath=/path/to/remote/signer/admin.macaroon
; The TLS certificate to use for establishing the remote signer's identity.
+; Should not be set if `remotesigner.experimentalallowinboundconnection` is
+; set to true.
; Default:
; remotesigner.tlscertpath=
; Example:
; remotesigner.tlscertpath=/path/to/remote/signer/tls.cert
-; The timeout for connecting to and signing requests with the remote signer.
+; The timeout for connecting to the remote signer.
+; Valid time units are {s, m, h}.
+; Default:
+; remotesigner.timeout=5s
+; Example:
+; remotesigner.timeout=2m
+
+; EXPERIMENTAL: The time we will wait when making requests to the remote
+; signer.
; Valid time units are {s, m, h}.
-; remotesigner.timeout=5s
+; Default:
+; remotesigner.experimentalrequesttimeout=5s
+; Example:
+; remotesigner.experimentalrequesttimeout=30s
+
+; EXPERIMENTAL: The time a node with
+; `remotesigner.experimentalallowinboundconnection` set to true will wait for
+; the remote signer to connect.
+; Valid time units are {s, m, h}.
+; Default:
+; remotesigner.experimentalstartuptimeout=5m
+; Example:
+; remotesigner.experimentalstartuptimeout=1m
; If a wallet with private key material already exists, migrate it into a
; watch-only wallet on first startup.
@@ -1863,6 +1906,52 @@
; remotesigner.migrate-wallet-to-watch-only=false
+[watchonlynode]
+
+; Configures experimental options for how to connect to a watch-only node from
+; a node that acts as a remote signer.
+
+; EXPERIMENTAL: Signals that this node functions as a remote signer that will
+; connect with a watch-only node.
+; Default:
+; watchonlynode.experimentalenable=false
+; Example:
+; watchonlynode.experimentalenable=true
+
+; The watch-only node's RPC host:port.
+; Default:
+; watchonlynode.rpchost=
+; Example:
+; watchonlynode.rpchost=watch.only.lnd.host:10009
+
+; The macaroon to use for authenticating with the watch-only node.
+; Default:
+; watchonlynode.macaroonpath=
+; Example:
+; watchonlynode.macaroonpath=/path/to/watch-only/watch-only.custom.macaroon
+
+; The TLS certificate to use for establishing the watch-only node's identity.
+; Default:
+; watchonlynode.tlscertpath=
+; Example:
+; watchonlynode.tlscertpath=/path/to/watch-only/tls.cert
+
+; The timeout during a connection attempt to the watch-only node.
+; Valid time units are {s, m, h}.
+; Default:
+; watchonlynode.timeout=5s
+; Example:
+; watchonlynode.timeout=2m
+
+; EXPERIMENTAL: The time we will wait when when sending responses to the
+; watch-only node.
+; Valid time units are {s, m, h}.
+; Default:
+; watchonlynode.experimentalrequesttimeout=5s
+; Example:
+; watchonlynode.experimentalrequesttimeout=30s
+
+
[gossip]
; Specify a set of pinned gossip syncers, which will always be actively syncing
### server.go
@@ -396,6 +396,9 @@ type server struct {
tlsManager *TLSManager
+ remoteSignerClientFactory func() (rpcwallet.RemoteSignerClient, error)
+ remoteSignerClient rpcwallet.RemoteSignerClient
+
// featureMgr dispatches feature vectors for various contexts within the
// daemon.
featureMgr *feature.Manager
@@ -666,7 +669,9 @@ func newServer(ctx context.Context, cfg *Config, listenAddrs []net.Addr,
chanPredicate chanacceptor.ChannelAcceptor,
torController *tor.Controller, tlsManager *TLSManager,
leaderElector cluster.LeaderElector,
- implCfg *ImplementationCfg) (*server, error) {
+ implCfg *ImplementationCfg,
+ remoteSignerClientFactory func() (rpcwallet.RemoteSignerClient,
+ error)) (*server, error) {
var (
err error
@@ -781,6 +786,13 @@ func newServer(ctx context.Context, cfg *Config, listenAddrs []net.Addr,
addrSource := channeldb.NewMultiAddrSource(dbs.ChanStateDB, v1Graph)
chanStateDB := dbs.ChanStateDB.ChannelStateDB()
+ if remoteSignerClientFactory == nil {
+ remoteSignerClientFactory =
+ func() (rpcwallet.RemoteSignerClient, error) {
+ return &rpcwallet.NoOpClient{}, nil
+ }
+ }
+
s := &server{
cfg: cfg,
implCfg: implCfg,
@@ -842,6 +854,9 @@ func newServer(ctx context.Context, cfg *Config, listenAddrs []net.Addr,
tlsManager: tlsManager,
+ remoteSignerClientFactory: remoteSignerClientFactory,
+ remoteSignerClient: &rpcwallet.NoOpClient{},
+
featureMgr: featureMgr,
quit: make(chan struct{}),
}
@@ -1924,7 +1939,11 @@ func newServer(ctx context.Context, cfg *Config, listenAddrs []net.Addr,
}
// Create liveness monitor.
- s.createLivenessMonitor(cfg, cc, leaderElector)
+ err = s.createLivenessMonitor(ctx, cfg, cc, leaderElector)
+ if err != nil {
+ return nil, fmt.Errorf("unable to create liveness monitor: %w",
+ err)
+ }
listeners := make([]net.Listener, len(listenAddrs))
for i, listenAddr := range listenAddrs {
@@ -2041,8 +2060,8 @@ func (s *server) signAliasUpdate(u *lnwire.ChannelUpdate1) (*ecdsa.Signature,
//
// If a health check has been disabled by setting attempts to 0, our monitor
// will not run it.
-func (s *server) createLivenessMonitor(cfg *Config, cc *chainreg.ChainControl,
- leaderElector cluster.LeaderElector) {
+func (s *server) createLivenessMonitor(ctx context.Context, cfg *Config,
+ cc *chainreg.ChainControl, leaderElector cluster.LeaderElector) error {
chainBackendAttempts := cfg.HealthChecks.ChainCheck.Attempts
if cfg.Bitcoin.Node == "nochainbackend" {
@@ -2139,28 +2158,46 @@ func (s *server) createLivenessMonitor(cfg *Config, cc *chainreg.ChainControl,
// If remote signing is enabled, add the healthcheck for the remote
// signing RPC interface.
- if s.cfg.RemoteSigner != nil && s.cfg.RemoteSigner.Enable {
+ if s.cfg.RemoteSigner.Enable {
+ // remoteSignerPinger is a local interface added to keep
+ // dependencies narrow: for the liveness probe we only need
+ // the ability to ping the remote signer, which is done through
+ // the rpcwallet.RPCKeyRing.Ping function. Avoiding a concrete
+ // *rpcwallet.RPCKeyRing dependency keeps WalletController
+ // encapsulation intact.
+ type remoteSignerPinger interface {
+ Ping(context.Context, time.Duration) error
+ }
+
+ rsPinger, ok := cc.Wc.(remoteSignerPinger)
+ if !ok {
+ return errors.New("incorrect WalletController type, " +
+ "expected remote signer pinger")
+ }
+
+ innerTimeout := cfg.HealthChecks.RemoteSigner.Timeout
+
// Because we have two cascading timeouts here, we need to add
// some slack to the "outer" one of them in case the "inner"
// returns exactly on time.
- overhead := time.Millisecond * 10
+ outerTimeout := innerTimeout + time.Millisecond*10
- remoteSignerConnectionCheck := healthcheck.NewObservation(
+ rsConnectionCheck := healthcheck.NewObservation(
"remote signer connection",
rpcwallet.HealthCheck(
- s.cfg.RemoteSigner,
-
+ ctx,
// For the health check we might to be even
// stricter than the initial/normal connect, so
- // we use the health check timeout here.
- cfg.HealthChecks.RemoteSigner.Timeout,
+ // we use the health check timeout.
+ innerTimeout,
+ rsPinger.Ping,
),
cfg.HealthChecks.RemoteSigner.Interval,
- cfg.HealthChecks.RemoteSigner.Timeout+overhead,
+ outerTimeout,
cfg.HealthChecks.RemoteSigner.Backoff,
cfg.HealthChecks.RemoteSigner.Attempts,
)
- checks = append(checks, remoteSignerConnectionCheck)
+ checks = append(checks, rsConnectionCheck)
}
// If we have a leader elector, we add a health check to ensure we are
@@ -2176,7 +2213,7 @@ func (s *server) createLivenessMonitor(cfg *Config, cc *chainreg.ChainControl,
// as the healthcheck observer will handle the
// timeout case for us.
timeoutCtx, cancel := context.WithTimeout(
- context.Background(),
+ ctx,
cfg.HealthChecks.LeaderCheck.Timeout,
)
defer cancel()
@@ -2214,6 +2251,8 @@ func (s *server) createLivenessMonitor(cfg *Config, cc *chainreg.ChainControl,
Shutdown: srvrLog.Criticalf,
},
)
+
+ return nil
}
// Started returns true if the server has been started, and false otherwise.
@@ -2300,6 +2339,19 @@ func (s *server) Start(ctx context.Context) error {
}
}
+ remoteSignerClient, err := s.remoteSignerClientFactory()
+ if err != nil {
+ startErr = err
+ return
+ }
+ s.remoteSignerClient = remoteSignerClient
+
+ cleanup = cleanup.add(s.remoteSignerClient.Stop)
+ if err := s.remoteSignerClient.Start(ctx); err != nil {
+ startErr = err
+ return
+ }
+
// Start the notification server. This is used so channel
// management goroutines can be notified when a funding
// transaction reaches a sufficient number of confirmations, or
@@ -2868,6 +2920,10 @@ func (s *server) Stop() error {
srvrLog.Warnf("Unable to stop BestBlockTracker: %v",
err)
}
+ if err := s.remoteSignerClient.Stop(); err != nil {
+ srvrLog.Warnf("Unable to stop remote signer "+
+ "client: %v", err)
+ }
if err := s.chanEventStore.Stop(); err != nil {
srvrLog.Warnf("Unable to stop ChannelEventStore: %v",
err)Why this scored 37/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.