From cc3bc5aaf518e6344a4f99b9bed4a31b9fb1333f Mon Sep 17 00:00:00 2001
From: johnny9 <985648+johnny9@users.noreply.github.com>
Date: Sat, 14 Mar 2026 07:34:51 -0400
Subject: [PATCH 1/7] Add wallet migration GUI flow and test
---
qml/bitcoin_qml.qrc | 1 +
qml/pages/wallet/DesktopWallets.qml | 38 +++--
qml/pages/wallet/ImportWalletMigration.qml | 161 +++++++++++++++++++
qml/pages/wallet/WalletBadge.qml | 1 +
qml/pages/wallet/WalletSelect.qml | 1 +
test/functional/qml_driver.py | 11 ++
test/functional/qml_test_wallet_migration.py | 101 ++++++++++++
7 files changed, 303 insertions(+), 11 deletions(-)
create mode 100644 qml/pages/wallet/ImportWalletMigration.qml
create mode 100644 test/functional/qml_test_wallet_migration.py
diff --git a/qml/bitcoin_qml.qrc b/qml/bitcoin_qml.qrc
index 4945da9d1a..1e8fc33063 100644
--- a/qml/bitcoin_qml.qrc
+++ b/qml/bitcoin_qml.qrc
@@ -107,6 +107,7 @@
pages/wallet/CreatePassword.qml
pages/wallet/ImportWalletOptions.qml
pages/wallet/ImportWalletSuccess.qml
+ pages/wallet/ImportWalletMigration.qml
pages/wallet/CreateWalletWizard.qml
pages/wallet/DesktopWallets.qml
pages/wallet/MultipleSendReview.qml
diff --git a/qml/pages/wallet/DesktopWallets.qml b/qml/pages/wallet/DesktopWallets.qml
index 4f4dbf72af..f95bcee24d 100644
--- a/qml/pages/wallet/DesktopWallets.qml
+++ b/qml/pages/wallet/DesktopWallets.qml
@@ -22,12 +22,38 @@ Page {
signal addWallet()
signal sendTransaction(bool multipleRecipientsEnabled)
+ function handleWalletBadgeClicked() {
+ if (!walletController.initialized) {
+ return
+ }
+
+ walletListModel.listWalletDir()
+ if (walletController.noWalletsFound) {
+ root.addWallet()
+ } else {
+ walletSelect.opened ? walletSelect.close() : walletSelect.open()
+ }
+ }
+
+ Component {
+ id: walletMigrationPage
+ ImportWalletMigration {
+ onBack: root.StackView.view.pop()
+ onCancel: root.StackView.view.pop()
+ onNext: root.StackView.view.pop()
+ }
+ }
+
Connections {
target: walletController
function onOpenWalletSettingsRequested() {
settingsTabButton.checked = true
nodeSettings.openWalletSettings()
}
+ function onWalletMigrationRequired(walletPath) {
+ walletSelect.close()
+ root.StackView.view.push(walletMigrationPage, { "walletPath": walletPath })
+ }
}
header: NavigationBar2 {
@@ -42,17 +68,7 @@ Page {
loading: !walletController.initialized
noWalletLoaded: !walletController.isWalletLoaded
noWalletsFound: walletController.noWalletsFound
-
- onClicked: {
- if (walletController.initialized) {
- walletListModel.listWalletDir()
- if (walletController.noWalletsFound) {
- root.addWallet()
- } else {
- walletSelect.opened ? walletSelect.close() : walletSelect.open()
- }
- }
- }
+ onClicked: root.handleWalletBadgeClicked()
WalletSelect {
id: walletSelect
diff --git a/qml/pages/wallet/ImportWalletMigration.qml b/qml/pages/wallet/ImportWalletMigration.qml
new file mode 100644
index 0000000000..51d7affea3
--- /dev/null
+++ b/qml/pages/wallet/ImportWalletMigration.qml
@@ -0,0 +1,161 @@
+// Copyright (c) 2026 The Bitcoin Core developers
+// Distributed under the MIT software license, see the accompanying
+// file COPYING or http://www.opensource.org/licenses/mit-license.php.
+
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Layouts 1.15
+
+import "../../controls"
+import "../../components"
+import "../settings"
+
+Page {
+ id: root
+ objectName: "importWalletMigration"
+ required property string walletPath
+ signal back
+ signal cancel
+ signal next
+
+ property string phase: "intro"
+ readonly property bool introPhase: phase === "intro"
+ readonly property bool updatingPhase: phase === "updating"
+ readonly property bool successPhase: phase === "success"
+ readonly property bool failedPhase: phase === "failed"
+
+ function startMigration() {
+ phase = "updating"
+ walletController.migrateWallet(walletPath)
+ }
+
+ Component.onCompleted: {
+ walletController.clearWalletMigrationStatus()
+ phase = "intro"
+ }
+
+ background: null
+
+ header: NavigationBar2 {
+ leftItem: NavButton {
+ visible: !root.updatingPhase
+ enabled: visible
+ iconSource: "image://images/caret-left"
+ text: qsTr("Back")
+ onClicked: {
+ walletController.clearWalletMigrationStatus()
+ root.back()
+ }
+ }
+ rightItem: NavButton {
+ visible: root.introPhase || root.failedPhase
+ enabled: visible
+ text: qsTr("Cancel")
+ onClicked: {
+ walletController.clearWalletMigrationStatus()
+ root.cancel()
+ }
+ }
+ }
+
+ Connections {
+ target: walletController
+ function onWalletMigrationSucceeded() {
+ root.phase = "success"
+ }
+ function onWalletMigrationFailed() {
+ root.phase = "failed"
+ }
+ }
+
+ ColumnLayout {
+ width: Math.min(parent.width, 450)
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ Image {
+ Layout.alignment: Qt.AlignCenter
+ Layout.topMargin: 20
+ source: {
+ if (root.successPhase) {
+ return "image://images/circle-green-check"
+ }
+ if (root.failedPhase) {
+ return "image://images/circle-red-cross"
+ }
+ return "image://images/pending"
+ }
+ sourceSize.width: 60
+ sourceSize.height: 60
+ }
+
+ Header {
+ Layout.topMargin: 18
+ Layout.fillWidth: true
+ Layout.leftMargin: 20
+ Layout.rightMargin: 20
+ header: {
+ if (root.successPhase) {
+ return qsTr("Update completed")
+ }
+ if (root.failedPhase) {
+ return qsTr("Update failed")
+ }
+ if (root.updatingPhase) {
+ return qsTr("Updating wallet")
+ }
+ return qsTr("Wallet update required")
+ }
+ headerBold: true
+ description: {
+ if (root.successPhase) {
+ return qsTr("You can use your wallet as usual.")
+ }
+ if (root.failedPhase) {
+ return walletController.walletMigrationError.length > 0
+ ? walletController.walletMigrationError
+ : qsTr("The wallet could not be updated.")
+ }
+ if (root.updatingPhase) {
+ return qsTr("Updating your wallet now. This may take a moment.")
+ }
+ return qsTr("This wallet uses an outdated file format and needs to be updated. This does not impact security or any previously made transactions.")
+ }
+ }
+
+ ContinueButton {
+ id: actionButton
+ objectName: "walletMigrationActionButton"
+ Layout.preferredWidth: Math.min(335, parent.width - 2 * Layout.leftMargin)
+ Layout.topMargin: 30
+ Layout.leftMargin: 20
+ Layout.rightMargin: 20
+ Layout.alignment: Qt.AlignCenter
+ enabled: !root.updatingPhase
+ text: {
+ if (root.successPhase) {
+ return qsTr("Next")
+ }
+ if (root.failedPhase) {
+ return qsTr("Back to overview")
+ }
+ if (root.updatingPhase) {
+ return qsTr("Updating wallet...")
+ }
+ return qsTr("Update wallet")
+ }
+ onClicked: {
+ if (root.successPhase) {
+ walletController.clearWalletMigrationStatus()
+ root.next()
+ return
+ }
+ if (root.failedPhase) {
+ walletController.clearWalletMigrationStatus()
+ root.cancel()
+ return
+ }
+ root.startMigration()
+ }
+ }
+ }
+}
diff --git a/qml/pages/wallet/WalletBadge.qml b/qml/pages/wallet/WalletBadge.qml
index 7a7652a0f0..1345091c16 100644
--- a/qml/pages/wallet/WalletBadge.qml
+++ b/qml/pages/wallet/WalletBadge.qml
@@ -12,6 +12,7 @@ import "../../controls"
Button {
id: root
+ objectName: "walletBadge"
property color bgActiveColor: Theme.color.neutral2
property color textColor: Theme.color.neutral7
diff --git a/qml/pages/wallet/WalletSelect.qml b/qml/pages/wallet/WalletSelect.qml
index 2492045fd8..b525f4c6f3 100644
--- a/qml/pages/wallet/WalletSelect.qml
+++ b/qml/pages/wallet/WalletSelect.qml
@@ -78,6 +78,7 @@ Popup {
delegate: WalletBadge {
required property string name;
+ objectName: "walletSelectItem_" + name
width: 220
height: 32
diff --git a/test/functional/qml_driver.py b/test/functional/qml_driver.py
index 182bcdc439..d47b0dcbc7 100644
--- a/test/functional/qml_driver.py
+++ b/test/functional/qml_driver.py
@@ -250,6 +250,17 @@ def settle(
f"Timed out waiting for stack views to become idle: {last_busy}"
)
+ def wait_for_object(self, object_name, timeout_ms=5000):
+ """Block until an object with the given name exists."""
+ deadline = time.time() + (timeout_ms / 1000)
+ while time.time() < deadline:
+ if any(obj.get("objectName") == object_name for obj in self.list_objects()):
+ return
+ time.sleep(0.1)
+ raise QmlDriverError(
+ f"wait_for_object({object_name!r}) failed: Object not found before timeout"
+ )
+
# ── Transport layer ──────────────────────────────────────────────
def _send(self, cmd):
diff --git a/test/functional/qml_test_wallet_migration.py b/test/functional/qml_test_wallet_migration.py
new file mode 100644
index 0000000000..baf7604386
--- /dev/null
+++ b/test/functional/qml_test_wallet_migration.py
@@ -0,0 +1,101 @@
+#!/usr/bin/env python3
+# Copyright (c) 2026 The Bitcoin Core developers
+# Distributed under the MIT software license, see the accompanying
+# file COPYING or http://www.opensource.org/licenses/mit-license.php.
+"""End-to-end GUI test for selector-driven legacy wallet migration."""
+
+import os
+import shutil
+import sys
+
+from qml_test_harness import dump_qml_tree
+from qml_wallet_test_lib import WalletFlowHarness, find_legacy_bitcoind, rpc_call
+
+
+def run_test():
+ harness = WalletFlowHarness("qml_wallet_migration", port_offset=50)
+ gui = None
+ try:
+ legacy_binary = find_legacy_bitcoind()
+ if not legacy_binary:
+ print("SKIPPED: legacy bitcoind not found.")
+ print("Set BITCOIND_LEGACY or provide releases/v28.0/bin/bitcoind to exercise this flow.")
+ return 77
+
+ harness.start_source_node(
+ binary=legacy_binary,
+ extra_args=["-deprecatedrpc=create_bdb"],
+ )
+ wallet_name = "legacy_flow"
+ try:
+ rpc_call(
+ harness.source_rpc_port,
+ "createwallet",
+ {
+ "wallet_name": wallet_name,
+ "descriptors": False,
+ },
+ )
+ except RuntimeError as err:
+ print(f"SKIPPED: could not create a legacy wallet fixture: {err}")
+ return 77
+
+ rpc_call(harness.source_rpc_port, "unloadwallet", [wallet_name])
+ source_wallet_path = os.path.join(harness.source_wallets_path, wallet_name)
+ target_wallet_path = os.path.join(harness.gui_wallets_path, wallet_name)
+ os.makedirs(os.path.dirname(target_wallet_path), exist_ok=True)
+ shutil.copytree(source_wallet_path, target_wallet_path)
+ harness.stop_source_node()
+
+ harness.start_gui()
+ gui = harness.driver
+ gui.wait_for_property("walletBadge", "loading", False, timeout_ms=20000)
+ gui.wait_for_property("walletBadge", "visible", True, timeout_ms=10000)
+
+ wallet_dir = rpc_call(harness.gui_rpc_port, "listwalletdir")["wallets"]
+ legacy_entry = next((entry for entry in wallet_dir if entry["name"] == wallet_name), None)
+ assert legacy_entry is not None, f"Expected {wallet_name} to be present in listwalletdir"
+ assert legacy_entry["warnings"] == [
+ "This wallet is a legacy wallet and will need to be migrated with migratewallet before it can be loaded"
+ ], f"Expected migration warning for {wallet_name}"
+
+ gui.click("walletBadge")
+ gui.wait_for_property("walletSelectPopup", "opened", True, timeout_ms=5000)
+ gui.wait_for_property("walletSelectList", "count", 1, timeout_ms=5000)
+ gui.wait_for_object(f"walletSelectItem_{wallet_name}", timeout_ms=5000)
+ gui.click(f"walletSelectItem_{wallet_name}")
+
+ gui.wait_for_page("importWalletMigration", timeout_ms=10000)
+ gui.wait_for_property("walletMigrationActionButton", "text", "Update wallet", timeout_ms=5000)
+ gui.click("walletMigrationActionButton")
+ gui.wait_for_property("walletMigrationActionButton", "text", "Next", timeout_ms=30000)
+ gui.click("walletMigrationActionButton")
+
+ gui.wait_for_property("walletBadge", "text", wallet_name, timeout_ms=20000)
+ wallet_dat_path = os.path.join(target_wallet_path, "wallet.dat")
+ with open(wallet_dat_path, "rb") as wallet_file:
+ assert wallet_file.read(16) == b"SQLite format 3\x00", "Migrated wallet should be SQLite"
+
+ wallet_info = rpc_call(harness.gui_rpc_port, "getwalletinfo", wallet=wallet_name)
+ assert wallet_info["descriptors"] is True, "Migrated wallet should be descriptor based"
+ assert wallet_info["format"] == "sqlite", "Migrated wallet should be stored as sqlite"
+
+ print("Migration flow passed.")
+ return 0
+ except Exception as err: # noqa: BLE001 - preserve failure context for functional test output
+ print(f"\nFAILED: {err}", file=sys.stderr)
+ import traceback
+ traceback.print_exc()
+ gui_output = harness.process_output(harness.gui_process)
+ if gui_output:
+ print("\n--- GUI process output ---", file=sys.stderr)
+ print(gui_output, file=sys.stderr)
+ if gui is not None:
+ dump_qml_tree(gui)
+ return 1
+ finally:
+ harness.stop()
+
+
+if __name__ == "__main__":
+ sys.exit(run_test())
From 84c48a611811452379da6d4c4b9e3900d602306b Mon Sep 17 00:00:00 2001
From: johnny9 <985648+johnny9@users.noreply.github.com>
Date: Mon, 30 Mar 2026 16:33:56 -0400
Subject: [PATCH 2/7] qml: ignore stale wallet migration navigation
---
qml/pages/wallet/DesktopWallets.qml | 13 +++++++++++--
1 file changed, 11 insertions(+), 2 deletions(-)
diff --git a/qml/pages/wallet/DesktopWallets.qml b/qml/pages/wallet/DesktopWallets.qml
index f95bcee24d..19c9a24a1f 100644
--- a/qml/pages/wallet/DesktopWallets.qml
+++ b/qml/pages/wallet/DesktopWallets.qml
@@ -22,6 +22,16 @@ Page {
signal addWallet()
signal sendTransaction(bool multipleRecipientsEnabled)
+ function handleWalletMigrationRequired(walletPath) {
+ const stackView = root.StackView.view
+ if (!stackView || stackView.currentItem !== root) {
+ return
+ }
+
+ walletSelect.close()
+ stackView.push(walletMigrationPage, { "walletPath": walletPath })
+ }
+
function handleWalletBadgeClicked() {
if (!walletController.initialized) {
return
@@ -51,8 +61,7 @@ Page {
nodeSettings.openWalletSettings()
}
function onWalletMigrationRequired(walletPath) {
- walletSelect.close()
- root.StackView.view.push(walletMigrationPage, { "walletPath": walletPath })
+ root.handleWalletMigrationRequired(walletPath)
}
}
From b0c0e7078b006c203ef6781f6efb10734ac66132 Mon Sep 17 00:00:00 2001
From: johnny9 <985648+johnny9@users.noreply.github.com>
Date: Mon, 30 Mar 2026 23:12:34 -0400
Subject: [PATCH 3/7] ci: run gui functional wallet tests with legacy bitcoind
wallets
---
.github/workflows/gui-functional-tests.yml | 48 +++++++++++++++++++++-
1 file changed, 47 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/gui-functional-tests.yml b/.github/workflows/gui-functional-tests.yml
index 7fee1ac3f8..c51c73071f 100644
--- a/.github/workflows/gui-functional-tests.yml
+++ b/.github/workflows/gui-functional-tests.yml
@@ -16,6 +16,8 @@ on:
jobs:
qml-functional-tests:
runs-on: ubuntu-24.04
+ env:
+ LEGACY_BITCOIND_VERSION: "28.0"
steps:
- name: Checkout
@@ -27,7 +29,7 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install -y \
- build-essential cmake pkgconf python3 \
+ build-essential cmake curl pkgconf python3 \
libevent-dev libboost-dev libsqlite3-dev libgl-dev libqrencode-dev \
qt6-qpa-plugins \
qt6-base-dev qt6-tools-dev qt6-l10n-tools qt6-tools-dev-tools \
@@ -37,6 +39,47 @@ jobs:
qml6-module-qtquick-layouts qml6-module-qtquick-controls \
qml6-module-qtquick-dialogs qml6-module-qtquick-templates
+ - name: Restore legacy bitcoind cache
+ uses: actions/cache/restore@v4
+ id: legacy-bitcoind-cache
+ with:
+ path: releases/v${{ env.LEGACY_BITCOIND_VERSION }}
+ key: ${{ runner.os }}-legacy-bitcoind-v${{ env.LEGACY_BITCOIND_VERSION }}
+
+ - name: Install legacy bitcoind
+ run: |
+ archive="bitcoin-${LEGACY_BITCOIND_VERSION}-x86_64-linux-gnu.tar.gz"
+ base_url="https://bitcoincore.org/bin/bitcoin-core-${LEGACY_BITCOIND_VERSION}"
+ legacy_dir="${GITHUB_WORKSPACE}/releases/v${LEGACY_BITCOIND_VERSION}"
+
+ mkdir -p "${legacy_dir}"
+
+ if [ ! -x "${legacy_dir}/bin/bitcoind" ]; then
+ workdir="$(mktemp -d)"
+ trap 'rm -rf "${workdir}"' EXIT
+
+ curl -fsSLo "${workdir}/${archive}" "${base_url}/${archive}"
+ curl -fsSLo "${workdir}/SHA256SUMS" "${base_url}/SHA256SUMS"
+ (
+ cd "${workdir}"
+ grep " ${archive}$" "SHA256SUMS" | sha256sum -c -
+ )
+
+ tar -xzf "${workdir}/${archive}" \
+ --strip-components=1 \
+ -C "${legacy_dir}" \
+ "bitcoin-${LEGACY_BITCOIND_VERSION}/bin"
+ fi
+
+ "${legacy_dir}/bin/bitcoind" --version | head -n 1
+
+ - name: Save legacy bitcoind cache
+ uses: actions/cache/save@v4
+ if: github.event_name != 'pull_request' && steps.legacy-bitcoind-cache.outputs.cache-hit != 'true'
+ with:
+ path: releases/v${{ env.LEGACY_BITCOIND_VERSION }}
+ key: ${{ runner.os }}-legacy-bitcoind-v${{ env.LEGACY_BITCOIND_VERSION }}
+
- name: Configure
run: |
cmake -B build \
@@ -53,6 +96,7 @@ jobs:
QT_QUICK_BACKEND: software
LIBGL_ALWAYS_SOFTWARE: "1"
run: |
+ export BITCOIND_LEGACY="${GITHUB_WORKSPACE}/releases/v${LEGACY_BITCOIND_VERSION}/bin/bitcoind"
python3 test/functional/qml_test_bridge_sanity.py
python3 test/functional/qml_test_onboarding.py
python3 test/functional/qml_test_external_signer.py
@@ -60,3 +104,5 @@ jobs:
python3 test/functional/qml_test_proxy.py
python3 test/functional/qml_test_debug_log.py
python3 test/functional/qml_test_settings_display.py
+ python3 test/functional/qml_test_wallet_migration.py
+ python3 test/functional/qml_test_wallet_restore.py
From 04cd68a2df852adea2325a24c861a57cf95a40ff Mon Sep 17 00:00:00 2001
From: johnny9 <985648+johnny9@users.noreply.github.com>
Date: Fri, 8 May 2026 04:50:30 -0400
Subject: [PATCH 4/7] qml: address wallet migration review feedback
---
qml/pages/wallet/DesktopWallets.qml | 1 -
qml/pages/wallet/ImportWalletMigration.qml | 16 +++-------------
test/functional/qml_test_wallet_migration.py | 2 +-
3 files changed, 4 insertions(+), 15 deletions(-)
diff --git a/qml/pages/wallet/DesktopWallets.qml b/qml/pages/wallet/DesktopWallets.qml
index 19c9a24a1f..fd3a488bc6 100644
--- a/qml/pages/wallet/DesktopWallets.qml
+++ b/qml/pages/wallet/DesktopWallets.qml
@@ -49,7 +49,6 @@ Page {
id: walletMigrationPage
ImportWalletMigration {
onBack: root.StackView.view.pop()
- onCancel: root.StackView.view.pop()
onNext: root.StackView.view.pop()
}
}
diff --git a/qml/pages/wallet/ImportWalletMigration.qml b/qml/pages/wallet/ImportWalletMigration.qml
index 51d7affea3..75aa0f1749 100644
--- a/qml/pages/wallet/ImportWalletMigration.qml
+++ b/qml/pages/wallet/ImportWalletMigration.qml
@@ -15,7 +15,6 @@ Page {
objectName: "importWalletMigration"
required property string walletPath
signal back
- signal cancel
signal next
property string phase: "intro"
@@ -47,15 +46,6 @@ Page {
root.back()
}
}
- rightItem: NavButton {
- visible: root.introPhase || root.failedPhase
- enabled: visible
- text: qsTr("Cancel")
- onClicked: {
- walletController.clearWalletMigrationStatus()
- root.cancel()
- }
- }
}
Connections {
@@ -95,7 +85,7 @@ Page {
Layout.rightMargin: 20
header: {
if (root.successPhase) {
- return qsTr("Update completed")
+ return qsTr("Wallet successfully migrated")
}
if (root.failedPhase) {
return qsTr("Update failed")
@@ -133,7 +123,7 @@ Page {
enabled: !root.updatingPhase
text: {
if (root.successPhase) {
- return qsTr("Next")
+ return qsTr("Done")
}
if (root.failedPhase) {
return qsTr("Back to overview")
@@ -151,7 +141,7 @@ Page {
}
if (root.failedPhase) {
walletController.clearWalletMigrationStatus()
- root.cancel()
+ root.back()
return
}
root.startMigration()
diff --git a/test/functional/qml_test_wallet_migration.py b/test/functional/qml_test_wallet_migration.py
index baf7604386..a527088be5 100644
--- a/test/functional/qml_test_wallet_migration.py
+++ b/test/functional/qml_test_wallet_migration.py
@@ -68,7 +68,7 @@ def run_test():
gui.wait_for_page("importWalletMigration", timeout_ms=10000)
gui.wait_for_property("walletMigrationActionButton", "text", "Update wallet", timeout_ms=5000)
gui.click("walletMigrationActionButton")
- gui.wait_for_property("walletMigrationActionButton", "text", "Next", timeout_ms=30000)
+ gui.wait_for_property("walletMigrationActionButton", "text", "Done", timeout_ms=30000)
gui.click("walletMigrationActionButton")
gui.wait_for_property("walletBadge", "text", wallet_name, timeout_ms=20000)
From f45c56207aad684c85d581bb6c33a59e13dca4df Mon Sep 17 00:00:00 2001
From: johnny9 <985648+johnny9@users.noreply.github.com>
Date: Mon, 11 May 2026 13:13:23 -0400
Subject: [PATCH 5/7] qml, test: add wallet migration checkpoints
---
test/functional/qml_test_wallet_migration.py | 84 +++++++++++++++++++-
1 file changed, 82 insertions(+), 2 deletions(-)
diff --git a/test/functional/qml_test_wallet_migration.py b/test/functional/qml_test_wallet_migration.py
index a527088be5..e723d8de99 100644
--- a/test/functional/qml_test_wallet_migration.py
+++ b/test/functional/qml_test_wallet_migration.py
@@ -4,16 +4,73 @@
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
"""End-to-end GUI test for selector-driven legacy wallet migration."""
+import argparse
import os
+import re
import shutil
import sys
+from datetime import datetime
from qml_test_harness import dump_qml_tree
from qml_wallet_test_lib import WalletFlowHarness, find_legacy_bitcoind, rpc_call
-def run_test():
+def parse_args():
+ parser = argparse.ArgumentParser(
+ description="Wallet migration GUI functional test",
+ add_help=True,
+ )
+ parser.add_argument(
+ "--save-screenshots",
+ action="store_true",
+ help="Save a PNG at each GUI checkpoint under test/artifacts/",
+ )
+ return parser.parse_args()
+
+
+def make_screenshot_root():
+ repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ artifacts_root = os.path.join(repo_root, "test", "artifacts")
+ timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
+ screenshot_root = os.path.join(artifacts_root, f"qml_test_wallet_migration-{timestamp}")
+ os.makedirs(screenshot_root, exist_ok=True)
+ return screenshot_root
+
+
+class CheckpointRecorder:
+ def __init__(self, save_screenshots, screenshot_root):
+ self.save_screenshots = save_screenshots
+ self.screenshot_root = screenshot_root
+ self.index = 0
+
+ def _sanitize_label(self, label):
+ return re.sub(r"[^a-z0-9]+", "-", label.lower()).strip("-") or "checkpoint"
+
+ def checkpoint(self, label, gui=None):
+ if gui is not None:
+ gui.settle()
+
+ self.index += 1
+ prefix = f"[qml_test_wallet_migration] checkpoint {self.index:02d}"
+ print(f"{prefix}: {label}")
+ if gui is None:
+ return
+
+ if not self.save_screenshots:
+ return
+
+ filename = f"{self.index:02d}-{self._sanitize_label(label)}.png"
+ screenshot_path = os.path.join(self.screenshot_root, filename)
+ screenshot = gui.save_screenshot(screenshot_path)
+ print(
+ f"{prefix}: screenshot saved to {screenshot['path']} "
+ f"({screenshot['width']}x{screenshot['height']})"
+ )
+
+
+def run_test(*, save_screenshots=False, screenshot_root=None):
harness = WalletFlowHarness("qml_wallet_migration", port_offset=50)
+ checkpoints = CheckpointRecorder(save_screenshots, screenshot_root)
gui = None
try:
legacy_binary = find_legacy_bitcoind()
@@ -22,10 +79,12 @@ def run_test():
print("Set BITCOIND_LEGACY or provide releases/v28.0/bin/bitcoind to exercise this flow.")
return 77
+ checkpoints.checkpoint(f"legacy bitcoind located: {legacy_binary}")
harness.start_source_node(
binary=legacy_binary,
extra_args=["-deprecatedrpc=create_bdb"],
)
+ checkpoints.checkpoint("legacy source node started")
wallet_name = "legacy_flow"
try:
rpc_call(
@@ -40,17 +99,20 @@ def run_test():
print(f"SKIPPED: could not create a legacy wallet fixture: {err}")
return 77
+ checkpoints.checkpoint("legacy wallet fixture created")
rpc_call(harness.source_rpc_port, "unloadwallet", [wallet_name])
source_wallet_path = os.path.join(harness.source_wallets_path, wallet_name)
target_wallet_path = os.path.join(harness.gui_wallets_path, wallet_name)
os.makedirs(os.path.dirname(target_wallet_path), exist_ok=True)
shutil.copytree(source_wallet_path, target_wallet_path)
harness.stop_source_node()
+ checkpoints.checkpoint(f"legacy wallet fixture copied to GUI datadir: {target_wallet_path}")
harness.start_gui()
gui = harness.driver
gui.wait_for_property("walletBadge", "loading", False, timeout_ms=20000)
gui.wait_for_property("walletBadge", "visible", True, timeout_ms=10000)
+ checkpoints.checkpoint("GUI launched", gui)
wallet_dir = rpc_call(harness.gui_rpc_port, "listwalletdir")["wallets"]
legacy_entry = next((entry for entry in wallet_dir if entry["name"] == wallet_name), None)
@@ -58,20 +120,26 @@ def run_test():
assert legacy_entry["warnings"] == [
"This wallet is a legacy wallet and will need to be migrated with migratewallet before it can be loaded"
], f"Expected migration warning for {wallet_name}"
+ print(f"[qml_test_wallet_migration] legacy wallet dir entry: {legacy_entry}")
+ checkpoints.checkpoint("legacy wallet warning verified", gui)
gui.click("walletBadge")
gui.wait_for_property("walletSelectPopup", "opened", True, timeout_ms=5000)
gui.wait_for_property("walletSelectList", "count", 1, timeout_ms=5000)
gui.wait_for_object(f"walletSelectItem_{wallet_name}", timeout_ms=5000)
+ checkpoints.checkpoint("legacy wallet visible in selector", gui)
gui.click(f"walletSelectItem_{wallet_name}")
gui.wait_for_page("importWalletMigration", timeout_ms=10000)
gui.wait_for_property("walletMigrationActionButton", "text", "Update wallet", timeout_ms=5000)
+ checkpoints.checkpoint("migration confirmation page displayed", gui)
gui.click("walletMigrationActionButton")
gui.wait_for_property("walletMigrationActionButton", "text", "Done", timeout_ms=30000)
+ checkpoints.checkpoint("wallet migration completed", gui)
gui.click("walletMigrationActionButton")
gui.wait_for_property("walletBadge", "text", wallet_name, timeout_ms=20000)
+ checkpoints.checkpoint("migrated wallet loaded", gui)
wallet_dat_path = os.path.join(target_wallet_path, "wallet.dat")
with open(wallet_dat_path, "rb") as wallet_file:
assert wallet_file.read(16) == b"SQLite format 3\x00", "Migrated wallet should be SQLite"
@@ -79,6 +147,8 @@ def run_test():
wallet_info = rpc_call(harness.gui_rpc_port, "getwalletinfo", wallet=wallet_name)
assert wallet_info["descriptors"] is True, "Migrated wallet should be descriptor based"
assert wallet_info["format"] == "sqlite", "Migrated wallet should be stored as sqlite"
+ print(f"[qml_test_wallet_migration] migrated wallet info: {wallet_info}")
+ checkpoints.checkpoint("migration RPC verification passed", gui)
print("Migration flow passed.")
return 0
@@ -86,6 +156,11 @@ def run_test():
print(f"\nFAILED: {err}", file=sys.stderr)
import traceback
traceback.print_exc()
+ if gui is not None:
+ try:
+ checkpoints.checkpoint("failure state", gui)
+ except Exception as screenshot_err: # noqa: BLE001 - preserve original failure context
+ print(f"[qml_test_wallet_migration] failed to save failure screenshot: {screenshot_err}", file=sys.stderr)
gui_output = harness.process_output(harness.gui_process)
if gui_output:
print("\n--- GUI process output ---", file=sys.stderr)
@@ -98,4 +173,9 @@ def run_test():
if __name__ == "__main__":
- sys.exit(run_test())
+ args = parse_args()
+ screenshot_root = None
+ if args.save_screenshots:
+ screenshot_root = make_screenshot_root()
+ print(f"Checkpoint screenshots will be saved under: {screenshot_root}")
+ sys.exit(run_test(save_screenshots=args.save_screenshots, screenshot_root=screenshot_root))
From 596fc76df48afc9830079d7a8c904cba4e2ef26d Mon Sep 17 00:00:00 2001
From: johnny9
Date: Wed, 13 May 2026 22:36:59 -0400
Subject: [PATCH 6/7] test: isolate QSettings in QML harnesses
Run launched GUI processes with a temporary XDG_CONFIG_HOME so QSettings written by one functional test do not leak into later tests.
When a harness reuses an existing datadir for restart coverage, keep it on the same temporary config root so settings persistence is still exercised.
---
test/functional/qml_test_harness.py | 6 ++++++
test/functional/qml_wallet_test_lib.py | 3 +++
2 files changed, 9 insertions(+)
diff --git a/test/functional/qml_test_harness.py b/test/functional/qml_test_harness.py
index a7c489ba5d..cde9a01183 100755
--- a/test/functional/qml_test_harness.py
+++ b/test/functional/qml_test_harness.py
@@ -93,6 +93,7 @@ def __init__(self, socket_path=None, extra_args=None, reset_settings=True, datad
self.driver = None
self.extra_args = extra_args or []
self.reset_settings = reset_settings
+ self.config_home = None
if self.external:
self.socket_path = socket_path
@@ -105,10 +106,12 @@ def __init__(self, socket_path=None, extra_args=None, reset_settings=True, datad
self.tmpdir = None
self.datadir = datadir
self.socket_path = os.path.join(datadir, "test_bridge.sock")
+ self.config_home = os.path.join(os.path.dirname(datadir), "config")
else:
self.tmpdir = tempfile.mkdtemp(prefix="qml_test_bridge_")
self.datadir = setup_datadir(self.tmpdir)
self.socket_path = os.path.join(self.tmpdir, "test_bridge.sock")
+ self.config_home = os.path.join(self.tmpdir, "config")
def start(self):
"""Launch bitcoin-core-app or attach to an existing instance."""
@@ -120,6 +123,9 @@ def start(self):
env = dict(os.environ)
env["QT_QPA_PLATFORM"] = "offscreen"
+ if self.config_home:
+ os.makedirs(self.config_home, exist_ok=True)
+ env["XDG_CONFIG_HOME"] = self.config_home
args = [
self.gui_binary,
diff --git a/test/functional/qml_wallet_test_lib.py b/test/functional/qml_wallet_test_lib.py
index 7c5010eb21..43f3e801f2 100644
--- a/test/functional/qml_wallet_test_lib.py
+++ b/test/functional/qml_wallet_test_lib.py
@@ -163,6 +163,7 @@ def __init__(self, name, port_offset):
self.socket_path = os.path.join(self.tmpdir, "test_bridge.sock")
self.gui_datadir = os.path.join(self.tmpdir, "gui_node")
self.source_datadir = os.path.join(self.tmpdir, "source_node")
+ self.config_home = os.path.join(self.tmpdir, "config")
self.gui_process = None
self.source_process = None
self.driver = None
@@ -211,6 +212,8 @@ def stop_source_node(self):
def start_gui(self, reset_gui_settings=False, extra_args=None, cwd=None):
env = dict(os.environ)
env["QT_QPA_PLATFORM"] = "offscreen"
+ os.makedirs(self.config_home, exist_ok=True)
+ env["XDG_CONFIG_HOME"] = self.config_home
args = [
self.gui_binary,
f"-datadir={self.gui_datadir}",
From 09ba06cbc35b27ca94ad1c1240f7538638f059b9 Mon Sep 17 00:00:00 2001
From: johnny9
Date: Thu, 14 May 2026 00:05:11 -0400
Subject: [PATCH 7/7] qml: detect legacy wallets before loading
---
qml/models/walletlistmodel.cpp | 24 +++++--
qml/models/walletlistmodel.h | 6 +-
qml/pages/wallet/WalletSelect.qml | 6 +-
qml/walletqmlcontroller.cpp | 68 +++++++++++--------
qml/walletqmlcontroller.h | 8 +--
test/qml/qml_tests_main.cpp | 12 +++-
test/test_walletqmlcontroller.cpp | 108 ++++++++++++++++++++++++++++++
7 files changed, 186 insertions(+), 46 deletions(-)
diff --git a/qml/models/walletlistmodel.cpp b/qml/models/walletlistmodel.cpp
index def378b7d9..9c77b53051 100644
--- a/qml/models/walletlistmodel.cpp
+++ b/qml/models/walletlistmodel.cpp
@@ -1,4 +1,4 @@
-// Copyright (c) 2024 The Bitcoin Core developers
+// Copyright (c) 2024-2026 The Bitcoin Core developers
// Distributed under the MIT software license, see the accompanying
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
@@ -6,7 +6,7 @@
#include
-#include
+#include
WalletListModel::WalletListModel(interfaces::Node& node, QObject *parent)
: QAbstractListModel(parent)
@@ -16,17 +16,24 @@ WalletListModel::WalletListModel(interfaces::Node& node, QObject *parent)
void WalletListModel::listWalletDir()
{
- QSet existing_names;
+ QHash existing_rows;
for (int i = 0; i < rowCount(); ++i) {
QModelIndex index = this->index(i, 0);
QString name = data(index, NameRole).toString();
- existing_names.insert(name);
+ existing_rows.insert(name, i);
}
- for (const auto& [path, info] : m_node.walletLoader().listWalletDir()) {
+ for (const auto& [path, format] : m_node.walletLoader().listWalletDir()) {
QString qname = QString::fromStdString(path);
- if (!existing_names.contains(qname)) {
- addItem({ qname });
+ QString qformat = QString::fromStdString(format);
+ if (existing_rows.contains(qname)) {
+ const int row = existing_rows.value(qname);
+ if (m_items[row].format != qformat) {
+ m_items[row].format = qformat;
+ Q_EMIT dataChanged(index(row, 0), index(row, 0), {FormatRole});
+ }
+ } else {
+ addItem({ qname, qformat });
}
}
}
@@ -47,6 +54,8 @@ QVariant WalletListModel::data(const QModelIndex &index, int role) const
case Qt::DisplayRole:
case NameRole:
return item.name;
+ case FormatRole:
+ return item.format;
default:
return QVariant();
}
@@ -56,6 +65,7 @@ QHash WalletListModel::roleNames() const
{
QHash roles;
roles[NameRole] = "name";
+ roles[FormatRole] = "format";
return roles;
}
diff --git a/qml/models/walletlistmodel.h b/qml/models/walletlistmodel.h
index c36cb2b4fc..2adf482439 100644
--- a/qml/models/walletlistmodel.h
+++ b/qml/models/walletlistmodel.h
@@ -1,4 +1,4 @@
-// Copyright (c) 2024 The Bitcoin Core developers
+// Copyright (c) 2024-2026 The Bitcoin Core developers
// Distributed under the MIT software license, see the accompanying
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
@@ -22,7 +22,8 @@ class WalletListModel : public QAbstractListModel
~WalletListModel() = default;
enum Roles {
- NameRole = Qt::UserRole + 1
+ NameRole = Qt::UserRole + 1,
+ FormatRole
};
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
@@ -35,6 +36,7 @@ public Q_SLOTS:
private:
struct Item {
QString name;
+ QString format;
};
void addItem(const Item &item);
diff --git a/qml/pages/wallet/WalletSelect.qml b/qml/pages/wallet/WalletSelect.qml
index b525f4c6f3..768d63f28d 100644
--- a/qml/pages/wallet/WalletSelect.qml
+++ b/qml/pages/wallet/WalletSelect.qml
@@ -1,4 +1,4 @@
-// Copyright (c) 2024 The Bitcoin Core developers
+// Copyright (c) 2024-2026 The Bitcoin Core developers
// Distributed under the MIT software license, see the accompanying
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
@@ -78,8 +78,8 @@ Popup {
delegate: WalletBadge {
required property string name;
+ required property string format;
objectName: "walletSelectItem_" + name
-
width: 220
height: 32
text: name
@@ -88,7 +88,7 @@ Popup {
showBalance: false
showIcon: false
onClicked: {
- walletController.setSelectedWallet(name)
+ walletController.setSelectedWallet(name, format)
root.close()
}
}
diff --git a/qml/walletqmlcontroller.cpp b/qml/walletqmlcontroller.cpp
index f824cba666..63dd2a5129 100644
--- a/qml/walletqmlcontroller.cpp
+++ b/qml/walletqmlcontroller.cpp
@@ -1,4 +1,4 @@
-// Copyright (c) 2024 The Bitcoin Core developers
+// Copyright (c) 2024-2026 The Bitcoin Core developers
// Distributed under the MIT software license, see the accompanying
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
@@ -39,16 +39,6 @@ QString JoinWarnings(const std::vector& warnings)
return lines.join('\n');
}
-bool NeedsWalletMigration(const bilingual_str& error)
-{
- const QString original = QString::fromStdString(error.original);
- const QString translated = QString::fromStdString(error.translated);
- return (original.contains("legacy wallet", Qt::CaseInsensitive) ||
- translated.contains("legacy wallet", Qt::CaseInsensitive)) &&
- (original.contains("migrat", Qt::CaseInsensitive) ||
- translated.contains("migrat", Qt::CaseInsensitive));
-}
-
bool IsBackupLikeFile(const QFileInfo& file_info)
{
const QString file_name = file_info.fileName().toLower();
@@ -105,7 +95,7 @@ WalletQmlController::~WalletQmlController()
delete m_empty_wallet;
}
-void WalletQmlController::setSelectedWallet(QString path)
+void WalletQmlController::setSelectedWallet(QString path, QString wallet_format)
{
if (!m_wallets.empty()) {
for (WalletQmlModel* wallet : m_wallets) {
@@ -118,7 +108,7 @@ void WalletQmlController::setSelectedWallet(QString path)
}
}
- startWalletLoad(path);
+ startWalletLoad(path, wallet_format);
}
WalletQmlModel* WalletQmlController::selectedWallet() const
@@ -422,21 +412,28 @@ QString WalletQmlController::inferWalletLoadTarget(const QString& normalized_pat
return {};
}
-QString WalletQmlController::resolveManagedWalletReference(const QString& path) const
+QString WalletQmlController::resolveManagedWalletReference(const QString& path, QString* wallet_format) const
{
+ if (wallet_format) {
+ wallet_format->clear();
+ }
+
if (path.isEmpty()) {
return {};
}
const QString candidate = QDir::cleanPath(path);
+ const auto wallet_dir_entries = m_node.walletLoader().listWalletDir();
// Wallet selector entries come from listWalletDir(), which reports wallet
// names relative to -walletdir. Accept those names directly before trying
// to interpret the value as a filesystem path.
- for (const auto& [wallet_path, info] : m_node.walletLoader().listWalletDir()) {
- Q_UNUSED(info);
+ for (const auto& [wallet_path, format] : wallet_dir_entries) {
const QString listed_wallet = QDir::cleanPath(QString::fromStdString(wallet_path));
if (listed_wallet == candidate) {
+ if (wallet_format) {
+ *wallet_format = QString::fromStdString(format);
+ }
return listed_wallet;
}
}
@@ -446,7 +443,23 @@ QString WalletQmlController::resolveManagedWalletReference(const QString& path)
return {};
}
- return inferWalletLoadTarget(normalized_path);
+ const QString load_target = inferWalletLoadTarget(normalized_path);
+ if (load_target.isEmpty()) {
+ return {};
+ }
+
+ const QString clean_load_target = QDir::cleanPath(load_target);
+ for (const auto& [wallet_path, format] : wallet_dir_entries) {
+ const QString listed_wallet = QDir::cleanPath(QString::fromStdString(wallet_path));
+ if (listed_wallet == clean_load_target) {
+ if (wallet_format) {
+ *wallet_format = QString::fromStdString(format);
+ }
+ break;
+ }
+ }
+
+ return load_target;
}
QString WalletQmlController::inferRestoreWalletName(const QString& normalized_path) const
@@ -625,7 +638,7 @@ void WalletQmlController::setNoWalletsFound(bool no_wallets_found)
}
}
-void WalletQmlController::startWalletLoad(const QString& path)
+void WalletQmlController::startWalletLoad(const QString& path, const QString& wallet_format)
{
clearWalletMigrationStatus();
clearWalletLoadStatus();
@@ -639,12 +652,20 @@ void WalletQmlController::startWalletLoad(const QString& path)
// Wallet selection can arrive either as a wallet name from listWalletDir()
// or as a concrete path under -walletdir. Resolve both into the wallet
// reference expected by loadWallet() and migrateWallet().
- const QString load_target = resolveManagedWalletReference(path);
+ QString load_target_format = wallet_format;
+ const QString load_target = load_target_format.isEmpty()
+ ? resolveManagedWalletReference(path, &load_target_format)
+ : QDir::cleanPath(path);
if (load_target.isEmpty()) {
setWalletLoadError(tr("The selected wallet is not available in the wallet directory."));
return;
}
+ if (load_target_format == "bdb") {
+ Q_EMIT walletMigrationRequired(load_target);
+ return;
+ }
+
m_wallet_load_requested = true;
m_pending_wallet_load_action = WalletLoadAction::Load;
setWalletLoadInProgress(true);
@@ -659,17 +680,10 @@ void WalletQmlController::startWalletLoad(const QString& path)
if (!wallet) {
const bilingual_str result_error = util::ErrorString(wallet);
const QString error = QString::fromStdString(result_error.translated);
- const bool migration_required = !load_target.isEmpty() && NeedsWalletMigration(result_error);
- QMetaObject::invokeMethod(this, [this, error, warnings, migration_required, load_target]() {
+ QMetaObject::invokeMethod(this, [this, error, warnings]() {
m_wallet_load_requested = false;
m_pending_wallet_load_action = WalletLoadAction::None;
setWalletLoadInProgress(false);
- if (migration_required) {
- clearWalletLoadStatus();
- Q_EMIT walletMigrationRequired(load_target);
- return;
- }
-
setWalletLoadWarnings(warnings);
setWalletLoadError(error.isEmpty() ? tr("Wallet could not be opened.") : error);
});
diff --git a/qml/walletqmlcontroller.h b/qml/walletqmlcontroller.h
index 228b88757f..4523bfa12b 100644
--- a/qml/walletqmlcontroller.h
+++ b/qml/walletqmlcontroller.h
@@ -1,4 +1,4 @@
-// Copyright (c) 2024 The Bitcoin Core developers
+// Copyright (c) 2024-2026 The Bitcoin Core developers
// Distributed under the MIT software license, see the accompanying
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
@@ -43,7 +43,7 @@ class WalletQmlController : public QObject
explicit WalletQmlController(interfaces::Node& node, QObject *parent = nullptr);
~WalletQmlController();
- Q_INVOKABLE void setSelectedWallet(QString path);
+ Q_INVOKABLE void setSelectedWallet(QString path, QString wallet_format = QString());
Q_INVOKABLE bool createSingleSigWallet(const QString &name, const QString &passphrase);
Q_INVOKABLE bool createExternalSignerWallet(const QString& name);
Q_INVOKABLE void importWallet(const QString& path);
@@ -108,9 +108,9 @@ public Q_SLOTS:
void handleLoadWallet(std::unique_ptr wallet);
void startWalletImport(const QString& path);
- void startWalletLoad(const QString& path);
+ void startWalletLoad(const QString& path, const QString& wallet_format = QString());
void startWalletMigration(const QString& path);
- QString resolveManagedWalletReference(const QString& path) const;
+ QString resolveManagedWalletReference(const QString& path, QString* wallet_format = nullptr) const;
QString inferWalletLoadTarget(const QString& normalized_path) const;
QString inferRestoreWalletName(const QString& normalized_path) const;
QString describeImportedWalletKeyScheme(interfaces::Wallet& wallet) const;
diff --git a/test/qml/qml_tests_main.cpp b/test/qml/qml_tests_main.cpp
index b4fcc94264..eb0cf8a1e2 100644
--- a/test/qml/qml_tests_main.cpp
+++ b/test/qml/qml_tests_main.cpp
@@ -734,8 +734,9 @@ class MockWalletController : public QObject
m_selected_wallet = wallet;
Q_EMIT selectedWalletChanged();
}
- Q_INVOKABLE void setSelectedWallet(const QString& name)
+ Q_INVOKABLE void setSelectedWallet(const QString& name, const QString& format = QString())
{
+ Q_UNUSED(format);
if (m_last_selected_wallet_name == name) return;
m_last_selected_wallet_name = name;
Q_EMIT lastSelectedWalletNameChanged();
@@ -961,7 +962,8 @@ class MockWalletListModel : public QAbstractListModel
public:
enum Roles {
- NameRole = Qt::UserRole + 1
+ NameRole = Qt::UserRole + 1,
+ FormatRole
};
int rowCount(const QModelIndex& parent = QModelIndex{}) const override
@@ -974,12 +976,16 @@ class MockWalletListModel : public QAbstractListModel
{
if (!index.isValid() || index.row() < 0 || index.row() >= m_wallet_names.size()) return {};
if (role == NameRole) return m_wallet_names.at(index.row());
+ if (role == FormatRole) return QStringLiteral("sqlite");
return {};
}
QHash roleNames() const override
{
- return {{NameRole, "name"}};
+ return {
+ {NameRole, "name"},
+ {FormatRole, "format"},
+ };
}
Q_INVOKABLE void listWalletDir() {}
diff --git a/test/test_walletqmlcontroller.cpp b/test/test_walletqmlcontroller.cpp
index 983e5f9290..2ed9df6c25 100644
--- a/test/test_walletqmlcontroller.cpp
+++ b/test/test_walletqmlcontroller.cpp
@@ -7,7 +7,10 @@
#include
#include
+#include
+#include
#include
+#include
#include
#include
@@ -35,6 +38,88 @@ std::vector> MakeSigners(std::initia
}
return signers;
}
+
+std::unique_ptr MakeNoopHandler()
+{
+ return interfaces::MakeCleanupHandler([] {});
+}
+
+class FakeWalletLoader : public interfaces::WalletLoader
+{
+public:
+ int handle_load_wallet_calls{0};
+ int get_wallets_calls{0};
+ int list_wallet_dir_calls{0};
+ int load_wallet_calls{0};
+
+ std::function>()> get_wallets_fn = [] {
+ return std::vector>{};
+ };
+ std::vector> wallet_dir_entries;
+
+ void registerRpcs() override {}
+ bool verify() override { return true; }
+ bool load() override { return true; }
+ void start(CScheduler&) override {}
+ void stop() override {}
+ void setMockTime(int64_t) override {}
+ void schedulerMockForward(std::chrono::seconds) override {}
+ util::Result> createWallet(
+ const std::string&,
+ const SecureString&,
+ uint64_t,
+ std::vector&) override
+ {
+ return util::Error{Untranslated("Unexpected createWallet call")};
+ }
+ util::Result> loadWallet(const std::string&, std::vector&) override
+ {
+ ++load_wallet_calls;
+ return util::Error{Untranslated("Unexpected loadWallet call")};
+ }
+ std::string getWalletDir() override { return {}; }
+ util::Result> restoreWallet(const fs::path&, const std::string&, std::vector&) override
+ {
+ return util::Error{Untranslated("Unexpected restoreWallet call")};
+ }
+ util::Result migrateWallet(const std::string&, const SecureString&) override
+ {
+ return util::Error{Untranslated("Unexpected migrateWallet call")};
+ }
+ bool isEncrypted(const std::string&) override { return false; }
+ std::vector> listWalletDir() override
+ {
+ ++list_wallet_dir_calls;
+ return wallet_dir_entries;
+ }
+ std::vector> getWallets() override
+ {
+ ++get_wallets_calls;
+ return get_wallets_fn();
+ }
+ std::unique_ptr handleLoadWallet(LoadWalletFn) override
+ {
+ ++handle_load_wallet_calls;
+ return MakeNoopHandler();
+ }
+};
+
+void ExpectControllerInitialization(MockNode& node, FakeWalletLoader& loader)
+{
+ using ::testing::_;
+ using ::testing::AtLeast;
+ using ::testing::Invoke;
+ using ::testing::Return;
+ using ::testing::ReturnRef;
+
+ ON_CALL(node, walletLoader()).WillByDefault(ReturnRef(loader));
+ EXPECT_CALL(node, walletLoader()).Times(AtLeast(3)).WillRepeatedly(ReturnRef(loader));
+ EXPECT_CALL(node, getPersistentSetting(_)).WillOnce(Return(common::SettingsValue{}));
+ EXPECT_CALL(node, forceSetting(_, _)).Times(1);
+ EXPECT_CALL(node, listExternalSigners()).WillOnce(Invoke([] {
+ return std::vector>{};
+ }));
+}
} // namespace
class WalletQmlControllerTests : public QObject
@@ -45,6 +130,7 @@ private Q_SLOTS:
void externalSignerCreationRequiresConfiguredPath();
void externalSignerCreationRequiresExactlyOneSigner();
void externalSignerSuggestionUsesSignerName();
+ void initializedControllerSignalsMigrationForLegacyWallet();
};
void WalletQmlControllerTests::externalSignerCreationRequiresConfiguredPath()
@@ -111,6 +197,28 @@ void WalletQmlControllerTests::externalSignerSuggestionUsesSignerName()
QCOMPARE(controller.suggestedExternalSignerWalletName(), QString("Coldcard_Mk4"));
}
+void WalletQmlControllerTests::initializedControllerSignalsMigrationForLegacyWallet()
+{
+ using ::testing::StrictMock;
+
+ StrictMock node;
+ FakeWalletLoader loader;
+ loader.wallet_dir_entries = {{"legacy_wallet", "bdb"}};
+ ExpectControllerInitialization(node, loader);
+
+ WalletQmlController controller(node);
+ controller.initialize();
+
+ QSignalSpy migration_spy(&controller, &WalletQmlController::walletMigrationRequired);
+
+ controller.setSelectedWallet("legacy_wallet", "bdb");
+ QCOMPARE(migration_spy.count(), 1);
+ QCOMPARE(migration_spy.takeFirst().at(0).toString(), QString{"legacy_wallet"});
+ QCOMPARE(loader.load_wallet_calls, 0);
+ QVERIFY(!controller.walletLoadInProgress());
+ QVERIFY(controller.walletLoadError().isEmpty());
+}
+
#ifdef BITCOINQML_NO_TEST_MAIN
#include
BITCOINQML_REGISTER_QT_TEST(WalletQmlControllerTests)