mirror of
https://github.com/lightningnetwork/lnd.git
synced 2024-11-19 09:53:54 +01:00
cmd/lncli+walletrpc: add createwatchonly command
This commit is contained in:
parent
1541b2ef1b
commit
78b700387c
@ -10,8 +10,10 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/lightninglabs/protobuf-hex-display/jsonpb"
|
||||||
"github.com/lightningnetwork/lnd/lncfg"
|
"github.com/lightningnetwork/lnd/lncfg"
|
||||||
"github.com/lightningnetwork/lnd/lnrpc"
|
"github.com/lightningnetwork/lnd/lnrpc"
|
||||||
|
"github.com/lightningnetwork/lnd/lnrpc/walletrpc"
|
||||||
"github.com/lightningnetwork/lnd/walletunlocker"
|
"github.com/lightningnetwork/lnd/walletunlocker"
|
||||||
"github.com/urfave/cli"
|
"github.com/urfave/cli"
|
||||||
)
|
)
|
||||||
@ -638,6 +640,129 @@ func changePassword(ctx *cli.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var createWatchOnlyCommand = cli.Command{
|
||||||
|
Name: "createwatchonly",
|
||||||
|
Category: "Startup",
|
||||||
|
ArgsUsage: "accounts-json-file",
|
||||||
|
Usage: "Initialize a watch-only wallet after starting lnd for the " +
|
||||||
|
"first time.",
|
||||||
|
Description: `
|
||||||
|
The create command is used to initialize an lnd wallet from scratch for
|
||||||
|
the very first time, in watch-only mode. Watch-only means, there will be
|
||||||
|
no private keys in lnd's wallet. This is only useful in combination with
|
||||||
|
a remote signer or when lnd should be used as an on-chain wallet with
|
||||||
|
PSBT interaction only.
|
||||||
|
|
||||||
|
This is an interactive command that takes a JSON file as its first and
|
||||||
|
only argument. The JSON is in the same format as the output of the
|
||||||
|
'lncli wallet accounts list' command. This makes it easy to initialize
|
||||||
|
the remote signer with the seed, then export the extended public account
|
||||||
|
keys (xpubs) to import the watch-only wallet.
|
||||||
|
|
||||||
|
Example JSON (non-mandatory or ignored fields are omitted):
|
||||||
|
{
|
||||||
|
"accounts": [
|
||||||
|
{
|
||||||
|
"extended_public_key": "upub5Eep7....",
|
||||||
|
"derivation_path": "m/49'/0'/0'"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"extended_public_key": "vpub5ZU1PH...",
|
||||||
|
"derivation_path": "m/84'/0'/0'"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"extended_public_key": "tpubDDXFH...",
|
||||||
|
"derivation_path": "m/1017'/1'/0'"
|
||||||
|
},
|
||||||
|
...
|
||||||
|
{
|
||||||
|
"extended_public_key": "tpubDDXFH...",
|
||||||
|
"derivation_path": "m/1017'/1'/9'"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
There must be an account for each of the existing key families that lnd
|
||||||
|
uses internally (currently 0-9, see keychain/derivation.go).
|
||||||
|
|
||||||
|
Read the documentation under docs/remote-signing.md for more information
|
||||||
|
on how to set up a remote signing node over RPC.
|
||||||
|
`,
|
||||||
|
Action: actionDecorator(createWatchOnly),
|
||||||
|
}
|
||||||
|
|
||||||
|
func createWatchOnly(ctx *cli.Context) error {
|
||||||
|
ctxc := getContext()
|
||||||
|
client, cleanUp := getWalletUnlockerClient(ctx)
|
||||||
|
defer cleanUp()
|
||||||
|
|
||||||
|
if ctx.NArg() != 1 {
|
||||||
|
return cli.ShowCommandHelp(ctx, "createwatchonly")
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonFile := lncfg.CleanAndExpandPath(ctx.Args().First())
|
||||||
|
jsonBytes, err := ioutil.ReadFile(jsonFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error reading JSON from file %v: %v",
|
||||||
|
jsonFile, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonAccts := &walletrpc.ListAccountsResponse{}
|
||||||
|
err = jsonpb.Unmarshal(bytes.NewReader(jsonBytes), jsonAccts)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error parsing JSON: %v", err)
|
||||||
|
}
|
||||||
|
if len(jsonAccts.Accounts) == 0 {
|
||||||
|
return fmt.Errorf("cannot import empty account list")
|
||||||
|
}
|
||||||
|
|
||||||
|
walletPassword, err := capturePassword(
|
||||||
|
"Input wallet password: ", false,
|
||||||
|
walletunlocker.ValidatePassword,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
extendedRootKeyBirthday, err := askBirthdayTimestamp()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
recoveryWindow, err := askRecoveryWindow()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rpcAccounts, err := walletrpc.AccountsToWatchOnly(jsonAccts.Accounts)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rpcResp := &lnrpc.WatchOnly{
|
||||||
|
MasterKeyBirthdayTimestamp: extendedRootKeyBirthday,
|
||||||
|
Accounts: rpcAccounts,
|
||||||
|
}
|
||||||
|
|
||||||
|
// We assume that all accounts were exported from the same master root
|
||||||
|
// key. So if one is set, we just forward that. If other accounts should
|
||||||
|
// be watched later on, they should be imported into the watch-only
|
||||||
|
// node, that then also forwards the import request to the remote
|
||||||
|
// signer.
|
||||||
|
for _, acct := range jsonAccts.Accounts {
|
||||||
|
if len(acct.MasterKeyFingerprint) > 0 {
|
||||||
|
rpcResp.MasterKeyFingerprint = acct.MasterKeyFingerprint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = client.InitWallet(ctxc, &lnrpc.InitWalletRequest{
|
||||||
|
WalletPassword: walletPassword,
|
||||||
|
WatchOnly: rpcResp,
|
||||||
|
RecoveryWindow: recoveryWindow,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// storeOrPrintAdminMac either stores the admin macaroon to a file specified or
|
// storeOrPrintAdminMac either stores the admin macaroon to a file specified or
|
||||||
// prints it to standard out, depending on the user flags set.
|
// prints it to standard out, depending on the user flags set.
|
||||||
func storeOrPrintAdminMac(ctx *cli.Context, adminMac []byte) error {
|
func storeOrPrintAdminMac(ctx *cli.Context, adminMac []byte) error {
|
||||||
|
@ -327,6 +327,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
app.Commands = []cli.Command{
|
app.Commands = []cli.Command{
|
||||||
createCommand,
|
createCommand,
|
||||||
|
createWatchOnlyCommand,
|
||||||
unlockCommand,
|
unlockCommand,
|
||||||
changePasswordCommand,
|
changePasswordCommand,
|
||||||
newAddressCommand,
|
newAddressCommand,
|
||||||
|
76
lnrpc/walletrpc/walletkit_util.go
Normal file
76
lnrpc/walletrpc/walletkit_util.go
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
package walletrpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/lightningnetwork/lnd/lnrpc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AccountsToWatchOnly converts the accounts returned by the walletkit's
|
||||||
|
// ListAccounts RPC into a struct that can be used to create a watch-only
|
||||||
|
// wallet.
|
||||||
|
func AccountsToWatchOnly(exported []*Account) ([]*lnrpc.WatchOnlyAccount,
|
||||||
|
error) {
|
||||||
|
|
||||||
|
result := make([]*lnrpc.WatchOnlyAccount, len(exported))
|
||||||
|
for idx, acct := range exported {
|
||||||
|
parsedPath, err := parseDerivationPath(acct.DerivationPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error parsing derivation path "+
|
||||||
|
"of account %d: %v", idx, err)
|
||||||
|
}
|
||||||
|
if len(parsedPath) < 3 {
|
||||||
|
return nil, fmt.Errorf("derivation path of account %d "+
|
||||||
|
"has invalid derivation path, need at least "+
|
||||||
|
"path of depth 3, instead has depth %d", idx,
|
||||||
|
len(parsedPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
result[idx] = &lnrpc.WatchOnlyAccount{
|
||||||
|
Purpose: parsedPath[0],
|
||||||
|
CoinType: parsedPath[1],
|
||||||
|
Account: parsedPath[2],
|
||||||
|
Xpub: acct.ExtendedPublicKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDerivationPath parses a path in the form of m/x'/y'/z'/a/b into a slice
|
||||||
|
// of [x, y, z, a, b], meaning that the apostrophe is ignored and 2^31 is _not_
|
||||||
|
// added to the numbers.
|
||||||
|
func parseDerivationPath(path string) ([]uint32, error) {
|
||||||
|
path = strings.TrimSpace(path)
|
||||||
|
if len(path) == 0 {
|
||||||
|
return nil, fmt.Errorf("path cannot be empty")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(path, "m/") {
|
||||||
|
return nil, fmt.Errorf("path must start with m/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Just the root key, no path was provided. This is valid but not useful
|
||||||
|
// in most cases.
|
||||||
|
rest := strings.ReplaceAll(path, "m/", "")
|
||||||
|
if rest == "" {
|
||||||
|
return []uint32{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(rest, "/")
|
||||||
|
indices := make([]uint32, len(parts))
|
||||||
|
for i := 0; i < len(parts); i++ {
|
||||||
|
part := parts[i]
|
||||||
|
if strings.Contains(parts[i], "'") {
|
||||||
|
part = strings.TrimRight(parts[i], "'")
|
||||||
|
}
|
||||||
|
parsed, err := strconv.ParseInt(part, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("could not parse part \"%s\": "+
|
||||||
|
"%v", part, err)
|
||||||
|
}
|
||||||
|
indices[i] = uint32(parsed)
|
||||||
|
}
|
||||||
|
return indices, nil
|
||||||
|
}
|
74
lnrpc/walletrpc/walletkit_util_test.go
Normal file
74
lnrpc/walletrpc/walletkit_util_test.go
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
package walletrpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseDerivationPath(t *testing.T) {
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
expectedErr string
|
||||||
|
expectedResult []uint32
|
||||||
|
}{{
|
||||||
|
name: "empty path",
|
||||||
|
path: "",
|
||||||
|
expectedErr: "path cannot be empty",
|
||||||
|
}, {
|
||||||
|
name: "just whitespace",
|
||||||
|
path: " \n\t\r",
|
||||||
|
expectedErr: "path cannot be empty",
|
||||||
|
}, {
|
||||||
|
name: "incorrect prefix",
|
||||||
|
path: "0/0",
|
||||||
|
expectedErr: "path must start with m/",
|
||||||
|
}, {
|
||||||
|
name: "invalid number",
|
||||||
|
path: "m/a'/0'",
|
||||||
|
expectedErr: "could not parse part \"a\": strconv.ParseInt",
|
||||||
|
}, {
|
||||||
|
name: "double slash",
|
||||||
|
path: "m/0'//",
|
||||||
|
expectedErr: "could not parse part \"\": strconv.ParseInt",
|
||||||
|
}, {
|
||||||
|
name: "number too large",
|
||||||
|
path: "m/99999999999999",
|
||||||
|
expectedErr: "could not parse part \"99999999999999\": strconv",
|
||||||
|
}, {
|
||||||
|
name: "empty path",
|
||||||
|
path: "m/",
|
||||||
|
expectedResult: []uint32{},
|
||||||
|
}, {
|
||||||
|
name: "mixed path",
|
||||||
|
path: "m/0'/1'/2'/3/4/5/6'/7'",
|
||||||
|
expectedResult: []uint32{0, 1, 2, 3, 4, 5, 6, 7},
|
||||||
|
}, {
|
||||||
|
name: "short path",
|
||||||
|
path: "m/0'",
|
||||||
|
expectedResult: []uint32{0},
|
||||||
|
}, {
|
||||||
|
name: "plain path",
|
||||||
|
path: "m/0/1/2",
|
||||||
|
expectedResult: []uint32{0, 1, 2},
|
||||||
|
}}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
tc := tc
|
||||||
|
|
||||||
|
t.Run(tc.name, func(tt *testing.T) {
|
||||||
|
result, err := parseDerivationPath(tc.path)
|
||||||
|
|
||||||
|
if tc.expectedErr != "" {
|
||||||
|
require.Error(tt, err)
|
||||||
|
require.Contains(
|
||||||
|
tt, err.Error(), tc.expectedErr,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
require.NoError(tt, err)
|
||||||
|
require.Equal(tt, tc.expectedResult, result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user