wallet: Add exportwatchonlywallet RPC (wallet, rpc)

https://github.com/bitcoin/bitcoin/pull/32489

Host: ryanofsky  -  PR author: achow101

The PR branch HEAD was e6ac2015a852 at the time of this review club meeting.

Notes

Motivation & user story

  • Air-gapped (“signing”) machines hold the private keys; an online (“watch-only”) node tracks balances, generates receive addresses, and prepares PSBTs. Today users must manually recreate that watch-only wallet by typing or scripting importdescriptors, copying address labels, etc.
  • exportwatchonlywallet provides a single-step RPC that produces a wallet file containing:
    • All public descriptors (& caches when needed)
    • Address book entries, receive-request metadata, and “avoid-reuse” markers
    • All wallet TXs (so rescans are unnecessary)
    • Persisted locked-coin state and wallet flags
    • No private keys (wallet is created with WALLET_FLAG_DISABLE_PRIVATE_KEYS)

The result can be moved to any node and loaded with the existing restorewallet RPC.


New building blocks introduced in this PR

Area New API / behaviour Why it is needed
Descriptors Descriptor::CanSelfExpand() plus plumbing in every DescriptorImpl and PubkeyProvider Lets the wallet know whether a descriptor can generate new scriptPubKeys without a cache and without private keys. Hardened paths (e.g. /0h/*) return false.
Wallet internals CWallet::ExportDescriptors(bool export_private) Moves descriptor enumeration logic out of listdescriptors RPC so it can be reused by the exporter.
  CWallet::ExportWatchOnlyWallet(const fs::path& dst, WalletContext&) Creates a temporary watch-only wallet DB, populates it, backs it up to dst, then deletes the temp directory. This keeps the exported file self-contained and avoids leaving stray wallet dirs around.
RPC exportwatchonlywallet <destination> Thin wrapper around the method above.
Tests wallet_exported_watchonly.py functional test Exercises basic export, address-book copy, tx / lock copy, imported descriptors, avoid-reuse flag, encrypted source wallets.

Walk-through of the export algorithm (very high level)

  1. Pre-flight checks – ensure destination path does not exist; grab a canonical path; hold cs_wallet.
  2. Gather descriptor data using ExportDescriptors(false).
  3. Instantiate a new wallet DB with the same flags ∪ WALLET_FLAG_DISABLE_PRIVATE_KEYS.
  4. Populate the new wallet inside a single batch TX:
    • Add each descriptor as a DescriptorScriptPubKeyMan.
      • If !desc->CanSelfExpand(), copy the source descriptor cache first, so address generation works.
    • Copy active SPKMs / internal flags so it knows which pools to use.
    • Copy persistent locked coins, address-book rows, receive-requests, “previously-spent” markers, nOrderPosNext, and best-block locator (to avoid rescan).
    • Copy every CWalletTx so balances are immediately correct.
  5. Backup the new wallet to the requested destination and then delete the temp directory.
  6. Return the fully-qualified path in the RPC result.

Behavioural quirks & edge-cases worth reviewing

  • Hardened xpub paths – these cannot self-expand; exporter copies the cache so watch-only wallet can derive addresses up to the last used index but cannot extend indefinitely.
  • Encrypted source wallets – exporter runs while the wallet is locked because it never reads private keys.
  • Avoid-reuse & address-book flags – exporter must preserve both the wallet flag and the special “previously spent” metadata to keep behaviour identical.
  • Tx ordering & nOrderPosNext – ensures recreated wallet doesn’t reshuffle history.
  • Concurrency – every path that reads mutable wallet state is executed under cs_wallet; temp wallet is manipulated under its own lock and inside a batch DB TX.

Questions

  1. Review approach Did you test, concept ACK, approach ACK, or NACK the PR? What aspects did you focus on during review?
  2. Descriptor API Why can’t the existing IsRange()/IsSingleType() information tell us whether a descriptor can be expanded on the watch-only side? Explain the logic behind CanSelfExpand() for a) a hardened wpkh(xpub/0h/*) path and b) a pkh(pubkey) descriptor.
  3. Cache copying ExportWatchOnlyWallet only copies the descriptor cache if !desc->CanSelfExpand(). What exactly is stored in that cache? How could an incomplete cache affect address derivation on the watch-only wallet?
  4. Wallet flags The exporter sets create_flags = GetWalletFlags() | WALLET_FLAG_DISABLE_PRIVATE_KEYS. Why is it important to preserve the original flags (e.g. AVOID_REUSE) instead of clearing everything and starting fresh?
  5. Best-block locator Why does the exporter read the locator from the source wallet and write it verbatim into the new wallet instead of letting the new wallet start from block 0?
  6. Security / privacy Consider a multisig descriptor wsh(multi(2,xpub1,xpub2)). If one cosigner exports a watch-only wallet and shares it with a third party, what new information does that third party learn compared to just giving them the descriptor strings?
  7. Functional test In wallet_exported_watchonly.py, why does the test call wallet.keypoolrefill(100) before checking spendability across the online/offline pair?
  8. Alternatives Prior to this PR, users could achieve a watch-only setup via dumpwallet → importwallet or importdescriptors. Compare those approaches with exportwatchonlywallet in terms of UX, completeness, and long-term maintainability.

Meeting Log

  117:00 <ryanofsky> #startmeeting
  217:00 <corebot> ryanofsky: Meeting started at 2025-08-06T17:00+0000
  317:00 <corebot> ryanofsky: Current chairs: ryanofsky
  417:00 <corebot> ryanofsky: Useful commands: #action #info #idea #link #topic #motion #vote #close #endmeeting
  517:00 <corebot> ryanofsky: See also: https://hcoop-meetbot.readthedocs.io/en/stable/
  617:00 <corebot> ryanofsky: Participants should now identify themselves with '#here' or with an alias like '#here FirstLast'
  717:00 <ryanofsky> hi
  817:00 <ryanofsky> Welcome to the bitcoin review club! Today we'll discuss https://bitcoincore.reviews/32489 #32489
  917:00 <corebot> https://github.com/bitcoin/bitcoin/issues/32489 | wallet: Add `exportwatchonlywallet` RPC to export a watchonly version of a wallet by achow101 · Pull Request #32489 · bitcoin/bitcoin · GitHub
 1017:00 <glozow> hi
 1117:00 <stringintech> Hi
 1217:00 <stickies-v> hi
 1317:01 <pablomartin4btc> hello
 1417:01 <colinc> hi
 1517:01 <kevkevin> hi
 1617:01 <ryanofsky> First question to get started 1. Did you test, concept ACK, approach ACK, or NACK the PR? What aspects did you focus on during review?
 1717:01 <emzy> hi
 1817:02 <monlovesmango> heyy
 1917:02 <stringintech> I was not familiar with this part of the codebase so mainly spent time on getting a basic understanding of the components touched by this PR and then did a light review on the changes. Concept ACK.
 2017:02 <monlovesmango> concept ACK but mostly here just to lurk and learn
 2117:02 <pablomartin4btc> light code review ACK
 2217:03 <effexzi> Hi every1
 2317:03 <ryanofsky> Yeah I feel like the harder part of this PR if you aren't familiar with wallet is probably the concepts
 2417:03 <monlovesmango> yes definitely haha
 2517:04 <ryanofsky> The code is more straightfoward than other prs, because it's new and not interacting with legacy stuff like a lot of wallet prs
 2617:04 <ryanofsky> Feel free to ask if a particular concept is unclear
 2717:05 <ryanofsky> Next up is 2. Why can’t the existing IsRange()/IsSingleType() information tell us whether a descriptor can be expanded on the watch-only side? Explain the logic behind CanSelfExpand() for a) a hardened wpkh(xpub/0h/*) path and b) a pkh(pubkey) descriptor.
 2817:06 <monlovesmango> is it possible that a range can be hardened but also could not be hardened?
 2917:06 <stringintech> About the first part of the question, CanSelfExpand() tells whether we need private keys or cache to get new addresses for a descriptor. A single type range descriptor may contain hardened paths or may not. The latter can self expand but not the former. So only those two methods are not enough.
 3017:07 <stringintech> About the second part
 3117:07 <stickies-v> I think range descriptors with hardened paths can still self expand, but only if they contain the xpriv?
 3217:08 <stringintech> pkh(pubkey) cannot expand because it is not ranged but the other one (which shows the limitation of IsRange()/IsSingleType() checks) cannot expand because it contains hardened paths.
 3317:08 <ryanofsky> stringintech, yeah it's just in the exported wallet there are no private keys, so anything hardened needs to be exported in the cache
 3417:09 <ryanofsky> oops that was in reply to stickies-v
 3517:09 <stringintech> :D
 3617:09 <ryanofsky> stringintech, your answer sounds right
 3717:10 <stickies-v> oh yeah, watchonly exports wouldn't contain any private keys, i was making a more general comment
 3817:11 <ryanofsky> Basically in order to do the export the wallet needs to know if the descriptor uses any hardened paths, so CanSelfExport searched the descriptor recursively for anything hardened and returns true if it finds that
 3917:11 <ryanofsky> If there are any hardened paths caches need to be exported
 4017:12 <monlovesmango> I did have a code question, where is CanSelfExpand defined for OriginPubkeyProvider? since it is just returning m_provider->CanSelfExpand()?
 4117:13 <ryanofsky> I belive OriginPubkeyProvider just wraps another pubkey provider and adds origin information
 4217:13 <stickies-v> I don't really understand what the relevant of the `IsSingleType()` part to the question is, when is that relevant?
 4317:14 <monlovesmango> wait doesn't it return false if it finds hardened paths?
 4417:14 <ryanofsky> So since it doesn't need to derive anything it can call the wrapped pubkey provider
 4517:15 <ryanofsky> IsSingleType() is really not relevant. The question was just asking why existing methods like IsSingleType were not sufficient and a new CanSelfExpand method needed to be added
 4617:15 <stickies-v> okay, thx
 4717:15 <monlovesmango> so does pkh(pubkey) not use ConstPubkeyProvider? bc I had assumed it did but stringintech said pkh(pubkey) would not be able to expand
 4817:16 <ryanofsky> pkh(pubkey) should self-expand, right?
 4917:17 <ryanofsky> Anything that doesn't include a hardened path should self-expand
 5017:17 <monlovesmango> ryanofsky: that is what I thought
 5117:18 <ryanofsky> Well next question is about application of CanSelfExpand, so related
 5217:18 <ryanofsky> 3. ExportWatchOnlyWallet only copies the descriptor cache if !desc->CanSelfExpand(). What exactly is stored in that cache? How could an incomplete cache affect address derivation on the watch-only wallet?
 5317:19 <monlovesmango> ryanofsky: about OriginPubkeyProvider question, m_provider is cast as std::unique_ptr<PubkeyProvider> which only defines a virtual CanSelfExpand function. how can I see that it is wrapped?
 5417:20 <ryanofsky> Oh I might be using the word "wrapped" in a different way maybe?
 5517:20 <stringintech> monlovesmongo you are right i confused CanSelfExpand() with CanGetAddresses() earlier
 5617:20 <monlovesmango> I might also be asking the question in an unintuitive way haha
 5717:21 <ryanofsky> I just meant to say that OriginPubkeyProvider contains another PubkeyProvider. And since can just call CanSelfExpand on that provider since origin information in a descriptor doesn't affect what the descriptor expands to
 5817:22 <ryanofsky> Origin information is just additional metadata, doesn't affect how addresses are generated or what scriptPubKeys are matched
 5917:22 <monlovesmango> ok that makes sense! thank you!
 6017:23 <stickies-v> The cache stores all the parent xpubs (aren't those in the descriptor already?), a well a a number of derived xpubs (`m_derived_xpubs`), I think according to the size of the keypool?
 6117:23 <ryanofsky> Ok, I'm not 100% sure of this stuff myself so don't take me as authority, but that's my understanding
 6217:23 <stringintech> On question 3: This case we need to copy the cache because it contains the pregenerated addresses from a hardened path where copying them allows us to use those addresses while not having private keys to resolve the hardened paths.
 6317:23 <stickies-v> so an incomplete cache would be similar to someone sending a transaction to a derived address that exceeds the gap limit, I think?
 6417:24 <ryanofsky> +1 stickies-v and stringintech yes that all sounds right
 6517:24 <pablomartin4btc> stringintech +1 (derivation info like last index used, etc)
 6617:24 <ryanofsky> The cache stores pre-derived pubkeys for hardened paths. If it isn’t copied, the watch-only wallet will not be able to see transactions sent to resulting addresses
 6717:25 <ryanofsky> Feel free to ask more about that if not clear, next question is on another topic
 6817:25 <ryanofsky> 4. The exporter sets create_flags = GetWalletFlags() | WALLET_FLAG_DISABLE_PRIVATE_KEYS. Why is it important to preserve the original flags (e.g. AVOID_REUSE) instead of clearing everything and starting fresh?
 6917:26 <stringintech> How can the number of pre-derived pubkeys be configured? (before exporting the watchonly wallet) Is it configurable? Cause eventually we will run out of them I guess.
 7017:27 <monlovesmango> stringintech: great question!
 7117:27 <pablomartin4btc> 4. i think keeping user intent and expected behavior across exported/restored environments
 7217:27 <ryanofsky> yes good question i wonder if the keypool size options affect it
 7317:28 <stickies-v> continuing on the cache: does that mean user should consider calling `keypoolrefill` rpc before exporting the watchonly wallet?
 7417:28 <ryanofsky> or maybe you just topup the keypool before exporting the wallet
 7517:28 <stickies-v> oh we were all thinking the same hah
 7617:29 <ryanofsky> Yes could look into it, good question
 7717:29 <stringintech> Cool :)))
 7817:29 <ryanofsky> Yes for question 4 I had: I think AVOID_REUSE flag is actually not relevant. IIUC point of that flag is to avoid spending from coins previously spent from to improve privacy, and since watch-only wallets can't spend it's not relevant. But not sure about this.
 7917:29 <ryanofsky> In general seems good to preserve flags though
 8017:30 <monlovesmango> yes, any you never know if future flags might have differences in behavior
 8117:30 <ryanofsky> Yep
 8217:30 <glozow> I think AvailableCoins's result depends on AVOID_REUSE, so there is a difference?
 8317:30 <glozow> even though you can't spend those coins
 8417:31 <monlovesmango> true
 8517:31 <ryanofsky> Oh so balance would show up differently, that makes sense if I'm understanding right
 8617:31 <glozow> the user would be confused why the balance shown isn't the same
 8717:32 <glozow> yeah I think so
 8817:32 <monlovesmango> like if you just need to provide an address AVOID_REUSE would affect which address could be returned?
 8917:32 <monlovesmango> oh ok
 9017:32 <monlovesmango> wait AVOID_REUSE would make the wallet not identify balances just bc address is reused?
 9117:33 <glozow> https://github.com/bitcoin/bitcoin/blob/cf15d45192e03ff2b0267842353f5f89541cb3f1/src/wallet/spend.cpp#L420
 9217:33 <ryanofsky> monlovesmango, I'm guessing it would still be in overall balance but not available balance
 9317:34 <ryanofsky> 5. Why does the exporter read the locator from the source wallet and write it verbatim into the new wallet instead of letting the new wallet start from block 0?
 9417:34 <monlovesmango> glozow: thanks :) ryanofsky: gotcha
 9517:36 <monlovesmango> I assume to not have to rescan the wallet?
 9617:36 <pablomartin4btc> monlovesmango: check avoid_reuse description in getbalance RPC
 9717:36 <stringintech> I think it is because we want to know where to resume scanning the blocks for txs we are interested in (and since we have already copied the old txs data, the wallet doesn't need to rescan from genesis). Though I am not confident yet on how locators work in general.
 9817:37 <monlovesmango> pablomartin4btc: will do!
 9917:37 <ryanofsky> feel free to ask about locators! But yeah that all sounds right, it's just to avoid rescanning. It would almost defeat purpose of the RPC if needed to rescan after exporting
10017:38 <ryanofsky> 6. Consider a multisig descriptor wsh(multi(2,xpub1,xpub2)). If one cosigner exports a watch-only wallet and shares it with a third party, what new information does that third party learn compared to just giving them the descriptor strings?
10117:38 <pablomartin4btc> monlovesmango +1 plus avoid missing transactions that happened after wallet creation but before export
10217:39 <stringintech> About locators, what other parts of the codebase use them other than wallet?
10317:40 <monlovesmango> pablomartin4btc: watch only wallet wouldn't find these? interesting
10417:41 <ryanofsky> I think just the wallets and indexes use locators
10517:41 <monlovesmango> is it all scenarios or are there only certain scenarios where watch only wallet wouldn't find these?
10617:41 <stringintech> ryanofsky Thanks!
10717:43 <glozow> are these the same locators we use for syncing headers with our peers?
10817:45 <ryanofsky> Could be? Maybe they are. I'm very ignorant of p2p
10917:45 <glozow> Seems they could be! `CBlockLocator`
11017:45 <furszy> yes, they are.
11117:45 <stringintech> Nice!
11217:46 <ryanofsky> For answer 6 I had: If the third-party was given the watch-only wallet they would transaction history and wallet metadata along with the descriptor. If they only had the descriptor they might not be able to get full transaction history if uses hardened paths.
11317:47 <stringintech> And also the addresses copied through the cache I guess (related to hardened paths)?
11417:48 <monlovesmango> ryanofsky: oh that makes sense
11517:48 <ryanofsky> stringintech, yes I think that's right
11617:50 <ryanofsky> IIUC DescriptorCache just contains CExtPubKey objects so not addresses directly, but the thing you would use to derive them and also to match transactions
11717:50 <monlovesmango> so just to test my understanding, would watch only descriptor of hardened path kinda be useless? or what information could you discern from the descriptor alone?
11817:51 <stringintech> Oh right, thanks ryanofsky
11917:52 <ryanofsky> monlovesmango, yes I can't think of any uses if the wallet only had a hardened descriptor and not private key and no cache
12017:53 <monlovesmango> ryanofsky: cool thanks
12117:54 <ryanofsky> Since not much time left will just post the last two questions, feel free to respond to either
12217:54 <ryanofsky> 7. In wallet_exported_watchonly.py, why does the test call wallet.keypoolrefill(100) before checking spendability across the online/offline pair?
12317:54 <ryanofsky> 8. Prior to this PR, users could achieve a watch-only setup via dumpwallet → importwallet or importdescriptors. Compare those approaches with exportwatchonlywallet in terms of UX, completeness, and long-term maintainability.
12417:55 <stringintech> On the last one, dumpwallet and importwallet currently do not exist in the codebase, right?
12517:55 <ryanofsky> Ha yeah, that's a pretty good reason
12617:55 <monlovesmango> hahaah
12717:56 <stringintech> :D :D :D
12817:56 <monlovesmango> for the last one, there is no way to avoid rescan right?
12917:57 <ryanofsky> I wrote: dumpwallet saves private keys which we don't want to export. importdescriptors would drop metadata, require rescanning, not work with hardened descriptors
13017:57 <pablomartin4btc> stringintech yeah so at the moment you cant achieve the watch-only setup... unles you run it from the latest release
13117:57 <pablomartin4btc> but you can do the dump from the wallettool/ bitcoin-wallet bin
13217:57 <monlovesmango> and if descriptor of hardened path without priv key isn't useful importdescriptors wouldn't really do much?
13317:58 <ryanofsky> pablomartin4btc, I think the PR doesn't provide a way to do this from wallettool, but it could in the future
13417:58 <stringintech> ryanofsky: Is a manual copy of the wallet database file be equivalent of dumpwallet?
13517:58 <stringintech> pablomartin4btc: ah thanks!!
13617:58 <pablomartin4btc> ryanofsky, to run the dump, not the export
13717:58 <monlovesmango> ryanofsky: ok you beat me haa
13817:59 <stringintech> On question 7
13918:00 <stringintech> In that test the watch-only wallet generates a new address and prepares an incomplete tx and for the offline wallet to be able to sign that tx it has to call keypoolrefill so it can pre-compute addresses which includes that specific address which allows the offline wallet to sign the transaction (since the offline wallet has the corresponding
14018:00 <stringintech> private key)
14118:00 <ryanofsky> i think a manual copy of the file had some more information, dumpwallet was like a text representation, but similar
14218:00 <stringintech> ryanofsky: great thank you
14318:01 <ryanofsky> stringintech, I think that is correct for question 7
14418:01 <ryanofsky> Thanks everybody for reviewing and participating!
14518:01 <stringintech> Thank you and thanks everyone.
14618:01 <glozow> thanks ryanofsky!
14718:01 <ryanofsky> Will end the meeting in a minute if no remaining comments
14818:02 <pablomartin4btc> thank you ryanofsky! thanks for the useful notes!
14918:02 <monlovesmango> thanks for hosting ryanofsky !! went fast and learned a lot
15018:02 <ryanofsky> I learned a lot too, this was fun!
15118:02 <ryanofsky> #endmeeting