wallet: Migration fails for legacy wallet with no keys watching an address #36483

issue davidgumberg opened this issue on October 9, 2026
  1. davidgumberg commented at 3:05 AM on October 9, 2026: contributor

    This issue was found while using Qwen3.8-Flash-Next to review #36333, and the functional test demonstrating it was generated by the LLM and cleaned up by me.

    Consider the following functional test:

    diff --git a/test/functional/wallet_migration.py b/test/functional/wallet_migration.py
    index 6e032ae20d..461cb75a2c 100755
    --- a/test/functional/wallet_migration.py
    +++ b/test/functional/wallet_migration.py
    @@ -1178,6 +1178,26 @@ class WalletMigrationTest(BitcoinTestFramework):
     
             wallet.unloadwallet()
     
    +    def test_watchonly_txs_no_privkeys(self):
    +        self.log.info("Test migrating a wallet with only watch-only addresses imported and no spendable material with transactions.")
    +        default = self.master_node.get_wallet_rpc(self.default_wallet_name)
    +
    +        wallet = self.create_legacy_wallet("watchonly_no_privkeys", blank=True)
    +        key = get_generate_key()
    +        addr = key.p2pkh_addr
    +        wallet.importaddress(addr)
    +
    +        # Create a transaction that spends to the address
    +        received_txid = default.sendtoaddress(addr, 10)
    +        self.generate(self.master_node, 1)
    +
    +        wallet.gettransaction(received_txid)
    +
    +        res, wallet = self.migrate_and_get_rpc("watchonly_no_privkeys")
    +
    +        wallet.unloadwallet()
    +
    +
         def test_migrate_simple_watch_only(self):
             self.log.info("Test migrating a watch-only p2pk script")
             wallet = self.create_legacy_wallet("bare_p2pk", blank=True)
    @@ -1788,6 +1808,7 @@ class WalletMigrationTest(BitcoinTestFramework):
             self.test_avoidreuse()
             self.test_preserve_tx_extra_info()
             self.test_blank()
    +        self.test_watchonly_txs_no_privkeys()
             self.test_migrate_simple_watch_only()
             self.test_manual_keys_import()
             self.test_p2wsh()
    

    Fails as follows:

     node0 2026-10-09T02:31:21.207392Z (mocktime: 2026-10-09T02:31:21Z) [http.00] [../../../src/wallet/sqlite.cpp:55] [TraceSqlCallback] [walletdb:trace] [/tmp/bitcoin_func_test_mq7t3n66/node0/regtest/wallets/watchonly_no_privkeys/wallet.dat] SQLite Statement: DELETE FROM main WHERE key = ?
     node0 2026-10-09T02:31:21.207398Z (mocktime: 2026-10-09T02:31:21Z) [http.00] [../../../src/wallet/walletdb.cpp:1288] [RunWithinTxn] [walletdb] Error: apply migration process failed
     node0 2026-10-09T02:31:21.207400Z (mocktime: 2026-10-09T02:31:21Z) [http.00] [../../../src/wallet/sqlite.cpp:55] [TraceSqlCallback] [walletdb:trace] [/tmp/bitcoin_func_test_mq7t3n66/node0/regtest/wallets/watchonly_no_privkeys/wallet.dat] SQLite Statement: ROLLBACK TRANSACTION
     node0 2026-10-09T02:31:21.207441Z (mocktime: 2026-10-09T02:31:21Z) [http.00] [../../../src/wallet/wallet.h:928] [WalletLogPrintf] [watchonly_no_privkeys_watchonly] Releasing wallet watchonly_no_privkeys_watchonly..
     node0 2026-10-09T02:31:21.207465Z (mocktime: 2026-10-09T02:31:21Z) [http.00] [../../../src/wallet/wallet.h:928] [WalletLogPrintf] [watchonly_no_privkeys] Releasing wallet watchonly_no_privkeys..
     node0 2026-10-09T02:31:21.207491Z (mocktime: 2026-10-09T02:31:21Z) [http.00] [../../src/httpserver.cpp:643] [Send] [http] HTTPResponse (status code: 200 size: 186) added to send buffer for client 127.0.0.1:35826 (id=0)
     node0 2026-10-09T02:31:21.207506Z (mocktime: 2026-10-09T02:31:21Z) [http.00] [../../src/httpserver.cpp:1323] [MaybeSendBytesFromBuffer] [http] Sent 186 bytes to client 127.0.0.1:35826 (id=0)
     test  2026-10-09T02:31:21.207576Z TestFramework (ERROR): Unexpected exception:
                                       Traceback (most recent call last):
                                         File "/bitcoin/test/functional/test_framework/test_framework.py", line 146, in main
                                           self.run_test()
                                           ~~~~~~~~~~~~~^^
                                         File "/bitcoin/./build/test/functional/wallet_migration.py", line 1816, in run_test
                                           self.test_watchonly_txs_no_privkeys()
                                           ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^
                                         File "/bitcoin/./build/test/functional/wallet_migration.py", line 1196, in test_watchonly_txs_no_privkeys
                                           res, wallet = self.migrate_and_get_rpc("watchonly_no_privkeys")
                                                         ~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^
                                         File "/bitcoin/./build/test/functional/wallet_migration.py", line 144, in migrate_and_get_rpc
                                           migrate_info = self.master_node.migratewallet(wallet_name=wallet_name, **kwargs)
                                         File "/bitcoin/test/functional/test_framework/coverage.py", line 50, in __call__
                                           return_val = self.auth_service_proxy_instance.__call__(*args, **kwargs)
                                         File "/bitcoin/test/functional/test_framework/authproxy.py", line 147, in __call__
                                           raise JSONRPCException(response['error'], status)
                                       test_framework.util.JSONRPCException: bad_function_call (-1)  [http_status=200]
     test  2026-10-09T02:31:21.208450Z TestFramework (DEBUG): Closing down network thread
    

    I have not reviewed or confirmed this, but in case it's helpful, I've pasted an LLM-generated summary of the issue(s):

    <details> <summary>Qwen3.8-Next-Flash</summary>

    Bug A — stale mapWallet check (CWallet::ApplyMigrationData, src/wallet/wallet.cpp:4066)

    • For wallets with no spendable material, migration verifies success by asserting the main wallet's mapWallet is empty after moving txs to the _watchonly wallet.
    • But RemoveTxs only deletes rows from disk immediately — the in-memory mapWallet.erase() is deferred to the txn's on_commit listener, which hasn't run yet.
    • Result: the check sees txs that are already gone from disk and falsely returns "Error: Not all transaction records were migrated".

    Bug B — empty on_abort callback (CWallet::RemoveTxs + WalletBatch::TxnAbort, wallet.cpp:2295 /walletdb.cpp:1381)

    • RemoveTxs registers a txn listener with .on_abort={}, i.e. an empty std::function, while TxnAbort() unconditionally calls listener.on_abort() on every listener.
    • Any abort of a txn in which RemoveTxs ran therefore throws std::bad_function_call — an exception that escapes the migration entirely.
    • Effect: it masks the real (already wrong) Bug A error with the useless RPC message bad_function_call (-1), and skips migration's cleanup path.
  2. fanquake added the label Wallet on Oct 10, 2026

github-metadata-mirror

This is a metadata mirror of the GitHub repository bitcoin/bitcoin. This site is not affiliated with GitHub. Content is generated from a GitHub metadata backup.
generated: 2026-10-11 17:51 UTC

This site is hosted by @0xB10C
More mirrored repositories can be found on mirror.b10c.me