Skip to content

Commit

Permalink
rpc: add path argument to getxpub
Browse files Browse the repository at this point in the history
  • Loading branch information
Sjors committed Nov 6, 2023
1 parent efff6e8 commit e7796cd
Show file tree
Hide file tree
Showing 6 changed files with 84 additions and 23 deletions.
5 changes: 5 additions & 0 deletions doc/release-notes-22341.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Wallet
------

- A new `getxpub` RPC is available to obtain an xpub for any given BIP32 path.
The xpub can then be imported as e.g. part of a multisig descriptor. (#22341)
2 changes: 1 addition & 1 deletion src/rpc/client.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ static const CRPCConvertParam vRPCConvertParams[] =
{ "sendmsgtopeer", 0, "peer_id" },
{ "stop", 0, "wait" },
{ "addnode", 2, "v2transport" },
{ "getxpub", 0, "private"},
{ "getxpub", 1, "private"},
};
// clang-format on

Expand Down
37 changes: 22 additions & 15 deletions src/wallet/rpc/wallet.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
#include <rpc/server.h>
#include <rpc/util.h>
#include <util/translation.h>
#include <util/bip32.h>
#include <wallet/context.h>
#include <wallet/receive.h>
#include <wallet/rpc/wallet.h>
Expand Down Expand Up @@ -812,17 +813,19 @@ static RPCHelpMan migratewallet()
RPCHelpMan getxpub()
{
return RPCHelpMan{"getxpub",
"Returns the xpub most recently used to generate descriptors for this descriptor wallet. "
"Not entirely useful right now as it returns the xpub of the root, and there are "
"hardened derivation steps involved in normal key derivation.\n",
"Return extended public key for a given path. "
"The xpub needs to be imported in a descriptor in order to see transactions spending from it.",
{
{"path", RPCArg::Type::STR, RPCArg::Optional::NO, "BIP 32 derivation path, e.g. m/84'/1'/0'"},
{"private", RPCArg::Type::BOOL, RPCArg::Default{false}, "Whether to include the xprv"},
},
RPCResult{
RPCResult::Type::OBJ, "", "",
{{
{RPCResult::Type::STR, "xpub", "The xpub"},
{RPCResult::Type::STR, "xprv", /*optional=*/true, "The xprv if private is true"},
{RPCResult::Type::STR, "fingerprint", /*optional=*/true, "The master key fingerprint"},
{RPCResult::Type::STR, "origin", /*optional=*/true, "The fingerprint and path"},
}},
},
RPCExamples{
Expand All @@ -842,27 +845,31 @@ RPCHelpMan getxpub()
throw JSONRPCError(RPC_WALLET_ERROR, "Wallet has not been upgraded to support global hd keys");
}

EnsureWalletIsUnlocked(*pwallet);

LOCK(pwallet->cs_wallet);

std::vector<uint32_t> path = ParsePathBIP32(request.params[0].get_str());

UniValue obj(UniValue::VOBJ);
std::optional<CExtPubKey> extpub = pwallet->GetActiveHDPubKey();
if (!extpub) {
throw JSONRPCError(RPC_WALLET_ERROR, "This wallet does not have an active xpub");
std::optional<std::pair<CExtKey, KeyOriginInfo>> xpriv_and_origin = pwallet->GetExtKey(path);
if (xpriv_and_origin == std::nullopt) {
throw JSONRPCError(RPC_WALLET_ERROR, "This wallet does not have an active master extended key");
}
std::string xpub = EncodeExtPubKey(*extpub);
const std::string xpub = EncodeExtPubKey(xpriv_and_origin->first.Neuter());
const std::string fingerprint = HexStr(xpriv_and_origin->second.fingerprint);
const std::string keypath = FormatHDKeypath(xpriv_and_origin->second.path);
obj.pushKV("xpub", xpub);

bool priv = !request.params[0].isNull() && request.params[0].get_bool();
bool priv = !request.params[1].isNull() && request.params[1].get_bool();
if (priv) {
EnsureWalletIsUnlocked(*pwallet);
std::optional<CExtKey> extkey = pwallet->GetHDKey(*extpub);
if (!extkey) {
throw JSONRPCError(RPC_WALLET_ERROR, "Could not find the xprv for active key");
}
std::string xprv = EncodeExtKey(*extkey);
std::string xprv = EncodeExtKey(xpriv_and_origin->first);
obj.pushKV("xprv", xprv);
}

if(!path.empty()) {
obj.pushKV("fingerprint", fingerprint);
obj.pushKV("origin", "[" + fingerprint + keypath + "]");
}
return obj;
},
};
Expand Down
2 changes: 1 addition & 1 deletion test/functional/wallet_backwards_compatibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,7 @@ def run_test(self):
# Check that descriptor wallets have hd key
if self.options.descriptors:
descs = wallet.listdescriptors(True)
xpub_info = wallet.getxpub(True)
xpub_info = wallet.getxpub("m", True)
if self.major_version_at_least(node, 23):
assert_equal(xpub_info["xprv"], expected_xprv)
expected_xprv_count = 6
Expand Down
59 changes: 54 additions & 5 deletions test/functional/wallet_getxpub.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
assert_equal,
assert_raises_rpc_error,
)
from test_framework.descriptors import descsum_create
from test_framework.wallet_util import WalletUnlock
import re


class WalletGetXpubTest(BitcoinTestFramework):
Expand All @@ -18,23 +20,25 @@ def add_options(self, parser):

def set_test_params(self):
self.setup_clean_chain = True
self.num_nodes = 1
self.num_nodes = 2

def skip_test_if_missing_module(self):
self.skip_if_no_wallet()
self.skip_if_no_sqlite()

def run_test(self):
self.test_basic_getxpub()
self.test_export()

def test_basic_getxpub(self):
self.log.info("Test getxpub basics")
self.nodes[0].createwallet("basic")
wallet = self.nodes[0].get_wallet_rpc("basic")
xpub_info = wallet.getxpub()
xpub_info = wallet.getxpub("m")
assert "xprv" not in xpub_info
xpub = xpub_info["xpub"]

xpub_info = wallet.getxpub(True)
xpub_info = wallet.getxpub("m", True)
xprv = xpub_info["xprv"]
assert_equal(xpub_info["xpub"], xpub)

Expand All @@ -44,9 +48,9 @@ def test_basic_getxpub(self):
assert xprv in desc["desc"]

wallet.encryptwallet("pass")
assert_raises_rpc_error(-13, "Error: Please enter the wallet passphrase with walletpassphrase first", wallet.getxpub, True)
assert_raises_rpc_error(-13, "Error: Please enter the wallet passphrase with walletpassphrase first", wallet.getxpub, "m", True)
with WalletUnlock(wallet, "pass"):
xpub_info = wallet.getxpub(True)
xpub_info = wallet.getxpub("m", True)
assert xpub_info["xpub"] != xpub
assert xpub_info["xprv"] != xprv
for desc in wallet.listdescriptors(True)["descriptors"]:
Expand All @@ -55,6 +59,51 @@ def test_basic_getxpub(self):
else:
assert xprv in desc["desc"]

def test_export(self):
self.log.info("Attempt to create a watch-only wallet clone by exporting an xpub")
self.nodes[0].createwallet(wallet_name="w1", descriptors=True)
w1 = self.nodes[0].get_wallet_rpc("w1")

# Get the activate wpkh() receive descriptor
desc = list(filter(lambda d:
d["active"] and not d["internal"] and d["desc"][0:4] == "wpkh",
w1.listdescriptors()["descriptors"])
)[0]["desc"]
self.log.debug(desc)

# Get master key fingerprint:
fpr = re.search(r'\[(.*?)/', desc).group(1)

self.nodes[1].createwallet(wallet_name="w2", descriptors=True, disable_private_keys=True)
w2 = self.nodes[1].get_wallet_rpc("w2")

self.log.info("Get xpub for BIP 84 native SegWit account 0")
res = w1.getxpub("m/84h/1h/0h")
self.log.debug(res)
assert res['xpub']
assert_equal(res['fingerprint'], fpr)
assert_equal(res['origin'], "[" + fpr + "/84h/1h/0h]")

self.log.info("Import descriptor using the xpub and fingerprint")
desc_receive = "wpkh([" + fpr + "/84/1h/0h]" + res['xpub'] + "/0/*)"
desc_change = "wpkh([" + fpr + "/84/1h/0h]" + res['xpub'] + "/1/*)"
res2 = w2.importdescriptors([{"desc":descsum_create(desc_receive),
"timestamp": "now",
"active": True,
"internal": False,
"range": [0, 10]},
{"desc":descsum_create(desc_change),
"timestamp": "now",
"active": True,
"internal": True,
"range": [0, 10]}
])
assert res2[0]['success'] and res2[1]['success']

self.log.info("Sanity check imported xpub")
w1_address_0 = w1.getnewaddress(address_type="bech32")
w2_address_0 = w2.getnewaddress(address_type="bech32")
assert_equal(w1_address_0, w2_address_0)

if __name__ == '__main__':
WalletGetXpubTest().main()
2 changes: 1 addition & 1 deletion test/functional/wallet_migration.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ def test_basic(self):
assert_equal(len(basic0.listdescriptors()["descriptors"]), 11)

# A global hd key should be added which matches the ones in the descriptors
xprv = basic0.getxpub(True)["xprv"]
xprv = basic0.getxpub("m", True)["xprv"]
assert all([xprv in desc["desc"] for desc in filter(lambda x: "range" in x, basic0.listdescriptors(True)["descriptors"])])

# Compare addresses info
Expand Down

0 comments on commit e7796cd

Please sign in to comment.