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.
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.
Copy every CWalletTx so balances are immediately correct.
Backup the new wallet to the requested destination and then delete the temp directory.
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.
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
Review approach Did you test, concept ACK, approach ACK, or NACK the PR? What aspects did you focus on during review?
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.
Cache copyingExportWatchOnlyWallet 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?
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?
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?
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?
Functional test In wallet_exported_watchonly.py, why does the test call wallet.keypoolrefill(100) before checking spendability across the online/offline pair?
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.
<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
<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.
<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.
<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.
<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.
<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
<monlovesmango> I did have a code question, where is CanSelfExpand defined for OriginPubkeyProvider? since it is just returning m_provider->CanSelfExpand()?
<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
<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?
<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?
<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
<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?
<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.
<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
<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?
<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.
<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.
<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?
<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.
<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
<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?
<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.
<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
<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?
<ryanofsky> 7. In wallet_exported_watchonly.py, why does the test call wallet.keypoolrefill(100) before checking spendability across the online/offline pair?
<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.
<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
<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