Skip to content

wallet: Migrate legacy wallets to descriptor wallets#19602

Merged
achow101 merged 11 commits intobitcoin:masterfrom
achow101:descriptor-wallet-migration
Sep 1, 2022
Merged

wallet: Migrate legacy wallets to descriptor wallets#19602
achow101 merged 11 commits intobitcoin:masterfrom
achow101:descriptor-wallet-migration

Conversation

@achow101
Copy link
Member

@achow101 achow101 commented Jul 27, 2020

This PR adds a new migratewallet RPC which migrates a legacy wallet to a descriptor wallet. Migrated wallets will need a new backup. If a wallet has watchonly stuff in it, a new watchonly descriptor wallet will be created containing those watchonly things. The related transactions, labels, and descriptors for those watchonly things will be removed from the original wallet. Migrated wallets will not have any of the legacy things be available for fetching from getnewaddress or getrawchangeaddress. Wallets that have private keys enabled will have newly generated descriptors. Wallets with private keys disabled will not have any active ScriptPubKeyMans.

For the basic HD wallet case of just generated keys, in addition to the standard descriptor wallet descriptors using the master key derived from the pre-existing hd seed, the migration will also create 3 descriptors for each HD chain in: a ranged combo external, a ranged combo internal, and a single key combo for the seed (the seed is a valid key that we can receive coins at!). The migrated wallet will then have newly generated descriptors as the active ScriptPubKeyMans. This is equivalent to creating a new descriptor wallet and importing the 3 descriptors for each HD chain. For wallets containing non-HD keys, each key will have its own combo descriptor.

There are also tests.

@DrahtBot
Copy link
Contributor

DrahtBot commented Jul 27, 2020

The following sections might be updated with supplementary metadata relevant to reviewers and maintainers.

Conflicts

Reviewers, this pull request conflicts with the following ones:

  • #25957 (wallet: fast rescan with BIP157 block filters for descriptor wallets by theStack)
  • #25907 (wallet: rpc to add automatically generated descriptors by achow101)
  • #25297 (wallet: group independent db writes on single batched db transaction by furszy)
  • #23417 (wallet, spkm: Move key management from DescriptorScriptPubKeyMan to wallet level KeyManager by achow101)
  • #22838 (descriptors: Be able to specify change and receiving in a single descriptor string by achow101)
  • #22341 (rpc: add getxpub by Sjors)
  • #20205 (wallet: Properly support a wallet id by achow101)

If you consider this pull request important, please also help to review the conflicting pull requests. Ideally, start with the one that should be merged first.

@hebasto
Copy link
Member

hebasto commented Jul 29, 2020

@achow101
It seems this PR has problems with linters.

@achow101 achow101 force-pushed the descriptor-wallet-migration branch 3 times, most recently from 5bb71b2 to ac5549a Compare July 29, 2020 18:48
@luke-jr
Copy link
Member

luke-jr commented Jul 31, 2020

Why doesn't this extend upgradewallet?

@achow101
Copy link
Member Author

Why doesn't this extend upgradewallet?

It didn't feel like it fit upgradewallet as I see that more for changing the wallet version number and using legacy wallet features. But this is changing a legacy wallet to something completely new.

This is also pretty invasive and fundamentally changes how the wallet is working, so I wanted to keep it separate from something that people might still want to use on legacy wallets.

@laanwj
Copy link
Member

laanwj commented Aug 12, 2020

I think we should only add this functionality after sqlite wallets? Otherwise you'd keep migrating.

@achow101
Copy link
Member Author

I think we should only add this functionality after sqlite wallets? Otherwise you'd keep migrating.

I think there's a question of whether we want to keep sqlite separate from descriptors. We might want to allow legacy wallets to migrate to sqlite without migrating to descriptors since they are orthogonal.

Copy link
Member

@maflcko maflcko left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left a style-nit. Feel free to ignore.

@meshcollider
Copy link
Contributor

Do you know what the performance of a wallet would be like, in the case of a non-HD wallet being migrated?

@achow101
Copy link
Member Author

Do you know what the performance of a wallet would be like, in the case of a non-HD wallet being migrated?

Probably worse.

@meshcollider
Copy link
Contributor

Probably worse.

Almost certainly, that's why I'm asking :) It would be nice to know how much worse it would be

Copy link
Member

@Sjors Sjors left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the changes. Reviewed another commit, will continue later.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

c50135edb6414fa5fd598f65e13eaa0e639573b9: maybe add comment: // These keys are deleted by the migratewallet RPC to ensure we don't add more stuff to this list without making sure they get migrated.

Note to other reviewers (plus some open questions), these types are captured by the backup as follows:

  • CRYPTED_KEY: when walletdb.cpp loads them, it puts them into mapCryptedKeys via LoadCryptedKey(). MigrateToDescriptor() iterates over that map.
  • CSCRIPT: when walletdb.cpp loads them, it puts them into mapScripts via LoadCScript().
    • MigrateToDescriptor() iterates over any multisig scripts in that map
    • TODO: I'm unclear what happens to mapScripts entries that are not multisig (see comment on earlier commit)
    • caveat: old records with redeemScript.size() > MAX_SCRIPT_ELEMENT_SIZE are lost in the upgrade (they're in the backup)
  • DEFAULTKEY: this is only used to detect corruption, and is not migrated, newer wallets don't have this key
  • HDCHAIN: m_hd_chain which we migrate
  • KEYMETA: ends up in mapKeyMetadata via LoadKeyMetadata, which we migrate (at least for every key we have).
  • KEY: when walletdb.cpp loads them, it puts them into mapKeys via LoadKey(). MigrateToDescriptor() iterates over that map.
  • OLD_KEY: these can only be loaded with v0.18 and older. They are skipped during wallet load, so they are lost in the migration.
  • POOL: these are ignored, we regenerate the keypool using descriptors
  • WATCHMETA: put in m_script_metadata by LoadScriptMetadata, and indexed by their CScriptID, TODO: same question as with CSCRIPT
  • WATCHS put in mapWatchKeys via LoadWatchOnly, and its keys are used to monitor PKH, etc via ImplicitlyLearnRelatedKeyScripts. We don't iterate over mapWatchKeys directly, so I'm a bit confused how these are migrated.


std::vector<std::vector<unsigned char>> sols;
TxoutType type = Solver(script, sols);
if (type == TxoutType::MULTISIG) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

35f428f What happens to mapScripts entries that are not MULTISIG?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They will be part of spks and added to the watchonly wallet. It is not possible to import other scripts without also watching them (and their P2(W)SH). Multisigs are special because we have addmultisigaddress which add the multisig but does not watch them. All of the import rpcs will watch.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

c50135edb6414fa5fd598f65e13eaa0e639573b9 : DeleteLegacyRecords (since we keep plenty of other stuff)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We delete all of the LegacyScriptPubKeyMan records (which this function is a member of). There are no remaining LegacySPKM records after this.

Copy link
Member

@Sjors Sjors left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed up to a6c0e2f844f589df4f2d19e38c2e350952e1b4bf. Mostly happy. I'm a bit confused about how legacy wallets deal with (unwatched) solvable scripts, so I'll review that aspect again once I wrap my head around it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a6c0e2f844f589df4f2d19e38c2e350952e1b4bf: bit of an aesthetic point, but I don't think the MigrationData struct should have the CWallet pointers. Instead you could just pass watchonly_wallet and solvable_wallet into ApplyMigrationData. That also makes it more clear what the data is applied to.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think that's any clearer, and would like to keep the function signature small.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a6c0e2f844f589df4f2d19e38c2e350952e1b4bf: if this fails after the file deletion, can we try to move the backup back in place? If that fails too, have the RPC throw an error telling the user the name of the backup file and they need to copy / rename it.

Similarly the asserts inside MigrateToSQLite could return false instead, though in that case moving the backup file back might fail.

MigrateToSQLite could take a second argument bool& deleted so we know whether or not the backup file needs to be moved.

Or you could create a file with a different name, defer deleting all the way to the end, and then rename the new file to match the original name. But then the m_database hot swap stuff won't work.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since these are filesystem errors, I don't think restoring the backup would actually work. Furthermore, because m_database has been swapped out, and potentially doesn't have a backing database, I don't think it's safe to continue execution in that state as the CWallet would still exist, but nowhere to write data to.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a6c0e2f844f589df4f2d19e38c2e350952e1b4bf: maybe move the file creation to a common method, so that if we ever decide to use some new opts field, we don't forget to apply it here.

Copy link
Member Author

@achow101 achow101 Aug 26, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps for a followup to do this everywhere we are making wallets.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a6c0e2f844f589df4f2d19e38c2e350952e1b4bf: I don't understand this comment.

Also, should we check db_status?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No. it just contains more granular errors. There's no "success by with caveats" that would be relevant here.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a6c0e2f844f589df4f2d19e38c2e350952e1b4bf: maybe just explicitly check for a lock before calling MigrateToDescriptor, so we can allow that function to fail and return nothing in other situations.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand what you're talking about.

Copy link
Member

@Sjors Sjors Aug 29, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're assuming here that the reason std::nullopt is returned is that the wallet is locked, rather than some other failure.

Copy link
Member

@furszy furszy Aug 29, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're assuming here that the reason std::nullopt is returned is that the wallet is locked, rather than some other failure.

The RPC handler is ensuring that the wallet is unlocked (third line there). This check is redundant for now, and might be useful when a migration button is implemented on the GUI.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think the message implies that the only reason it would return nullopt is because it is locked. However, it is likely that a failure would be due to being locked, hence the message. We do this in a couple of other places too where failure is often being locked, but could be some weird db condition that actually happened.

achow101 and others added 2 commits August 26, 2022 13:14
Refactors SetupDescSPKMs so that the DescSPKM loops are in their own
function. This allows us to call it later during migration with a key
that was already generated.
Copy link
Contributor

@w0xlt w0xlt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ACK 3299574f5e27bdb6c12fffd541e7cbfd148ab883

@Sjors
Copy link
Member

Sjors commented Aug 29, 2022

I started the GUI, made some transactions and then called migratewallet from the command line. Between adding the watch-only wallet and setting the new spkMans there was a 9 minute gap in which nothing seemed to happen. The log then showed "Wallet migration complete.", but the RPC call still didn't return. The GUI was unresponive. Eventually it hit the RPC client timeout, but GUI remained unresponsive.

I was unable to do a clean shutdown with ctrl+c from the terminal (where I started QT from). A gentle kill didn't work either (kill -9 did). I guess there's a lock order issue somewhere.

I repeated the experiment calling from the GUI console. There was again a 9 minute gap after watch-only was created, and the console got stuck in Executing, except the GUI never times out.


After migration the watch-only labels were missing. This also happened when I repeated the migration using the GUI console. I'll send you the wallet (maybe it's related to the stuff I added below).


Multisigs are special because we have addmultisigaddress which add the multisig but does not watch them. All of the import rpcs will watch.

Also, IIUC along with wrapped SegWit, multisig is the only p2sh redeemscript our wallet knows how to spend.

I played around a bit with importmulti on the legacy wallet, creating a p2sh address for a simple relative timelock. I used a key that's in the wallet, but because we don't recognize that script type, it's considered not solvable and watch-only. This causes it to be migrated to the watch-only wallet, away from its corresponding private key. We should probably warn about it in the documentation.

Interestingly, once we have Miniscript support, there could be cases where a (P2W)SH script goes from watch-only and non-solvable to spendable, assuming the migration script picks it up.

Copy link
Member

@maflcko maflcko left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is expected that you run into deadlocks, given that the thread sanitizer doesn't pass CI.

Also, the Windows compilation fails, see u8string suggestion.

Copy link
Member

@Sjors Sjors left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As you said:

Multisigs are special because we have addmultisigaddress which add the multisig but does not watch them.

The test could use some comments around that behavior. It correctly demonstrates it though.

Copy link
Member

@Sjors Sjors Aug 29, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fb5e0957e066bd0825947a399242242f1f1a2ae6: why do you need to call importaddress after addmultisigaddress? Is that makes it marked as watch-only (as oppose to the second address)? Why?

(update: see above, but I still don't understand why)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So that this particular address is being watched and will end up in the _watchonly wallet.

@Sjors
Copy link
Member

Sjors commented Aug 29, 2022

I was confused about the difference between solvable and watch-only.

  • solvable: anything we have any (public) keys for and we know who to spend (e.g. a multisig for which we have one public key, and we know the other two because we have the full redeemscript)
  • watch-only: may or may not be solvable, e.g. an unsolvable multisig is one for which we only know the address (scriptPubKey), not the full script, i.e. we don't know which keys it needs

A solvable address is not automatically watch-only, but all import methods except importmulti ensure it is.

importmulti adds a redeemscript so it's solvable, but it's not marked watch-only. The net-effect of that is that we see the full script with getaddressinfo, the wallet ignores transactions to it, but we can sign them. This may be useful if you're a co-signer and you don't want those transactions to show up.

@Sjors
Copy link
Member

Sjors commented Aug 29, 2022

I also noticed the label given by addmultisigaddress is not migrated to the solvables wallet. In fact it stays behind, as seen by getaddressinfo. The other info does move.

We should consider making the solvables wallet legacy for now. Because right now it's just another watch-only wallet, except without transaction history. But future transaction will show up and rescan will reveal the old ones. This particular use case of having scripts but not seeing their transactions, seems a legacy feature we can't migrate yet. And in that case, we might as well move or copy corresponding private keys over too. Unless I totally misunderstand this multisig use case, you need to keep those around.

@achow101
Copy link
Member Author

The issues with locks and hanging should be resolved now. I've changed it to just unload a wallet before migrating, then loading it but without connecting all of the signals, etc. for the migration itself. This should fix the issues with locks and hanging (which was caused by waiting for the shared_ptr to be released by the GUI).

We should consider making the solvables wallet legacy for now.

I don't think we should. The point is to make it so that migrated wallets do not have any dependency on legacy stuff at all so that we can remove the legacy wallet. I agree that it can be confusing, but I don't think it is helpful or less confusing to make a new legacy wallet when the user is expecting everything to go to descriptors.

@Sjors
Copy link
Member

Sjors commented Aug 29, 2022

The problem is that the solvables wallet is completely useless. It can't sign things the legacy wallet could sign. You might as well put these things in the watch-only wallet, unless I'm missing something.

Another approach could be to handle the special (and only?) case of a multisig wallet more gracefully. The best substitute we can make for how that worked before, is to create a blank descriptor wallet and then import the (inferred) descriptor with the private keys that we have. They'll lose the "feature" of not seeing transactions, but they'll retain the ability to sign, which is more important.


The locking issue seems resolved. It's also no longer freezing the UI while the migration is in progress. Labels

It's quite a slow call (several minutes), so a nice followup improvement would be a way to abort it and maybe a progress bar. It's a one-off task, but probably one that makes users very nervous.

Notice one glitch: if you call this from GUI console, since it closes the wallet, you'll suddenly see a different wallet selected in the console, even though the command is still in progress. That's confusing, though maybe it's enough for now to just to warn the user to ignore that. It also crashes if you touch the dropdown though.

@achow101
Copy link
Member Author

The problem is that the solvables wallet is completely useless. It can't sign things the legacy wallet could sign. You might as well put these things in the watch-only wallet, unless I'm missing something.

The solvables wallet can update a PSBT with the UTXO, scripts, and pubkeys, for inputs that spend the any of the scripts it contains. Because the user had not watched those scripts when the wallet is a legacy wallet, it does not make sense to have them be in the watchonly wallet.

@Sjors
Copy link
Member

Sjors commented Aug 29, 2022

The slowness seems to be macOS thing, or even particular to one of my machines. The exact same wallet takes ~9 minutes to upgrade on one on my macOS machines, and less than a minute on the other and on Ubuntu. When the upgrade is fast, the labels are preserved fine too.


It would be useful to know how people use(d) legacy multisig wallets, especially before PSBT. It's not clear to me with which wallet they would craft the transaction.

A solveable-only legacy wallet can sign them and add more metadata, but because it lacks transaction history, they can't craft them. Presumably the user would have different wallet for that purpose, which they may or may not be upgrading separately. As long as that other wallet can produce a PSBT, then our upgraded main wallet can sign it just fine. The _solvable wallet's only purpose then is to backup the redeemscript. They should never need it.

If their other wallet can't produce a psbt, then the upgrade process breaks their ability to sign, since the private keys are now no longer in the same wallet as the redeemscript. So we should warn about that. And we could a utility that takes a raw unsigned transaction and transform it into a PSBT to address that potential problem.

Knowing these use cases would also help us write upgrade documentation for them, to explain what new workflows they should consider.

Copy link
Member

@furszy furszy Aug 29, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're assuming here that the reason std::nullopt is returned is that the wallet is locked, rather than some other failure.

The RPC handler is ensuring that the wallet is unlocked (third line there). This check is redundant for now, and might be useful when a migration button is implemented on the GUI.

@Sjors
Copy link
Member

Sjors commented Aug 31, 2022

tACK 53e7ed0

I think this is good enough for an experimental RPC. Hopefully we can figure out why some macOS machines have difficulty with the RPC, but if not, we can just mention that in the release notes. Similarly we can revisit the way solvable multisig is handled; anyone using that feature will know how to restore the backup.

Copy link
Contributor

@w0xlt w0xlt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reACK 53e7ed0

for (const auto& addr_pair : m_address_book) {
// Labels applied to receiving addresses should go based on IsMine
if (addr_pair.second.purpose == "receive") {
if (!IsMine(addr_pair.first)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In commit "Implement MigrateLegacyToDescriptor" (0bf7b38)

It seems like this should say IsMine not !IsMine. Also I'm not sure why addr_pair.second.purpose == "receive" check is necessary. It seems like just checking IsMine should be enough, and it would be less robust to rely on purpose field being present.

Copy link
Member

@furszy furszy Mar 13, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's because the migration process divides data between different wallets.
It first deletes and unloads the legacy spkm from the main wallet (check the beginning of the function), and setup new descriptors. Then moves the data to two possible wallets; a solvable and a watch-only wallet.

So the !IsMine means that the record in the addressbook is from the legacy spkm (not loaded anymore), so it needs migration to the new wallets.

It also took me a while to get it while was rewriting it for #26836.
(sorry for the double PR mention, saw late your response in the other PR)

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.