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.