Skip to content

Commit 267917f

Browse files
committed
Merge bitcoin/bitcoin#23304: wallet: Derive inactive HD chains in additional places
c4d76c6 tests: Tests for inactive HD chains (Andrew Chow) 8077862 wallet: Refactor TopUp to be able to top up inactive chains too (Andrew Chow) 70134eb wallet: Properly set hd chain counters when loading (Andrew Chow) 961b9e4 wallet: Parse hdKeypath if key_origin is not available (Andrew Chow) 0652ee7 Add size check on meta.key_origin.path (Rob Fielding) Pull request description: Currently inactive HD chains are only derived from at the time a key in that chain is found to have been used. However, at that time, the wallet may not be able to derive keys (e.g. it is locked). Currently we would just move on and not derive any new keys, however this could result in missing funds. This PR resolves this problem by adding memory only variables to `CHDChain` which track the highest known index. `TopUp` is modified to always try to top up the inactive HD chains, and this process will use the new variables to determine how much to top up. In this way, after an encrypted wallet is unlocked, the inactive HD chains will be topped up and hopefully funds will not be missed. Note that because these variables are not persisted to disk (because `CHDChain`s for inactive HD chains are not written to disk), if an encrypted wallet is not unlocked in the same session as a key from an inactive chain is found to be used, then it will not be topped up later unless more keys are found. Additionally, wallets which do not have upgraded key metadata will not derive any keys from inactive HD chains. This is resolved by using the derivation path string in `CKeyMetadata.hdKeypath` to determine what indexes to derive. ACKs for top commit: laanwj: Code review ACK c4d76c6 Tree-SHA512: b2b572ad7f1b1b2847edece09f7583543d63997e18ae32764e5a27ad608dd64b9bdb2d84ea27137894e986a8e82f047a3dba9c8015b74f5f179961911f0c4095
2 parents ba11eb3 + c4d76c6 commit 267917f

File tree

7 files changed

+234
-55
lines changed

7 files changed

+234
-55
lines changed

src/wallet/scriptpubkeyman.cpp

Lines changed: 78 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -322,36 +322,21 @@ bool LegacyScriptPubKeyMan::TopUpInactiveHDChain(const CKeyID seed_id, int64_t i
322322
{
323323
LOCK(cs_KeyStore);
324324

325-
if (m_storage.IsLocked()) return false;
326-
327325
auto it = m_inactive_hd_chains.find(seed_id);
328326
if (it == m_inactive_hd_chains.end()) {
329327
return false;
330328
}
331329

332330
CHDChain& chain = it->second;
333331

334-
// Top up key pool
335-
int64_t target_size = std::max(gArgs.GetIntArg("-keypool", DEFAULT_KEYPOOL_SIZE), (int64_t) 1);
336-
337-
// "size" of the keypools. Not really the size, actually the difference between index and the chain counter
338-
// Since chain counter is 1 based and index is 0 based, one of them needs to be offset by 1.
339-
int64_t kp_size = (internal ? chain.nInternalChainCounter : chain.nExternalChainCounter) - (index + 1);
332+
if (internal) {
333+
chain.m_next_internal_index = std::max(chain.m_next_internal_index, index + 1);
334+
} else {
335+
chain.m_next_external_index = std::max(chain.m_next_external_index, index + 1);
336+
}
340337

341-
// make sure the keypool fits the user-selected target (-keypool)
342-
int64_t missing = std::max(target_size - kp_size, (int64_t) 0);
338+
TopUpChain(chain, 0);
343339

344-
if (missing > 0) {
345-
WalletBatch batch(m_storage.GetDatabase());
346-
for (int64_t i = missing; i > 0; --i) {
347-
GenerateNewKey(batch, chain, internal);
348-
}
349-
if (internal) {
350-
WalletLogPrintf("inactive seed with id %s added %d internal keys\n", HexStr(seed_id), missing);
351-
} else {
352-
WalletLogPrintf("inactive seed with id %s added %d keys\n", HexStr(seed_id), missing);
353-
}
354-
}
355340
return true;
356341
}
357342

@@ -383,11 +368,26 @@ std::vector<WalletDestination> LegacyScriptPubKeyMan::MarkUnusedAddresses(const
383368
if (it != mapKeyMetadata.end()){
384369
CKeyMetadata meta = it->second;
385370
if (!meta.hd_seed_id.IsNull() && meta.hd_seed_id != m_hd_chain.seed_id) {
386-
bool internal = (meta.key_origin.path[1] & ~BIP32_HARDENED_KEY_LIMIT) != 0;
387-
int64_t index = meta.key_origin.path[2] & ~BIP32_HARDENED_KEY_LIMIT;
388-
389-
if (!TopUpInactiveHDChain(meta.hd_seed_id, index, internal)) {
390-
WalletLogPrintf("%s: Adding inactive seed keys failed\n", __func__);
371+
std::vector<uint32_t> path;
372+
if (meta.has_key_origin) {
373+
path = meta.key_origin.path;
374+
} else if (!ParseHDKeypath(meta.hdKeypath, path)) {
375+
WalletLogPrintf("%s: Adding inactive seed keys failed, invalid hdKeypath: %s\n",
376+
__func__,
377+
meta.hdKeypath);
378+
}
379+
if (path.size() != 3) {
380+
WalletLogPrintf("%s: Adding inactive seed keys failed, invalid path size: %d, has_key_origin: %s\n",
381+
__func__,
382+
path.size(),
383+
meta.has_key_origin);
384+
} else {
385+
bool internal = (path[1] & ~BIP32_HARDENED_KEY_LIMIT) != 0;
386+
int64_t index = path[2] & ~BIP32_HARDENED_KEY_LIMIT;
387+
388+
if (!TopUpInactiveHDChain(meta.hd_seed_id, index, internal)) {
389+
WalletLogPrintf("%s: Adding inactive seed keys failed\n", __func__);
390+
}
391391
}
392392
}
393393
}
@@ -1265,44 +1265,69 @@ bool LegacyScriptPubKeyMan::TopUp(unsigned int kpSize)
12651265
if (!CanGenerateKeys()) {
12661266
return false;
12671267
}
1268-
{
1269-
LOCK(cs_KeyStore);
12701268

1271-
if (m_storage.IsLocked()) return false;
1269+
if (!TopUpChain(m_hd_chain, kpSize)) {
1270+
return false;
1271+
}
1272+
for (auto& [chain_id, chain] : m_inactive_hd_chains) {
1273+
if (!TopUpChain(chain, kpSize)) {
1274+
return false;
1275+
}
1276+
}
1277+
NotifyCanGetAddressesChanged();
1278+
return true;
1279+
}
12721280

1273-
// Top up key pool
1274-
unsigned int nTargetSize;
1275-
if (kpSize > 0)
1276-
nTargetSize = kpSize;
1277-
else
1278-
nTargetSize = std::max(gArgs.GetIntArg("-keypool", DEFAULT_KEYPOOL_SIZE), (int64_t) 0);
1281+
bool LegacyScriptPubKeyMan::TopUpChain(CHDChain& chain, unsigned int kpSize)
1282+
{
1283+
LOCK(cs_KeyStore);
12791284

1280-
// count amount of available keys (internal, external)
1281-
// make sure the keypool of external and internal keys fits the user selected target (-keypool)
1282-
int64_t missingExternal = std::max(std::max((int64_t) nTargetSize, (int64_t) 1) - (int64_t)setExternalKeyPool.size(), (int64_t) 0);
1283-
int64_t missingInternal = std::max(std::max((int64_t) nTargetSize, (int64_t) 1) - (int64_t)setInternalKeyPool.size(), (int64_t) 0);
1285+
if (m_storage.IsLocked()) return false;
12841286

1285-
if (!IsHDEnabled() || !m_storage.CanSupportFeature(FEATURE_HD_SPLIT))
1286-
{
1287-
// don't create extra internal keys
1288-
missingInternal = 0;
1287+
// Top up key pool
1288+
unsigned int nTargetSize;
1289+
if (kpSize > 0) {
1290+
nTargetSize = kpSize;
1291+
} else {
1292+
nTargetSize = std::max(gArgs.GetIntArg("-keypool", DEFAULT_KEYPOOL_SIZE), int64_t{0});
1293+
}
1294+
int64_t target = std::max((int64_t) nTargetSize, int64_t{1});
1295+
1296+
// count amount of available keys (internal, external)
1297+
// make sure the keypool of external and internal keys fits the user selected target (-keypool)
1298+
int64_t missingExternal;
1299+
int64_t missingInternal;
1300+
if (chain == m_hd_chain) {
1301+
missingExternal = std::max(target - (int64_t)setExternalKeyPool.size(), int64_t{0});
1302+
missingInternal = std::max(target - (int64_t)setInternalKeyPool.size(), int64_t{0});
1303+
} else {
1304+
missingExternal = std::max(target - (chain.nExternalChainCounter - chain.m_next_external_index), int64_t{0});
1305+
missingInternal = std::max(target - (chain.nInternalChainCounter - chain.m_next_internal_index), int64_t{0});
1306+
}
1307+
1308+
if (!IsHDEnabled() || !m_storage.CanSupportFeature(FEATURE_HD_SPLIT)) {
1309+
// don't create extra internal keys
1310+
missingInternal = 0;
1311+
}
1312+
bool internal = false;
1313+
WalletBatch batch(m_storage.GetDatabase());
1314+
for (int64_t i = missingInternal + missingExternal; i--;) {
1315+
if (i < missingInternal) {
1316+
internal = true;
12891317
}
1290-
bool internal = false;
1291-
WalletBatch batch(m_storage.GetDatabase());
1292-
for (int64_t i = missingInternal + missingExternal; i--;)
1293-
{
1294-
if (i < missingInternal) {
1295-
internal = true;
1296-
}
12971318

1298-
CPubKey pubkey(GenerateNewKey(batch, m_hd_chain, internal));
1319+
CPubKey pubkey(GenerateNewKey(batch, chain, internal));
1320+
if (chain == m_hd_chain) {
12991321
AddKeypoolPubkeyWithDB(pubkey, internal, batch);
13001322
}
1301-
if (missingInternal + missingExternal > 0) {
1323+
}
1324+
if (missingInternal + missingExternal > 0) {
1325+
if (chain == m_hd_chain) {
13021326
WalletLogPrintf("keypool added %d keys (%d internal), size=%u (%u internal)\n", missingInternal + missingExternal, missingInternal, setInternalKeyPool.size() + setExternalKeyPool.size() + set_pre_split_keypool.size(), setInternalKeyPool.size());
1327+
} else {
1328+
WalletLogPrintf("inactive seed with id %s added %d external keys, %d internal keys\n", HexStr(chain.seed_id), missingExternal, missingInternal);
13031329
}
13041330
}
1305-
NotifyCanGetAddressesChanged();
13061331
return true;
13071332
}
13081333

src/wallet/scriptpubkeyman.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,7 @@ class LegacyScriptPubKeyMan : public ScriptPubKeyMan, public FillableSigningProv
355355
*/
356356
bool TopUpInactiveHDChain(const CKeyID seed_id, int64_t index, bool internal);
357357

358+
bool TopUpChain(CHDChain& chain, unsigned int size);
358359
public:
359360
using ScriptPubKeyMan::ScriptPubKeyMan;
360361

src/wallet/walletdb.cpp

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -557,9 +557,9 @@ ReadKeyValue(CWallet* pwallet, CDataStream& ssKey, CDataStream& ssValue,
557557
}
558558
if (internal) {
559559
chain.nVersion = CHDChain::VERSION_HD_CHAIN_SPLIT;
560-
chain.nInternalChainCounter = std::max(chain.nInternalChainCounter, index);
560+
chain.nInternalChainCounter = std::max(chain.nInternalChainCounter, index + 1);
561561
} else {
562-
chain.nExternalChainCounter = std::max(chain.nExternalChainCounter, index);
562+
chain.nExternalChainCounter = std::max(chain.nExternalChainCounter, index + 1);
563563
}
564564
}
565565
} else if (strType == DBKeys::WATCHMETA) {

src/wallet/walletdb.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ class CHDChain
9393
uint32_t nExternalChainCounter;
9494
uint32_t nInternalChainCounter;
9595
CKeyID seed_id; //!< seed hash160
96+
int64_t m_next_external_index{0}; // Next index in the keypool to be used. Memory only.
97+
int64_t m_next_internal_index{0}; // Next index in the keypool to be used. Memory only.
9698

9799
static const int VERSION_HD_BASE = 1;
98100
static const int VERSION_HD_CHAIN_SPLIT = 2;

test/functional/test_framework/test_node.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -743,6 +743,9 @@ def __init__(self, rpc, cli=False, descriptors=False):
743743
def __getattr__(self, name):
744744
return getattr(self.rpc, name)
745745

746+
def createwallet_passthrough(self, *args, **kwargs):
747+
return self.__getattr__("createwallet")(*args, **kwargs)
748+
746749
def createwallet(self, wallet_name, disable_private_keys=None, blank=None, passphrase='', avoid_reuse=None, descriptors=None, load_on_startup=None, external_signer=None):
747750
if descriptors is None:
748751
descriptors = self.descriptors

test/functional/test_runner.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,7 @@
281281
'wallet_send.py --descriptors',
282282
'wallet_create_tx.py --descriptors',
283283
'wallet_taproot.py',
284+
'wallet_inactive_hdchains.py',
284285
'p2p_fingerprint.py',
285286
'feature_uacomment.py',
286287
'feature_init.py',
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
#!/usr/bin/env python3
2+
# Copyright (c) 2021 The Bitcoin Core developers
3+
# Distributed under the MIT software license, see the accompanying
4+
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
5+
"""
6+
Test Inactive HD Chains.
7+
"""
8+
import os
9+
import shutil
10+
import time
11+
12+
from test_framework.authproxy import JSONRPCException
13+
from test_framework.test_framework import BitcoinTestFramework
14+
from test_framework.wallet_util import (
15+
get_generate_key,
16+
)
17+
18+
19+
class InactiveHDChainsTest(BitcoinTestFramework):
20+
def set_test_params(self):
21+
self.setup_clean_chain = True
22+
self.num_nodes = 2
23+
self.extra_args = [["-keypool=10"], ["-nowallet", "-keypool=10"]]
24+
25+
def skip_test_if_missing_module(self):
26+
self.skip_if_no_wallet()
27+
self.skip_if_no_bdb()
28+
self.skip_if_no_previous_releases()
29+
30+
def setup_nodes(self):
31+
self.add_nodes(self.num_nodes, extra_args=self.extra_args, versions=[
32+
None,
33+
170200, # 0.17.2 Does not have the key metadata upgrade
34+
])
35+
36+
self.start_nodes()
37+
self.init_wallet(node=0)
38+
39+
def prepare_wallets(self, wallet_basename, encrypt=False):
40+
self.nodes[0].createwallet(wallet_name=f"{wallet_basename}_base", descriptors=False, blank=True)
41+
self.nodes[0].createwallet(wallet_name=f"{wallet_basename}_test", descriptors=False, blank=True)
42+
base_wallet = self.nodes[0].get_wallet_rpc(f"{wallet_basename}_base")
43+
test_wallet = self.nodes[0].get_wallet_rpc(f"{wallet_basename}_test")
44+
45+
# Setup both wallets with the same HD seed
46+
seed = get_generate_key()
47+
base_wallet.sethdseed(True, seed.privkey)
48+
test_wallet.sethdseed(True, seed.privkey)
49+
50+
if encrypt:
51+
# Encrypting will generate a new HD seed and flush the keypool
52+
test_wallet.encryptwallet("pass")
53+
else:
54+
# Generate a new HD seed on the test wallet
55+
test_wallet.sethdseed()
56+
57+
return base_wallet, test_wallet
58+
59+
def do_inactive_test(self, base_wallet, test_wallet, encrypt=False):
60+
default = self.nodes[0].get_wallet_rpc(self.default_wallet_name)
61+
62+
# The first address should be known by both wallets.
63+
addr1 = base_wallet.getnewaddress()
64+
assert test_wallet.getaddressinfo(addr1)["ismine"]
65+
# The address at index 9 is the first address that the test wallet will not know initially
66+
for _ in range(0, 9):
67+
base_wallet.getnewaddress()
68+
addr2 = base_wallet.getnewaddress()
69+
assert not test_wallet.getaddressinfo(addr2)["ismine"]
70+
71+
# Send to first address on the old seed
72+
txid = default.sendtoaddress(addr1, 10)
73+
self.generate(self.nodes[0], 1)
74+
75+
# Wait for the test wallet to see the transaction
76+
while True:
77+
try:
78+
test_wallet.gettransaction(txid)
79+
break
80+
except JSONRPCException:
81+
time.sleep(0.1)
82+
83+
if encrypt:
84+
# The test wallet will not be able to generate the topped up keypool
85+
# until it is unlocked. So it still should not know about the second address
86+
assert not test_wallet.getaddressinfo(addr2)["ismine"]
87+
test_wallet.walletpassphrase("pass", 1)
88+
89+
# The test wallet should now know about the second address as it
90+
# should have generated it in the inactive chain's keypool
91+
assert test_wallet.getaddressinfo(addr2)["ismine"]
92+
93+
# Send to second address on the old seed
94+
txid = default.sendtoaddress(addr2, 10)
95+
self.generate(self.nodes[0], 1)
96+
test_wallet.gettransaction(txid)
97+
98+
def test_basic(self):
99+
self.log.info("Test basic case for inactive HD chains")
100+
self.do_inactive_test(*self.prepare_wallets("basic"))
101+
102+
def test_encrypted_wallet(self):
103+
self.log.info("Test inactive HD chains when wallet is encrypted")
104+
self.do_inactive_test(*self.prepare_wallets("enc", encrypt=True), encrypt=True)
105+
106+
def test_without_upgraded_keymeta(self):
107+
# Test that it is possible to top up inactive hd chains even if there is no key origin
108+
# in CKeyMetadata. This tests for the segfault reported in
109+
# https://github.com/bitcoin/bitcoin/issues/21605
110+
self.log.info("Test that topping up inactive HD chains does not need upgraded key origin")
111+
112+
self.nodes[0].createwallet(wallet_name="keymeta_base", descriptors=False, blank=True)
113+
# Createwallet is overridden in the test framework so that the descriptor option can be filled
114+
# depending on the test's cli args. However we don't want to do that when using old nodes that
115+
# do not support descriptors. So we use the createwallet_passthrough function.
116+
self.nodes[1].createwallet_passthrough(wallet_name="keymeta_test")
117+
base_wallet = self.nodes[0].get_wallet_rpc("keymeta_base")
118+
test_wallet = self.nodes[1].get_wallet_rpc("keymeta_test")
119+
120+
# Setup both wallets with the same HD seed
121+
seed = get_generate_key()
122+
base_wallet.sethdseed(True, seed.privkey)
123+
test_wallet.sethdseed(True, seed.privkey)
124+
125+
# Encrypting will generate a new HD seed and flush the keypool
126+
test_wallet.encryptwallet("pass")
127+
128+
# Copy test wallet to node 0
129+
test_wallet.unloadwallet()
130+
test_wallet_dir = os.path.join(self.nodes[1].datadir, "regtest/wallets/keymeta_test")
131+
new_test_wallet_dir = os.path.join(self.nodes[0].datadir, "regtest/wallets/keymeta_test")
132+
shutil.copytree(test_wallet_dir, new_test_wallet_dir)
133+
self.nodes[0].loadwallet("keymeta_test")
134+
test_wallet = self.nodes[0].get_wallet_rpc("keymeta_test")
135+
136+
self.do_inactive_test(base_wallet, test_wallet, encrypt=True)
137+
138+
def run_test(self):
139+
self.generate(self.nodes[0], 101)
140+
141+
self.test_basic()
142+
self.test_encrypted_wallet()
143+
self.test_without_upgraded_keymeta()
144+
145+
146+
if __name__ == '__main__':
147+
InactiveHDChainsTest().main()

0 commit comments

Comments
 (0)