Merge bitcoin/bitcoin#35436: wallet: Add addHDkey interface
What changed, and why it matters
This commit refactors how Bitcoin Core wallets add new HD (hierarchical deterministic) keys. It moves the existing addhdkey logic from the RPC layer into a reusable CWallet method and exposes it through the wallet interface used by the GUI. The change is primarily a code-organization improvement to support future GUI multisig setup. It does not appear to introduce a security vulnerability; rather, it adds clearer error handling and tests for locked wallets and invalid keys.
No security action required. Treat as a normal feature/refactor review. Monitor follow-up PRs that will use this interface in the GUI for multisig setup.
Security signals we found
Refactor of existing wallet key-management RPC into reusable interface
Adds explicit WalletError with UnlockNeeded code for locked-wallet failures
Adds functional test for locked-wallet rejection and unit test for malformed key
No new entropy source or key generation algorithm introduced
No new network, RPC, or file-system attack surface added
Evidence from the diff
The merge refactors addhdkey by introducing CWallet::AddHDKey(), interfaces::Wallet::addHDKey(), and a WalletError type. The RPC now delegates to CWallet::AddHDKey() after parsing arguments. The new path preserves existing behavior: it rejects xpubs, rejects wallets with disabled private keys, requires an unlocked wallet, checks for duplicate HD keys, and returns the master xpub. New unit and functional tests cover locked-wallet and malformed-key cases. The diff shows no new cryptographic operations, no new network exposure, and no weakening of access controls beyond what already existed in the RPC version.
Changed components
src/wallet/wallet.cppsrc/wallet/wallet.hsrc/wallet/rpc/wallet.cppsrc/wallet/interfaces.cppsrc/interfaces/wallet.hsrc/wallet/test/wallet_interfaces_tests.cpptest/functional/wallet_hd.pyInspect captured patch +207 / −33
### src/interfaces/wallet.h
@@ -11,10 +11,13 @@
#include <consensus/amount.h>
#include <interfaces/chain.h>
#include <primitives/transaction.h>
+#include <pubkey.h>
#include <support/allocators/secure.h>
+#include <util/expected.h>
#include <util/fs.h>
#include <util/result.h>
#include <util/ui_change_type.h>
+#include <wallet/types.h>
#include <compare>
#include <cstddef>
@@ -30,19 +33,17 @@
#include <vector>
class ArgsManager;
-class CKeyID;
-class CPubKey;
class CScript;
class PartiallySignedTransaction;
class uint256;
enum class FeeReason;
enum class OutputType;
struct bilingual_str;
+struct CExtKey;
+
namespace wallet {
-struct CreatedTransactionResult;
class CCoinControl;
class CWallet;
-enum class AddressPurpose;
struct CRecipient;
struct WalletContext;
} // namespace wallet
@@ -97,6 +98,13 @@ class Wallet
//! Get public key.
virtual bool getPubKey(const CScript& script, const CKeyID& address, CPubKey& pub_key) = 0;
+ //! Generate and add a new HD key to the wallet.
+ //! Requires the wallet to be unlocked. Returns a `WalletError` with code
+ //! `WalletErrorCode::UnlockNeeded` if the wallet is locked.
+ //!
+ //! Return the master xpub for the added HD key, or a `WalletError` on failure.
+ virtual util::Expected<CExtPubKey, wallet::WalletError> addHDKey(const std::optional<CExtKey>& key) = 0;
+
//! Sign message
virtual SigningResult signMessage(const std::string& message, const PKHash& pkhash, std::string& str_sig) = 0;
### src/wallet/interfaces.cpp
@@ -10,6 +10,7 @@
#include <interfaces/handler.h>
#include <node/types.h>
#include <primitives/transaction.h>
+#include <pubkey.h>
#include <rpc/server.h>
#include <scheduler.h>
#include <support/allocators/secure.h>
@@ -30,6 +31,7 @@
#include <wallet/wallet.h>
#include <memory>
+#include <optional>
#include <string>
#include <utility>
#include <vector>
@@ -166,6 +168,11 @@ class WalletImpl : public Wallet
}
return false;
}
+ util::Expected<CExtPubKey, wallet::WalletError> addHDKey(const std::optional<CExtKey>& key) override
+ {
+ return m_wallet->AddHDKey(key);
+ }
+
SigningResult signMessage(const std::string& message, const PKHash& pkhash, std::string& str_sig) override
{
return m_wallet->SignMessage(message, pkhash, str_sig);
### src/wallet/rpc/wallet.cpp
@@ -863,13 +863,10 @@ RPCMethod addhdkey()
EnsureWalletIsUnlocked(*wallet);
- CExtKey hdkey;
- if (request.params[0].isNull()) {
- CKey seed_key = GenerateRandomKey();
- hdkey.SetSeed(seed_key);
- } else {
+ std::optional<CExtKey> hdkey;
+ if (!request.params[0].isNull()) {
hdkey = DecodeExtKey(request.params[0].get_str());
- if (!hdkey.key.IsValid()) {
+ if (!hdkey->key.IsValid()) {
// Check if the user gave us an xpub and give a more descriptive error if so
CExtPubKey xpub = DecodeExtPubKey(request.params[0].get_str());
if (xpub.pubkey.IsValid()) {
@@ -880,32 +877,16 @@ RPCMethod addhdkey()
}
}
- LOCK(wallet->cs_wallet);
- std::string desc_str = "unused(" + EncodeExtKey(hdkey) + ")";
- FlatSigningProvider keys;
- std::string error;
- std::vector<std::unique_ptr<Descriptor>> descs = Parse(desc_str, keys, error, false);
- CHECK_NONFATAL(!descs.empty());
- WalletDescriptor w_desc(std::move(descs.at(0)), GetTime(), 0, 0, 0);
- if (wallet->GetDescriptorScriptPubKeyMan(w_desc) != nullptr) {
- throw JSONRPCError(RPC_WALLET_ERROR, "HD key already exists");
- }
-
- auto spkm = wallet->AddWalletDescriptor(w_desc, keys, /*label=*/"", /*internal=*/false);
- if (!spkm) {
- throw JSONRPCError(RPC_WALLET_ERROR, util::ErrorString(spkm).original);
+ auto res = wallet->AddHDKey(hdkey);
+ if (!res) {
+ if (res.error().code == wallet::WalletErrorCode::UnlockNeeded) {
+ throw JSONRPCError(RPC_WALLET_UNLOCK_NEEDED, res.error().message.original);
+ }
+ throw JSONRPCError(RPC_WALLET_ERROR, res.error().message.original);
}
UniValue response(UniValue::VOBJ);
- const DescriptorScriptPubKeyMan& desc_spkm = spkm->get();
- LOCK(desc_spkm.cs_desc_man);
- std::set<CPubKey> pubkeys;
- std::set<CExtPubKey> extpubs;
- desc_spkm.GetWalletDescriptor().descriptor->GetPubKeys(pubkeys, extpubs);
- CHECK_NONFATAL(pubkeys.size() == 0);
- CHECK_NONFATAL(extpubs.size() == 1);
- response.pushKV("xpub", EncodeExtPubKey(*extpubs.begin()));
-
+ response.pushKV("xpub", EncodeExtPubKey(*res));
return response;
},
};
### src/wallet/test/CMakeLists.txt
@@ -19,6 +19,7 @@ target_sources(test_bitcoin
scriptpubkeyman_tests.cpp
spend_tests.cpp
wallet_crypto_tests.cpp
+ wallet_interfaces_tests.cpp
wallet_rpc_tests.cpp
wallet_tests.cpp
wallet_transaction_tests.cpp
### src/wallet/test/wallet_interfaces_tests.cpp
@@ -0,0 +1,81 @@
+// Copyright (c) 2026-present The Bitcoin Core developers
+// Distributed under the MIT software license, see the accompanying
+// file COPYING or https://www.opensource.org/licenses/mit-license.php.
+
+#include <interfaces/wallet.h>
+#include <key_io.h>
+#include <test/util/setup_common.h>
+#include <wallet/context.h>
+#include <wallet/test/util.h>
+#include <wallet/wallet.h>
+
+#include <boost/test/unit_test.hpp>
+#include <optional>
+
+namespace wallet {
+
+BOOST_FIXTURE_TEST_SUITE(wallet_interfaces_tests, BasicTestingSetup)
+
+BOOST_AUTO_TEST_CASE(addhdkey)
+{
+ WalletContext context;
+ context.args = &m_args;
+ auto wallet = TestCreateWallet(context);
+ auto interface = interfaces::MakeWallet(context, wallet);
+
+ auto result = interface->addHDKey(std::nullopt);
+ BOOST_REQUIRE(result);
+ BOOST_CHECK(result->pubkey.IsValid());
+}
+
+BOOST_AUTO_TEST_CASE(addhdkey_with_key)
+{
+ WalletContext context;
+ context.args = &m_args;
+ auto wallet = TestCreateWallet(context);
+ auto interface = interfaces::MakeWallet(context, wallet);
+
+ CKey seed_key = GenerateRandomKey();
+ CExtKey key;
+ key.SetSeed(seed_key);
+
+ auto result = interface->addHDKey(key);
+ BOOST_REQUIRE(result);
+ BOOST_CHECK(result->pubkey.IsValid());
+ BOOST_CHECK_EQUAL(EncodeExtPubKey(*result), EncodeExtPubKey(key.Neuter()));
+}
+
+BOOST_AUTO_TEST_CASE(addhdkey_with_malformed_key)
+{
+ WalletContext context;
+ context.args = &m_args;
+ auto wallet = TestCreateWallet(context);
+ auto interface = interfaces::MakeWallet(context, wallet);
+
+ CExtKey key;
+ key.SetSeed(GenerateRandomKey());
+ key.nChild = 1;
+
+ auto result = interface->addHDKey(key);
+ BOOST_REQUIRE(!result);
+ BOOST_CHECK(result.error().code == WalletErrorCode::GenericError);
+ BOOST_CHECK_EQUAL(result.error().message.original, "Invalid HD key");
+}
+
+BOOST_AUTO_TEST_CASE(addhdkey_wallet_locked)
+{
+ WalletContext context;
+ context.args = &m_args;
+ auto wallet = TestCreateWallet(context);
+ BOOST_REQUIRE(wallet->EncryptWallet("hunter2"));
+ BOOST_REQUIRE(wallet->Lock());
+
+ auto interface = interfaces::MakeWallet(context, wallet);
+ auto result = interface->addHDKey(std::nullopt);
+ BOOST_REQUIRE(!result);
+ BOOST_CHECK(result.error().code == WalletErrorCode::UnlockNeeded);
+}
+
+BOOST_AUTO_TEST_SUITE_END()
+
+} // namespace wallet
### src/wallet/wallet.cpp
@@ -53,6 +53,7 @@
#include <uint256.h>
#include <univalue.h>
#include <util/check.h>
+#include <util/expected.h>
#include <util/fs.h>
#include <util/fs_helpers.h>
#include <util/log.h>
@@ -3873,6 +3874,77 @@ util::Result<std::reference_wrapper<DescriptorScriptPubKeyMan>> CWallet::AddWall
return std::reference_wrapper(*spk_man);
}
+util::Expected<CExtPubKey, WalletError> CWallet::AddHDKey(const std::optional<CExtKey>& key)
+{
+ LOCK(cs_wallet);
+
+ if (key && !key->key.IsValid()) {
+ return util::Unexpected{WalletError{
+ WalletErrorCode::GenericError,
+ _("Invalid HD key"),
+ }};
+ }
+
+ if (IsWalletFlagSet(WALLET_FLAG_DISABLE_PRIVATE_KEYS)) {
+ return util::Unexpected{WalletError{
+ WalletErrorCode::GenericError,
+ _("addhdkey is not available for wallets without private keys")
+ }};
+ }
+
+ if (IsLocked()) {
+ return util::Unexpected{WalletError{
+ WalletErrorCode::UnlockNeeded,
+ _("Wallet needs to be unlocked to perform this operation.")
+ }};
+ }
+
+ CExtKey hdkey;
+ if (key) {
+ hdkey = *key;
+ } else {
+ CKey seed_key = GenerateRandomKey();
+ hdkey.SetSeed(seed_key);
+ }
+
+ std::string desc_str = "unused(" + EncodeExtKey(hdkey) + ")";
+ FlatSigningProvider keys;
+ std::string parse_error;
+ std::vector<std::unique_ptr<Descriptor>> descs = Parse(desc_str, keys, parse_error, /*require_checksum=*/false);
+ if (descs.empty()) {
+ return util::Unexpected{WalletError{
+ WalletErrorCode::GenericError,
+ _("Invalid HD key")
+ }};
+ }
+ WalletDescriptor w_desc(std::move(descs.at(0)), GetTime(), /*range_start=*/0, /*range_end=*/0, /*next_index=*/0);
+
+ if (GetDescriptorScriptPubKeyMan(w_desc) != nullptr) {
+ return util::Unexpected{WalletError{
+ WalletErrorCode::GenericError,
+ _("HD key already exists")
+ }};
+ }
+
+ auto spkm = AddWalletDescriptor(w_desc, keys, /*label=*/"", /*internal=*/false);
+ if(!spkm) {
+ return util::Unexpected{WalletError{
+ WalletErrorCode::GenericError,
+ util::ErrorString(spkm),
+ }};
+ }
+
+ const DescriptorScriptPubKeyMan& desc_spkm = spkm->get();
+ LOCK(desc_spkm.cs_desc_man);
+ std::set<CPubKey> pubkeys;
+ std::set<CExtPubKey> extpubs;
+ desc_spkm.GetWalletDescriptor().descriptor->GetPubKeys(pubkeys, extpubs);
+ Assume(pubkeys.empty());
+ Assume(extpubs.size() == 1);
+
+ return *extpubs.begin();
+}
+
bool CWallet::MigrateToSQLite(bilingual_str& error)
{
AssertLockHeld(cs_wallet);
### src/wallet/wallet.h
@@ -11,11 +11,13 @@
#include <interfaces/chain.h>
#include <interfaces/handler.h>
#include <kernel/cs_main.h>
+#include <key.h>
#include <node/types.h>
#include <outputtype.h>
#include <policy/feerate.h>
#include <primitives/transaction.h>
#include <primitives/transaction_identifier.h>
+#include <pubkey.h>
#include <script/interpreter.h>
#include <script/script.h>
#include <support/allocators/secure.h>
@@ -1060,6 +1062,15 @@ class CWallet final : public WalletStorage, public interfaces::Chain::Notificati
//! Add a descriptor to the wallet, return a ScriptPubKeyMan & associated output type
util::Result<std::reference_wrapper<DescriptorScriptPubKeyMan>> AddWalletDescriptor(WalletDescriptor& desc, const FlatSigningProvider& signing_provider, const std::string& label, bool internal) EXCLUSIVE_LOCKS_REQUIRED(cs_wallet);
+ //! Add an HD key to the wallet and return its master xpub.
+ //! Requires the wallet to be unlocked. Returns a `WalletError` with code
+ //! `WalletErrorCode::UnlockNeeded` if the wallet is locked.
+ //!
+ //! @param[in] key Optional extended private key to add. If not provided,
+ //! a new random HD key will be generated.
+ //! @return The master xpub for the added HD key, or a `WalletError` on failure.
+ util::Expected<CExtPubKey, WalletError> AddHDKey(const std::optional<CExtKey>& key);
+
/** Move all records from the BDB database to a new SQLite database for storage.
* The original BDB file will be deleted and replaced with a new SQLite file.
* A backup is not created.
### test/functional/wallet_hd.py
@@ -49,6 +49,7 @@ def test_addhdkey(self):
imp_xprv = imp_xpub_info["xprv"]
assert_raises_rpc_error(-5, "Extended public key (xpub) provided, but extended private key (xprv) is required", wallet.addhdkey, imp_xpub)
+ assert_raises_rpc_error(-5, "Could not parse HD key", wallet.addhdkey, "not_an_extended_key")
add_res = wallet.addhdkey(imp_xprv)
expected_unused_desc = descsum_create(f"unused({imp_xpub})")
assert_equal(add_res["xpub"], imp_xpub)
@@ -77,6 +78,17 @@ def test_addhdkey_noprivs(self):
wallet = self.nodes[0].get_wallet_rpc("hdkey_noprivs")
assert_raises_rpc_error(-4, "addhdkey is not available for wallets without private keys", wallet.addhdkey)
+ def test_addhdkey_locked(self):
+ self.log.info("Test addhdkey requires an unlocked wallet")
+ self.nodes[0].createwallet(wallet_name="hdkey_locked", passphrase="passphrase")
+ wallet = self.nodes[0].get_wallet_rpc("hdkey_locked")
+ wallet.walletlock()
+ assert_raises_rpc_error(-13, "Please enter the wallet passphrase with walletpassphrase first", wallet.addhdkey)
+ # Once unlocked, adding an HD key succeeds
+ wallet.walletpassphrase("passphrase", 100)
+ add_res = wallet.addhdkey()
+ assert "xpub" in add_res
+
def run_test(self):
# Make sure we use hd, keep masterkeyid
hd_fingerprint = self.nodes[1].getaddressinfo(self.nodes[1].getnewaddress())['hdmasterfingerprint']
@@ -178,6 +190,7 @@ def run_test(self):
self.test_addhdkey()
self.test_addhdkey_noprivs()
+ self.test_addhdkey_locked()
if __name__ == '__main__':
WalletHDTest(__file__).main()Why this scored 19/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.