diff --git a/cmd/lncli/cmd_walletunlocker.go b/cmd/lncli/cmd_walletunlocker.go index 7712cc095..2bc560482 100644 --- a/cmd/lncli/cmd_walletunlocker.go +++ b/cmd/lncli/cmd_walletunlocker.go @@ -10,8 +10,10 @@ import ( "strconv" "strings" + "github.com/lightninglabs/protobuf-hex-display/jsonpb" "github.com/lightningnetwork/lnd/lncfg" "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnrpc/walletrpc" "github.com/lightningnetwork/lnd/walletunlocker" "github.com/urfave/cli" ) @@ -638,6 +640,129 @@ func changePassword(ctx *cli.Context) error { 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 // prints it to standard out, depending on the user flags set. func storeOrPrintAdminMac(ctx *cli.Context, adminMac []byte) error { diff --git a/cmd/lncli/main.go b/cmd/lncli/main.go index 808b5d7eb..28c3c9bb6 100644 --- a/cmd/lncli/main.go +++ b/cmd/lncli/main.go @@ -327,6 +327,7 @@ func main() { } app.Commands = []cli.Command{ createCommand, + createWatchOnlyCommand, unlockCommand, changePasswordCommand, newAddressCommand, diff --git a/lnrpc/walletrpc/walletkit_util.go b/lnrpc/walletrpc/walletkit_util.go new file mode 100644 index 000000000..e840a9660 --- /dev/null +++ b/lnrpc/walletrpc/walletkit_util.go @@ -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 +} diff --git a/lnrpc/walletrpc/walletkit_util_test.go b/lnrpc/walletrpc/walletkit_util_test.go new file mode 100644 index 000000000..fdc8d2e0a --- /dev/null +++ b/lnrpc/walletrpc/walletkit_util_test.go @@ -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) + } + }) + } +}