cmd/lncli+walletrpc: add createwatchonly command

This commit is contained in:
Oliver Gugger 2021-10-14 15:42:58 +02:00
parent 1541b2ef1b
commit 78b700387c
No known key found for this signature in database
GPG Key ID: 8E4256593F177720
4 changed files with 276 additions and 0 deletions

View File

@ -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 {

View File

@ -327,6 +327,7 @@ func main() {
}
app.Commands = []cli.Command{
createCommand,
createWatchOnlyCommand,
unlockCommand,
changePasswordCommand,
newAddressCommand,

View 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
}

View 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)
}
})
}
}