Skip to content

Commit b789907

Browse files
committed
wallet: migration, avoid creating spendable wallet from a watch-only legacy wallet
Currently, the migration process creates a brand-new descriptor wallet with no connection to the user's legacy wallet when the legacy wallet lacks key material and contains only watch-only scripts. This behavior is not aligned with user expectations. If the legacy wallet contains only watch-only scripts, the migration process should only generate a watch-only wallet instead.
1 parent e86d71b commit b789907

File tree

3 files changed

+60
-10
lines changed

3 files changed

+60
-10
lines changed

src/qt/walletcontroller.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -468,7 +468,7 @@ void MigrateWalletActivity::migrate(const std::string& name)
468468
auto res{node().walletLoader().migrateWallet(name, passphrase)};
469469

470470
if (res) {
471-
m_success_message = tr("The wallet '%1' was migrated successfully.").arg(GUIUtil::HtmlEscape(GUIUtil::WalletDisplayName(res->wallet->getWalletName())));
471+
m_success_message = tr("The wallet '%1' was migrated successfully.").arg(GUIUtil::HtmlEscape(GUIUtil::WalletDisplayName(name)));
472472
if (res->watchonly_wallet_name) {
473473
m_success_message += QChar(' ') + tr("Watchonly scripts have been migrated to a new wallet named '%1'.").arg(GUIUtil::HtmlEscape(GUIUtil::WalletDisplayName(res->watchonly_wallet_name.value())));
474474
}

src/wallet/wallet.cpp

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3872,6 +3872,9 @@ util::Result<void> CWallet::ApplyMigrationData(WalletBatch& local_wallet_batch,
38723872
return util::Error{Untranslated(STR_INTERNAL_BUG("Error: Legacy wallet data missing"))};
38733873
}
38743874

3875+
// Note: when the legacy wallet has no spendable scripts, it must be empty at the end of the process.
3876+
bool has_spendable_material = !data.desc_spkms.empty() || data.master_key.key.IsValid();
3877+
38753878
// Get all invalid or non-watched scripts that will not be migrated
38763879
std::set<CTxDestination> not_migrated_dests;
38773880
for (const auto& script : legacy_spkm->GetNotMineScriptPubKeys()) {
@@ -3903,9 +3906,9 @@ util::Result<void> CWallet::ApplyMigrationData(WalletBatch& local_wallet_batch,
39033906
m_external_spk_managers.clear();
39043907
m_internal_spk_managers.clear();
39053908

3906-
// Setup new descriptors
3909+
// Setup new descriptors (only if we are migrating any key material)
39073910
SetWalletFlagWithDB(local_wallet_batch, WALLET_FLAG_DESCRIPTORS);
3908-
if (!IsWalletFlagSet(WALLET_FLAG_DISABLE_PRIVATE_KEYS)) {
3911+
if (has_spendable_material && !IsWalletFlagSet(WALLET_FLAG_DISABLE_PRIVATE_KEYS)) {
39093912
// Use the existing master key if we have it
39103913
if (data.master_key.key.IsValid()) {
39113914
SetupDescriptorScriptPubKeyMans(local_wallet_batch, data.master_key);
@@ -4055,6 +4058,14 @@ util::Result<void> CWallet::ApplyMigrationData(WalletBatch& local_wallet_batch,
40554058
}
40564059
}
40574060

4061+
// If there was no key material in the main wallet, there should be no records on it anymore.
4062+
// This wallet will be discarded at the end of the process. Only wallets that contain the
4063+
// migrated records will be presented to the user.
4064+
if (!has_spendable_material) {
4065+
if (!m_address_book.empty()) return util::Error{_("Error: Not all address book records were migrated")};
4066+
if (!mapWallet.empty()) return util::Error{_("Error: Not all transaction records were migrated")};
4067+
}
4068+
40584069
return {}; // all good
40594070
}
40604071

@@ -4292,6 +4303,14 @@ util::Result<MigrationResult> MigrateLegacyToDescriptor(std::shared_ptr<CWallet>
42924303
}
42934304
}
42944305

4306+
// Indicates whether the current wallet is empty after migration.
4307+
// Notes:
4308+
// When non-empty: the local wallet becomes the main spendable wallet.
4309+
// When empty: The local wallet is excluded from the result, as the
4310+
// user does not expect an empty spendable wallet after
4311+
// migrating only watch-only scripts.
4312+
bool empty_local_wallet = false;
4313+
42954314
{
42964315
LOCK(local_wallet->cs_wallet);
42974316
// First change to using SQLite
@@ -4300,6 +4319,8 @@ util::Result<MigrationResult> MigrateLegacyToDescriptor(std::shared_ptr<CWallet>
43004319
// Do the migration of keys and scripts for non-empty wallets, and cleanup if it fails
43014320
if (HasLegacyRecords(*local_wallet)) {
43024321
success = DoMigration(*local_wallet, context, error, res);
4322+
// No scripts mean empty wallet after migration
4323+
empty_local_wallet = local_wallet->GetAllScriptPubKeyMans().empty();
43034324
} else {
43044325
// Make sure that descriptors flag is actually set
43054326
local_wallet->SetWalletFlag(WALLET_FLAG_DESCRIPTORS);
@@ -4313,6 +4334,15 @@ util::Result<MigrationResult> MigrateLegacyToDescriptor(std::shared_ptr<CWallet>
43134334
// fails to reload.
43144335
std::set<fs::path> wallet_dirs;
43154336
if (success) {
4337+
Assume(!res.wallet); // We will set it here.
4338+
// Check if the local wallet is empty after migration
4339+
if (empty_local_wallet) {
4340+
// This wallet has no records. We can safely remove it.
4341+
std::vector<fs::path> paths_to_remove = local_wallet->GetDatabase().Files();
4342+
local_wallet.reset();
4343+
for (const auto& path_to_remove : paths_to_remove) fs::remove_all(path_to_remove);
4344+
}
4345+
43164346
// Migration successful, unload all wallets locally, then reload them.
43174347
// Note: We use a pointer to the shared_ptr to avoid increasing its reference count,
43184348
// as 'reload_wallet' expects to be the sole owner (use_count == 1).
@@ -4322,12 +4352,14 @@ util::Result<MigrationResult> MigrateLegacyToDescriptor(std::shared_ptr<CWallet>
43224352
// Save db path and reload wallet
43234353
wallet_dirs.insert(fs::PathFromString(wallet->GetDatabase().Filename()).parent_path());
43244354
success = reload_wallet(wallet);
4355+
4356+
// When no wallet is set, set the main wallet.
4357+
if (!res.wallet) {
4358+
res.wallet_name = wallet->GetName();
4359+
res.wallet = std::move(wallet);
4360+
}
43254361
}
43264362
}
4327-
4328-
// Set main wallet
4329-
res.wallet = local_wallet;
4330-
res.wallet_name = wallet_name;
43314363
}
43324364
if (!success) {
43334365
// Migration failed, cleanup

test/functional/wallet_migration.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
# Distributed under the MIT software license, see the accompanying
44
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
55
"""Test Migrating a wallet from legacy to descriptor."""
6-
6+
import os.path
77
import random
88
import shutil
99
import struct
@@ -116,9 +116,14 @@ def migrate_and_get_rpc(self, wallet_name, **kwargs):
116116
# Migrate, checking that rescan does not occur
117117
with self.master_node.assert_debug_log(expected_msgs=[], unexpected_msgs=["Rescanning"]):
118118
migrate_info = self.master_node.migratewallet(wallet_name=wallet_name, **kwargs)
119+
# Update wallet name in case the initial wallet was completely migrated to a watch-only wallet
120+
# (in which case the wallet name would be suffixed by the 'watchonly' term)
121+
wallet_name = migrate_info['wallet_name']
119122
wallet = self.master_node.get_wallet_rpc(wallet_name)
120123
assert_equal(wallet.getwalletinfo()["descriptors"], True)
121124
self.assert_is_sqlite(wallet_name)
125+
# Always verify the backup path exist after migration
126+
assert os.path.exists(migrate_info['backup_path'])
122127
return migrate_info, wallet
123128

124129
def test_basic(self):
@@ -1014,8 +1019,15 @@ def test_migrate_simple_watch_only(self):
10141019
wallet.importaddress(address=p2pk_script.hex())
10151020
# Migrate wallet in the latest node
10161021
res, _ = self.migrate_and_get_rpc("bare_p2pk")
1017-
wo_wallet = self.master_node.get_wallet_rpc(res['watchonly_name'])
1022+
wo_wallet = self.master_node.get_wallet_rpc(res['wallet_name'])
10181023
assert_equal(wo_wallet.listdescriptors()['descriptors'][0]['desc'], descsum_create(f'pk({pubkey.hex()})'))
1024+
assert_equal(wo_wallet.getwalletinfo()["private_keys_enabled"], False)
1025+
1026+
# Ensure that migrating a wallet with watch-only scripts does not create a spendable wallet.
1027+
assert_equal('bare_p2pk_watchonly', res['wallet_name'])
1028+
assert "bare_p2pk" not in self.master_node.listwallets()
1029+
assert "bare_p2pk" not in [w["name"] for w in self.master_node.listwalletdir()["wallets"]]
1030+
10191031
wo_wallet.unloadwallet()
10201032

10211033
def test_manual_keys_import(self):
@@ -1045,7 +1057,9 @@ def test_manual_keys_import(self):
10451057
res, _ = self.migrate_and_get_rpc("import_pubkeys")
10461058

10471059
# Same as before, there should be descriptors in the watch-only wallet for the imported pubkey
1048-
wo_wallet = self.nodes[0].get_wallet_rpc(res['watchonly_name'])
1060+
wo_wallet = self.nodes[0].get_wallet_rpc(res['wallet_name'])
1061+
# Assert this is a watch-only wallet
1062+
assert_equal(wo_wallet.getwalletinfo()["private_keys_enabled"], False)
10491063
# As we imported the pubkey only, there will be no key origin in the following descriptors
10501064
pk_desc = descsum_create(f'pk({pubkey_hex})')
10511065
pkh_desc = descsum_create(f'pkh({pubkey_hex})')
@@ -1056,6 +1070,10 @@ def test_manual_keys_import(self):
10561070
# Verify all expected descriptors were migrated
10571071
migrated_desc = [item['desc'] for item in wo_wallet.listdescriptors()['descriptors']]
10581072
assert_equal(expected_descs, migrated_desc)
1073+
# Ensure that migrating a wallet with watch-only scripts does not create a spendable wallet.
1074+
assert_equal('import_pubkeys_watchonly', res['wallet_name'])
1075+
assert "import_pubkeys" not in self.master_node.listwallets()
1076+
assert "import_pubkeys" not in [w["name"] for w in self.master_node.listwalletdir()["wallets"]]
10591077
wo_wallet.unloadwallet()
10601078

10611079
def test_p2wsh(self):

0 commit comments

Comments
 (0)