diff --git a/.github/workflows/gui-functional-tests.yml b/.github/workflows/gui-functional-tests.yml
index 7fee1ac3f8..ed3baaa87f 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,9 @@ 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_password_wallet.py
+ python3 test/functional/qml_test_send_receive.py
+ python3 test/functional/qml_test_send_review.py
+ python3 test/functional/qml_test_wallet_migration.py
+ python3 test/functional/qml_test_wallet_rbf.py
+ python3 test/functional/qml_test_wallet_restore.py
diff --git a/qml/bitcoin_qml.qrc b/qml/bitcoin_qml.qrc
index 4945da9d1a..31d74d461c 100644
--- a/qml/bitcoin_qml.qrc
+++ b/qml/bitcoin_qml.qrc
@@ -30,6 +30,8 @@
components/Tooltip.qml
components/LabeledValueField.qml
components/WalletSettings.qml
+ components/WalletMigrationPopup.qml
+ components/WalletPassphrasePopup.qml
controls/AddWalletButton.qml
controls/CaretRightIcon.qml
controls/ContinueButton.qml
@@ -107,6 +109,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/components/WalletMigrationPopup.qml b/qml/components/WalletMigrationPopup.qml
new file mode 100644
index 0000000000..e31c317aa0
--- /dev/null
+++ b/qml/components/WalletMigrationPopup.qml
@@ -0,0 +1,107 @@
+// 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"
+
+Popup {
+ id: root
+
+ property string popupObjectName: ""
+ property string titleText: qsTr("Wallet update required")
+ property string descriptionText: ""
+ property string confirmText: qsTr("Update wallet")
+ property string busyConfirmText: qsTr("Updating...")
+ property string errorText: ""
+ property string errorTextObjectName: ""
+ property string cancelButtonObjectName: ""
+ property string confirmButtonObjectName: ""
+ property bool busy: false
+
+ signal confirmed()
+
+ objectName: popupObjectName
+ modal: true
+ padding: 0
+ implicitWidth: 420
+ implicitHeight: columnLayout.implicitHeight
+ anchors.centerIn: parent
+
+ background: Rectangle {
+ color: Theme.color.background
+ radius: 10
+ border.color: Theme.color.neutral4
+ border.width: 1
+ }
+
+ ColumnLayout {
+ id: columnLayout
+ anchors.fill: parent
+ spacing: 0
+
+ CoreText {
+ Layout.fillWidth: true
+ Layout.preferredHeight: 56
+ text: root.titleText
+ bold: true
+ font.pixelSize: 24
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ Separator {
+ Layout.fillWidth: true
+ }
+
+ Header {
+ Layout.fillWidth: true
+ Layout.margins: 20
+ Layout.topMargin: 20
+ header: root.descriptionText
+ headerBold: false
+ headerSize: 16
+ }
+
+ CoreText {
+ objectName: root.errorTextObjectName
+ Layout.fillWidth: true
+ Layout.leftMargin: 20
+ Layout.rightMargin: 20
+ Layout.topMargin: 4
+ visible: text.length > 0
+ text: root.errorText
+ color: Theme.color.red
+ font.pixelSize: 15
+ wrapMode: Text.WordWrap
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ Layout.margins: 20
+ Layout.topMargin: 20
+ spacing: 15
+
+ OutlineButton {
+ objectName: root.cancelButtonObjectName
+ Layout.fillWidth: true
+ Layout.minimumWidth: 120
+ enabled: !root.busy
+ text: qsTr("Cancel")
+ onClicked: root.close()
+ }
+
+ ContinueButton {
+ objectName: root.confirmButtonObjectName
+ Layout.fillWidth: true
+ Layout.minimumWidth: 120
+ enabled: !root.busy
+ text: root.busy ? root.busyConfirmText : root.confirmText
+ onClicked: root.confirmed()
+ }
+ }
+ }
+}
diff --git a/qml/components/WalletPassphrasePopup.qml b/qml/components/WalletPassphrasePopup.qml
new file mode 100644
index 0000000000..3539d95f4e
--- /dev/null
+++ b/qml/components/WalletPassphrasePopup.qml
@@ -0,0 +1,123 @@
+// 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"
+
+Popup {
+ id: root
+
+ property string popupObjectName: ""
+ property string titleText: qsTr("Enter wallet password")
+ property string descriptionText: ""
+ property string confirmText: qsTr("Continue")
+ property string busyConfirmText: qsTr("Working...")
+ property string errorText: ""
+ property string passphraseFieldObjectName: ""
+ property string errorTextObjectName: ""
+ property string cancelButtonObjectName: ""
+ property string confirmButtonObjectName: ""
+ property bool busy: false
+
+ signal submitted(string passphrase)
+
+ objectName: popupObjectName
+ modal: true
+ padding: 0
+ implicitWidth: 420
+ implicitHeight: columnLayout.implicitHeight
+ anchors.centerIn: parent
+
+ onOpened: {
+ passphraseField.text = ""
+ passphraseField.forceActiveFocus()
+ }
+
+ background: Rectangle {
+ color: Theme.color.background
+ radius: 10
+ border.color: Theme.color.neutral4
+ border.width: 1
+ }
+
+ ColumnLayout {
+ id: columnLayout
+ anchors.fill: parent
+ spacing: 0
+
+ CoreText {
+ Layout.fillWidth: true
+ Layout.preferredHeight: 56
+ text: root.titleText
+ bold: true
+ font.pixelSize: 24
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ Separator {
+ Layout.fillWidth: true
+ }
+
+ Header {
+ Layout.fillWidth: true
+ Layout.margins: 20
+ Layout.topMargin: 20
+ header: root.descriptionText
+ headerBold: false
+ headerSize: 16
+ }
+
+ CoreTextField {
+ id: passphraseField
+ objectName: root.passphraseFieldObjectName
+ Layout.fillWidth: true
+ Layout.leftMargin: 20
+ Layout.rightMargin: 20
+ hideText: true
+ placeholderText: qsTr("Enter password...")
+ }
+
+ CoreText {
+ objectName: root.errorTextObjectName
+ Layout.fillWidth: true
+ Layout.leftMargin: 20
+ Layout.rightMargin: 20
+ Layout.topMargin: 12
+ visible: text.length > 0
+ text: root.errorText
+ color: Theme.color.red
+ font.pixelSize: 15
+ wrapMode: Text.WordWrap
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ Layout.margins: 20
+ Layout.topMargin: 20
+ spacing: 15
+
+ OutlineButton {
+ objectName: root.cancelButtonObjectName
+ Layout.fillWidth: true
+ Layout.minimumWidth: 120
+ enabled: !root.busy
+ text: qsTr("Cancel")
+ onClicked: root.close()
+ }
+
+ ContinueButton {
+ objectName: root.confirmButtonObjectName
+ Layout.fillWidth: true
+ Layout.minimumWidth: 120
+ enabled: !root.busy && passphraseField.text.length > 0
+ text: root.busy ? root.busyConfirmText : root.confirmText
+ onClicked: root.submitted(passphraseField.text)
+ }
+ }
+ }
+}
diff --git a/qml/models/walletqmlmodel.cpp b/qml/models/walletqmlmodel.cpp
index b89c5426bb..1f9a86bd8c 100644
--- a/qml/models/walletqmlmodel.cpp
+++ b/qml/models/walletqmlmodel.cpp
@@ -5,6 +5,7 @@
#include
+#include
#include
#include
#include
@@ -13,7 +14,6 @@
#include
#include
-#include
#include
#include
#include
@@ -24,6 +24,8 @@
#include
#include
#include
+#include
+#include
#include
#include
#include
@@ -246,6 +248,12 @@ struct QmlRecentRequestEntry
SER_READ(obj, obj.date = QDateTime::fromSecsSinceEpoch(date_timet));
}
};
+
+QString LocalizedString(const bilingual_str& value)
+{
+ return QString::fromStdString(value.translated.empty() ? value.original : value.translated);
+}
+
} // namespace
WalletQmlModel::WalletQmlModel(std::unique_ptr wallet, QObject *parent)
@@ -258,16 +266,8 @@ WalletQmlModel::WalletQmlModel(std::unique_ptr wallet, QObje
m_send_recipients = new SendRecipientsListModel(this);
m_current_payment_request = new PaymentRequest(this);
initializeFeeEstimator();
- m_handler_status_changed = handleStatusChanged([this] {
- QMetaObject::invokeMethod(this, [this] {
- Q_EMIT balanceChanged();
- });
- });
- m_handler_transaction_changed = handleTransactionChanged([this](const uint256&, ChangeType) {
- QMetaObject::invokeMethod(this, [this] {
- Q_EMIT balanceChanged();
- });
- });
+ refreshSecurityState();
+ subscribeToWalletSignals();
}
WalletQmlModel::WalletQmlModel(QObject* parent)
@@ -283,6 +283,7 @@ WalletQmlModel::WalletQmlModel(QObject* parent)
WalletQmlModel::~WalletQmlModel()
{
+ unsubscribeFromWalletSignals();
if (m_fee_estimation_timer) {
m_fee_estimation_timer->stop();
}
@@ -668,7 +669,29 @@ std::unique_ptr WalletQmlModel::handleStatusChanged(StatusC
bool WalletQmlModel::prepareTransaction()
{
+ return prepareTransactionInternal(std::nullopt);
+}
+
+bool WalletQmlModel::prepareTransactionWithPassphrase(const QString& passphrase)
+{
+ return prepareTransactionInternal(passphrase);
+}
+
+bool WalletQmlModel::prepareTransactionInternal(const std::optional& passphrase)
+{
+ clearTransactionStatus();
if (!m_wallet || !m_send_recipients || m_send_recipients->recipients().empty()) {
+ setTransactionStatus(tr("Enter at least one valid recipient to continue."));
+ return false;
+ }
+ if (!m_wallet->privateKeysDisabled() && m_wallet->isCrypted() && m_wallet->isLocked() && !passphrase.has_value()) {
+ refreshSecurityState();
+ setTransactionStatus(tr("Enter your wallet password to prepare this transaction."), true);
+ return false;
+ }
+
+ bool relock{false};
+ if (!unlockForAction(passphrase, relock)) {
return false;
}
@@ -704,6 +727,11 @@ bool WalletQmlModel::prepareTransaction()
CAmount balance = m_wallet->getBalance();
if (balance < total) {
+ if (relock) {
+ m_wallet->lock();
+ refreshSecurityState();
+ }
+ setTransactionStatus(tr("The wallet does not have enough balance for this transaction."));
return false;
}
@@ -723,11 +751,20 @@ bool WalletQmlModel::prepareTransaction()
m_current_transaction->reassignAmounts(nChangePosRet);
}
m_current_transaction->setDisplayUnit(m_display_unit);
+ if (relock) {
+ m_wallet->lock();
+ refreshSecurityState();
+ }
Q_EMIT currentTransactionChanged();
return true;
- } else {
- return false;
}
+
+ if (relock) {
+ m_wallet->lock();
+ refreshSecurityState();
+ }
+ setTransactionStatus(LocalizedString(util::ErrorString(result)));
+ return false;
}
void WalletQmlModel::approveExternalSignerTransaction()
@@ -793,20 +830,36 @@ void WalletQmlModel::approveExternalSignerTransaction()
}
}
-void WalletQmlModel::sendTransaction()
+bool WalletQmlModel::sendTransaction()
{
+ return sendTransactionInternal();
+}
+
+bool WalletQmlModel::sendTransactionInternal()
+{
+ clearTransactionStatus();
if (!m_wallet || !m_current_transaction) {
- return;
+ setTransactionStatus(tr("Review a transaction before sending it."));
+ return false;
}
- CTransactionRef newTx = m_current_transaction->getWtx();
- if (!newTx) {
- return;
+ CTransactionRef signed_tx = m_current_transaction->getWtx();
+ if (!signed_tx) {
+ setTransactionStatus(tr("Review a transaction before sending it."));
+ return false;
+ }
+
+ if (m_wallet->privateKeysDisabled() && !m_wallet->hasExternalSigner()) {
+ setTransactionStatus(tr("This wallet cannot sign transactions."));
+ return false;
}
interfaces::WalletValueMap value_map;
interfaces::WalletOrderForm order_form;
- m_wallet->commitTransaction(newTx, value_map, order_form);
+ m_wallet->commitTransaction(signed_tx, value_map, order_form);
+
+ clearTransactionStatus();
+ return true;
}
bool WalletQmlModel::canBumpTransaction(const uint256& txid) const
@@ -938,3 +991,80 @@ void WalletQmlModel::setDisplayUnit(int unit)
Q_EMIT displayUnitChanged(unit);
}
}
+
+void WalletQmlModel::subscribeToWalletSignals()
+{
+ if (!m_wallet) {
+ return;
+ }
+ m_handler_status_changed = handleStatusChanged([this]() {
+ QMetaObject::invokeMethod(this, [this]() {
+ refreshSecurityState();
+ Q_EMIT balanceChanged();
+ }, Qt::QueuedConnection);
+ });
+ m_handler_transaction_changed = handleTransactionChanged([this](const uint256&, ChangeType) {
+ QMetaObject::invokeMethod(this, [this] {
+ Q_EMIT balanceChanged();
+ }, Qt::QueuedConnection);
+ });
+}
+
+void WalletQmlModel::unsubscribeFromWalletSignals()
+{
+ if (m_handler_status_changed) {
+ m_handler_status_changed->disconnect();
+ }
+ if (m_handler_transaction_changed) {
+ m_handler_transaction_changed->disconnect();
+ }
+}
+
+void WalletQmlModel::refreshSecurityState()
+{
+ const bool encrypted = m_wallet ? m_wallet->isCrypted() : false;
+ const bool locked = m_wallet ? m_wallet->isLocked() : false;
+ if (m_is_encrypted != encrypted || m_is_locked != locked) {
+ m_is_encrypted = encrypted;
+ m_is_locked = locked;
+ Q_EMIT securityStateChanged();
+ }
+}
+
+bool WalletQmlModel::unlockForAction(const std::optional& passphrase, bool& relock)
+{
+ relock = false;
+ if (!m_wallet || !m_wallet->isCrypted() || !m_wallet->isLocked()) {
+ return true;
+ }
+ if (!passphrase.has_value()) {
+ return true;
+ }
+
+ const SecureString secure_passphrase{passphrase->toStdString()};
+ if (!m_wallet->unlock(secure_passphrase)) {
+ setTransactionStatus(tr("The wallet password you entered was incorrect."));
+ return false;
+ }
+
+ relock = true;
+ refreshSecurityState();
+ return true;
+}
+
+void WalletQmlModel::clearTransactionStatus()
+{
+ setTransactionStatus(QString());
+}
+
+void WalletQmlModel::setTransactionStatus(const QString& error, bool needs_unlock)
+{
+ if (m_transaction_error != error) {
+ m_transaction_error = error;
+ Q_EMIT transactionErrorChanged();
+ }
+ if (m_transaction_needs_unlock != needs_unlock) {
+ m_transaction_needs_unlock = needs_unlock;
+ Q_EMIT transactionNeedsUnlockChanged();
+ }
+}
diff --git a/qml/models/walletqmlmodel.h b/qml/models/walletqmlmodel.h
index daf1d5269e..45951f93b5 100644
--- a/qml/models/walletqmlmodel.h
+++ b/qml/models/walletqmlmodel.h
@@ -18,6 +18,7 @@
#include
#include
+#include
#include
#include
@@ -47,6 +48,10 @@ class WalletQmlModel : public QObject
Q_PROPERTY(BumpTransactionModel* bumpModel READ bumpModel CONSTANT)
Q_PROPERTY(bool isWalletLoaded READ isWalletLoaded NOTIFY walletIsLoadedChanged)
Q_PROPERTY(int displayUnit READ displayUnit WRITE setDisplayUnit NOTIFY displayUnitChanged)
+ Q_PROPERTY(bool isEncrypted READ isEncrypted NOTIFY securityStateChanged)
+ Q_PROPERTY(bool isLocked READ isLocked NOTIFY securityStateChanged)
+ Q_PROPERTY(QString transactionError READ transactionError NOTIFY transactionErrorChanged)
+ Q_PROPERTY(bool transactionNeedsUnlock READ transactionNeedsUnlock NOTIFY transactionNeedsUnlockChanged)
public:
WalletQmlModel(std::unique_ptr wallet, QObject* parent = nullptr);
@@ -73,7 +78,8 @@ class WalletQmlModel : public QObject
int feeEstimateRevision() const { return m_fee_estimate_revision; }
Q_INVOKABLE bool prepareTransaction();
Q_INVOKABLE void approveExternalSignerTransaction();
- Q_INVOKABLE void sendTransaction();
+ Q_INVOKABLE bool prepareTransactionWithPassphrase(const QString& passphrase);
+ Q_INVOKABLE bool sendTransaction();
Q_INVOKABLE QString newAddress(QString label);
Q_INVOKABLE QString estimatedFeeForTarget(unsigned int target_blocks) const;
Q_INVOKABLE int feeTargetIndex(unsigned int target_blocks) const;
@@ -112,6 +118,10 @@ class WalletQmlModel : public QObject
void setWalletLoaded(bool loaded);
int displayUnit() const { return m_display_unit; }
void setDisplayUnit(int unit);
+ bool isEncrypted() const { return m_is_encrypted; }
+ bool isLocked() const { return m_is_locked; }
+ QString transactionError() const { return m_transaction_error; }
+ bool transactionNeedsUnlock() const { return m_transaction_needs_unlock; }
Q_SIGNALS:
void nameChanged();
@@ -128,6 +138,9 @@ class WalletQmlModel : public QObject
void externalSignerApprovalSucceeded();
void externalSignerApprovalFailed(const QString& message, bool signerNotFound);
void displayUnitChanged(int unit);
+ void securityStateChanged();
+ void transactionErrorChanged();
+ void transactionNeedsUnlockChanged();
private:
void initializeFeeEstimator();
@@ -138,6 +151,14 @@ class WalletQmlModel : public QObject
void clearFeeEstimates();
QString ensurePreviewChangeAddress();
unsigned int nextPaymentRequestId() const;
+ void subscribeToWalletSignals();
+ void unsubscribeFromWalletSignals();
+ void refreshSecurityState();
+ bool prepareTransactionInternal(const std::optional& passphrase);
+ bool sendTransactionInternal();
+ bool unlockForAction(const std::optional& passphrase, bool& relock);
+ void clearTransactionStatus();
+ void setTransactionStatus(const QString& error, bool needs_unlock = false);
std::unique_ptr m_wallet;
ActivityListModel* m_activity_list_model{nullptr};
@@ -159,6 +180,10 @@ class WalletQmlModel : public QObject
bool m_custom_fee_enabled{false};
bool m_fee_estimate_pending{false};
bool m_is_wallet_loaded{false};
+ bool m_is_encrypted{false};
+ bool m_is_locked{false};
+ QString m_transaction_error;
+ bool m_transaction_needs_unlock{false};
std::unique_ptr m_handler_status_changed;
std::unique_ptr m_handler_transaction_changed;
int m_display_unit{0};
diff --git a/qml/pages/main.qml b/qml/pages/main.qml
index ca5dc6d385..955aaa0d2d 100644
--- a/qml/pages/main.qml
+++ b/qml/pages/main.qml
@@ -115,12 +115,15 @@ ApplicationWindow {
}
onTransactionSent: {
const externalSignerWallet = walletController.selectedWallet.hasExternalSigner
- sendResult.descriptionText = externalSignerWallet
+ const descriptionText = externalSignerWallet
? qsTr("Approved on external signer. It should be confirmed within the next 10 minutes.")
: qsTr("Based on your selected fee, it should be confirmed within the next 10 minutes.")
- sendResult.actionText = externalSignerWallet ? qsTr("Done") : qsTr("Close window")
+ const actionText = externalSignerWallet ? qsTr("Done") : qsTr("Close window")
walletController.selectedWallet.recipients.clear()
- main.push(sendResultPage)
+ main.push(sendResultPage, {
+ "descriptionText": descriptionText,
+ "actionText": actionText
+ })
}
}
}
@@ -133,12 +136,15 @@ ApplicationWindow {
}
onTransactionSent: {
const externalSignerWallet = walletController.selectedWallet.hasExternalSigner
- sendResult.descriptionText = externalSignerWallet
+ const descriptionText = externalSignerWallet
? qsTr("Approved on external signer. It should be confirmed within the next 10 minutes.")
: qsTr("Based on your selected fee, it should be confirmed within the next 10 minutes.")
- sendResult.actionText = externalSignerWallet ? qsTr("Done") : qsTr("Close window")
+ const actionText = externalSignerWallet ? qsTr("Done") : qsTr("Close window")
walletController.selectedWallet.recipients.clear()
- main.push(sendResultPage)
+ main.push(sendResultPage, {
+ "descriptionText": descriptionText,
+ "actionText": actionText
+ })
}
}
}
@@ -160,6 +166,12 @@ ApplicationWindow {
Shutdown {}
}
+ Component.onCompleted: {
+ if (!needOnboarding && AppMode.walletEnabled && AppMode.isDesktop) {
+ nodeModel.startNodeInitializionThread()
+ }
+ }
+
Component {
id: node
PageStack {
diff --git a/qml/pages/wallet/Activity.qml b/qml/pages/wallet/Activity.qml
index 448c0b9b1a..00c26cbd92 100644
--- a/qml/pages/wallet/Activity.qml
+++ b/qml/pages/wallet/Activity.qml
@@ -51,6 +51,7 @@ PageStack {
header: CoreText {
id: title
+ objectName: "walletActivityTitle"
horizontalAlignment: Text.AlignLeft
text: qsTr("Activity")
font.pixelSize: 21
diff --git a/qml/pages/wallet/CreatePassword.qml b/qml/pages/wallet/CreatePassword.qml
index 4d3043db88..1fcaecd352 100644
--- a/qml/pages/wallet/CreatePassword.qml
+++ b/qml/pages/wallet/CreatePassword.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.
@@ -19,7 +19,7 @@ Page {
required property string walletName;
- Component.onCompleted: walletController.clearWalletLoadStatus()
+ Component.onCompleted: walletController.clearWalletCreateStatus()
header: NavigationBar2 {
navigationStack: root.StackView.view
@@ -28,7 +28,6 @@ Page {
text: qsTr("Skip")
enabled: walletController.initialized
onClicked: {
- walletController.clearWalletLoadStatus()
if (walletController.createSingleSigWallet(walletName, "")) {
root.next()
}
@@ -69,7 +68,7 @@ Page {
focus: true
hideText: true
placeholderText: qsTr("Enter password...")
- onTextChanged: walletController.clearWalletLoadStatus()
+ onTextChanged: walletController.clearWalletCreateStatus()
}
CoreText {
Layout.topMargin: 20
@@ -87,11 +86,12 @@ Page {
Layout.rightMargin: 20
hideText: true
placeholderText: qsTr("Enter password again...")
- onTextChanged: walletController.clearWalletLoadStatus()
+ onTextChanged: walletController.clearWalletCreateStatus()
}
Setting {
id: confirmToggle
+ objectName: "createWalletPasswordConfirmToggle"
Layout.fillWidth: true
Layout.leftMargin: 20
Layout.rightMargin: 20
@@ -104,15 +104,6 @@ Page {
}
}
- CoreText {
- Layout.leftMargin: 20
- Layout.rightMargin: 20
- visible: walletController.walletLoadError.length > 0
- color: Theme.color.red
- wrapMode: Text.WordWrap
- text: walletController.walletLoadError
- }
-
ContinueButton {
objectName: "createWalletPasswordContinueButton"
Layout.preferredWidth: Math.min(300, parent.width - 2 * Layout.leftMargin)
@@ -127,11 +118,22 @@ Page {
password.text == passwordRepeat.text &&
confirmToggle.loadedItem.checked
onClicked: {
- walletController.clearWalletLoadStatus()
if (walletController.createSingleSigWallet(walletName, password.text)) {
root.next()
}
}
}
+
+ CoreText {
+ objectName: "createWalletPasswordErrorText"
+ Layout.fillWidth: true
+ Layout.leftMargin: 20
+ Layout.rightMargin: 20
+ visible: text.length > 0
+ text: walletController.walletCreateError
+ color: Theme.color.red
+ font.pixelSize: 15
+ wrapMode: Text.WordWrap
+ }
}
}
diff --git a/qml/pages/wallet/CreateWalletWizard.qml b/qml/pages/wallet/CreateWalletWizard.qml
index 73300fe76a..d117f631b6 100644
--- a/qml/pages/wallet/CreateWalletWizard.qml
+++ b/qml/pages/wallet/CreateWalletWizard.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.
@@ -88,6 +88,7 @@ PageStack {
Layout.rightMargin: Layout.leftMargin
Layout.bottomMargin: 20
Layout.alignment: Qt.AlignCenter
+ enabled: walletController.initialized
text: qsTr("Create wallet")
onClicked: {
root.push(intro)
@@ -100,6 +101,7 @@ PageStack {
Layout.leftMargin: 20
Layout.rightMargin: Layout.leftMargin
Layout.alignment: Qt.AlignCenter
+ enabled: walletController.initialized
text: qsTr("Import wallet")
borderColor: Theme.color.neutral6
borderHoverColor: Theme.color.orangeLight1
diff --git a/qml/pages/wallet/DesktopWallets.qml b/qml/pages/wallet/DesktopWallets.qml
index 4f4dbf72af..1d638d8508 100644
--- a/qml/pages/wallet/DesktopWallets.qml
+++ b/qml/pages/wallet/DesktopWallets.qml
@@ -17,17 +17,76 @@ Page {
id: root
background: null
+ property string pendingMigrationPath: ""
+
ButtonGroup { id: navigationTabs }
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
+ }
+
+ walletListModel.listWalletDir()
+ if (walletController.noWalletsFound) {
+ root.addWallet()
+ } else {
+ walletSelect.opened ? walletSelect.close() : walletSelect.open()
+ }
+ }
+
+ Component {
+ id: walletMigrationPage
+ ImportWalletMigration {
+ onBack: root.StackView.view.pop()
+ onNext: root.StackView.view.pop()
+ }
+ }
+
Connections {
target: walletController
function onOpenWalletSettingsRequested() {
settingsTabButton.checked = true
nodeSettings.openWalletSettings()
}
+ function onWalletMigrationRequired(path) {
+ root.pendingMigrationPath = path
+ migrationRequiredPopup.errorText = ""
+ migrationRequiredPopup.open()
+ }
+ function onWalletMigrationSucceeded() {
+ root.pendingMigrationPath = ""
+ migrationRequiredPopup.close()
+ migrationPassphrasePopup.close()
+ }
+ function onWalletMigrationFailed() {
+ if (root.pendingMigrationPath.length === 0) {
+ return
+ }
+ if (walletController.walletMigrationError.toLowerCase().indexOf("passphrase") !== -1) {
+ const showPassphraseError = migrationPassphrasePopup.opened
+ migrationRequiredPopup.close()
+ migrationPassphrasePopup.busy = false
+ migrationPassphrasePopup.errorText = showPassphraseError ? walletController.walletMigrationError : ""
+ migrationPassphrasePopup.open()
+ } else {
+ migrationRequiredPopup.busy = false
+ migrationRequiredPopup.errorText = walletController.walletMigrationError
+ migrationRequiredPopup.open()
+ }
+ }
}
header: NavigationBar2 {
@@ -42,17 +101,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
@@ -174,5 +223,39 @@ Page {
}
}
- Component.onCompleted: nodeModel.startNodeInitializionThread();
+ WalletMigrationPopup {
+ id: migrationRequiredPopup
+ parent: Overlay.overlay
+ width: Math.min(420, root.width - 40)
+ popupObjectName: "walletMigrationPopup"
+ errorTextObjectName: "walletMigrationErrorText"
+ cancelButtonObjectName: "walletMigrationCancelButton"
+ confirmButtonObjectName: "walletMigrationConfirmButton"
+ descriptionText: qsTr("This wallet uses a legacy format and needs to be updated before it can be opened.")
+ busy: walletController.walletMigrationInProgress
+ onConfirmed: {
+ migrationRequiredPopup.errorText = ""
+ migrationRequiredPopup.close()
+ walletController.migrateWallet(root.pendingMigrationPath, "")
+ }
+ }
+
+ WalletPassphrasePopup {
+ id: migrationPassphrasePopup
+ parent: Overlay.overlay
+ width: Math.min(420, root.width - 40)
+ popupObjectName: "walletMigrationPassphrasePopup"
+ passphraseFieldObjectName: "walletMigrationPassphraseField"
+ errorTextObjectName: "walletMigrationPassphraseErrorText"
+ cancelButtonObjectName: "walletMigrationPassphraseCancelButton"
+ confirmButtonObjectName: "walletMigrationPassphraseConfirmButton"
+ titleText: qsTr("Enter wallet password")
+ descriptionText: qsTr("Enter the wallet password to complete the legacy wallet update.")
+ confirmText: qsTr("Unlock and update")
+ busyConfirmText: qsTr("Updating...")
+ onSubmitted: (passphrase) => {
+ migrationPassphrasePopup.busy = true
+ walletController.migrateWallet(root.pendingMigrationPath, passphrase)
+ }
+ }
}
diff --git a/qml/pages/wallet/ImportWalletMigration.qml b/qml/pages/wallet/ImportWalletMigration.qml
new file mode 100644
index 0000000000..75aa0f1749
--- /dev/null
+++ b/qml/pages/wallet/ImportWalletMigration.qml
@@ -0,0 +1,151 @@
+// 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 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()
+ }
+ }
+ }
+
+ 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("Wallet successfully migrated")
+ }
+ 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("Done")
+ }
+ 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.back()
+ return
+ }
+ root.startMigration()
+ }
+ }
+ }
+}
diff --git a/qml/pages/wallet/ImportWalletOptions.qml b/qml/pages/wallet/ImportWalletOptions.qml
index 271c37c205..cf88c5cdd0 100644
--- a/qml/pages/wallet/ImportWalletOptions.qml
+++ b/qml/pages/wallet/ImportWalletOptions.qml
@@ -115,7 +115,7 @@ Page {
Layout.leftMargin: 20
Layout.rightMargin: 20
Layout.alignment: Qt.AlignCenter
- enabled: !walletController.walletLoadInProgress
+ enabled: walletController.initialized && !walletController.walletLoadInProgress
text: walletController.walletLoadInProgress ? qsTr("Importing...") : qsTr("Choose a wallet file")
onClicked: {
if (automationPathField.text.length > 0) {
diff --git a/qml/pages/wallet/MultipleSendReview.qml b/qml/pages/wallet/MultipleSendReview.qml
index 6e96e178dd..8861ad87de 100644
--- a/qml/pages/wallet/MultipleSendReview.qml
+++ b/qml/pages/wallet/MultipleSendReview.qml
@@ -17,6 +17,7 @@ Page {
property WalletQmlModel wallet: walletController.selectedWallet
property WalletQmlModelTransaction transaction: walletController.selectedWallet.currentTransaction
+ property bool sending: false
readonly property int recipientCount: root.wallet ? root.wallet.recipients.count : 0
readonly property string recipientCountText: recipientCount === 1
? qsTr("There is 1 recipient.")
@@ -264,23 +265,44 @@ Page {
Layout.fillWidth: true
Layout.topMargin: 30
onSendRequested: {
- root.wallet.sendTransaction()
- root.transactionSent()
+ if (root.sending) {
+ return
+ }
+ if (root.wallet.sendTransaction()) {
+ root.sending = true
+ root.transactionSent()
+ }
}
}
ContinueButton {
id: confirmationButton
- objectName: "multipleSendReviewSendButton"
+ objectName: "sendReviewSendButton"
visible: !root.wallet || !root.wallet.hasExternalSigner
+ enabled: !root.sending
Layout.fillWidth: true
Layout.topMargin: 30
text: qsTr("Send")
onClicked: {
- root.wallet.sendTransaction()
- root.transactionSent()
+ if (root.sending) {
+ return
+ }
+ if (root.wallet.sendTransaction()) {
+ root.sending = true
+ root.transactionSent()
+ }
}
}
+
+ CoreText {
+ objectName: "sendReviewErrorText"
+ Layout.fillWidth: true
+ visible: text.length > 0
+ text: root.wallet.transactionError
+ color: Theme.color.red
+ font.pixelSize: 15
+ wrapMode: Text.WordWrap
+ }
}
}
}
diff --git a/qml/pages/wallet/Send.qml b/qml/pages/wallet/Send.qml
index e202dbb438..6459234508 100644
--- a/qml/pages/wallet/Send.qml
+++ b/qml/pages/wallet/Send.qml
@@ -127,6 +127,7 @@ PageStack {
CoreText {
id: title
+ objectName: "walletSendTitle"
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: qsTr("Send bitcoin")
@@ -291,7 +292,9 @@ PageStack {
text: root.recipient.amount.display
onTextChanged: {
root.clearPrepareTransactionError()
- root.recipient.amount.display = text
+ if (text !== root.recipient.amount.display) {
+ root.recipient.amount.display = text
+ }
root.scheduleFeeEstimates()
}
onTextEdited: root.recipient.amount.display = text
@@ -480,9 +483,14 @@ PageStack {
onClicked: {
root.clearPrepareTransactionError()
if (root.wallet.prepareTransaction()) {
- root.transactionPrepared(settings.multipleRecipientsEnabled);
+ root.transactionPrepared(settings.multipleRecipientsEnabled)
+ } else if (root.wallet.transactionNeedsUnlock) {
+ reviewPassphrasePopup.errorText = ""
+ reviewPassphrasePopup.open()
} else {
- root.prepareTransactionErrorText = qsTr("Amount plus fee exceeds available balance")
+ root.prepareTransactionErrorText = root.wallet.transactionError.length > 0
+ ? root.wallet.transactionError
+ : qsTr("Amount plus fee exceeds available balance")
}
}
}
@@ -496,4 +504,30 @@ PageStack {
onDone: root.pop()
}
}
+
+ WalletPassphrasePopup {
+ id: reviewPassphrasePopup
+ parent: Overlay.overlay
+ width: Math.min(420, root.width - 40)
+ popupObjectName: "reviewPassphrasePopup"
+ passphraseFieldObjectName: "reviewPassphraseField"
+ errorTextObjectName: "reviewPassphraseErrorText"
+ cancelButtonObjectName: "reviewPassphraseCancelButton"
+ confirmButtonObjectName: "reviewPassphraseConfirmButton"
+ titleText: qsTr("Enter wallet password")
+ descriptionText: qsTr("Enter your wallet password to prepare this transaction for review.")
+ confirmText: qsTr("Unlock and continue")
+ busyConfirmText: qsTr("Unlocking...")
+ onSubmitted: (passphrase) => {
+ reviewPassphrasePopup.busy = true
+ if (root.wallet.prepareTransactionWithPassphrase(passphrase)) {
+ reviewPassphrasePopup.busy = false
+ reviewPassphrasePopup.close()
+ root.transactionPrepared(settings.multipleRecipientsEnabled)
+ return
+ }
+ reviewPassphrasePopup.busy = false
+ reviewPassphrasePopup.errorText = root.wallet.transactionError
+ }
+ }
}
diff --git a/qml/pages/wallet/SendReview.qml b/qml/pages/wallet/SendReview.qml
index f56a2ec1b5..8ea47f8b9c 100644
--- a/qml/pages/wallet/SendReview.qml
+++ b/qml/pages/wallet/SendReview.qml
@@ -18,6 +18,7 @@ Page {
property WalletQmlModel wallet: walletController.selectedWallet
property SendRecipient recipient: wallet.recipients.current
property WalletQmlModelTransaction transaction: walletController.selectedWallet.currentTransaction
+ property bool sending: false
signal finished()
signal back()
@@ -123,8 +124,13 @@ Page {
Layout.fillWidth: true
Layout.topMargin: 30
onSendRequested: {
- root.wallet.sendTransaction()
- root.transactionSent()
+ if (root.sending) {
+ return
+ }
+ if (root.wallet.sendTransaction()) {
+ root.sending = true
+ root.transactionSent()
+ }
}
}
@@ -132,14 +138,30 @@ Page {
id: confirmationButton
objectName: "sendReviewSendButton"
visible: !root.wallet || !root.wallet.hasExternalSigner
+ enabled: !root.sending
Layout.fillWidth: true
Layout.topMargin: 30
text: qsTr("Send")
onClicked: {
- root.wallet.sendTransaction()
- root.transactionSent()
+ if (root.sending) {
+ return
+ }
+ if (root.wallet.sendTransaction()) {
+ root.sending = true
+ root.transactionSent()
+ }
}
}
+
+ CoreText {
+ objectName: "sendReviewErrorText"
+ Layout.fillWidth: true
+ visible: text.length > 0
+ text: root.wallet.transactionError
+ color: Theme.color.red
+ font.pixelSize: 15
+ wrapMode: Text.WordWrap
+ }
}
}
}
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..e6f10ecad8 100644
--- a/qml/pages/wallet/WalletSelect.qml
+++ b/qml/pages/wallet/WalletSelect.qml
@@ -78,7 +78,7 @@ Popup {
delegate: WalletBadge {
required property string name;
-
+ objectName: "walletSelectItem_" + name.replace(/[^A-Za-z0-9_]/g, "_")
width: 220
height: 32
text: name
diff --git a/qml/walletqmlcontroller.cpp b/qml/walletqmlcontroller.cpp
index f824cba666..be97e18920 100644
--- a/qml/walletqmlcontroller.cpp
+++ b/qml/walletqmlcontroller.cpp
@@ -107,6 +107,11 @@ WalletQmlController::~WalletQmlController()
void WalletQmlController::setSelectedWallet(QString path)
{
+ if (!m_initialized) {
+ setWalletLoadError(tr("Wallets are still loading. Try again in a moment."));
+ return;
+ }
+
if (!m_wallets.empty()) {
for (WalletQmlModel* wallet : m_wallets) {
if (wallet->name() == path) {
@@ -142,9 +147,14 @@ void WalletQmlController::unloadWallets()
bool WalletQmlController::createSingleSigWallet(const QString &name, const QString &passphrase)
{
+ clearWalletCreateStatus();
clearWalletLoadStatus();
clearWalletMigrationStatus();
m_warning_messages.clear();
+ if (!m_initialized) {
+ setWalletCreateError(tr("Wallets are still loading. Try again in a moment."));
+ return false;
+ }
const SecureString secure_passphrase{passphrase.toStdString()};
const std::string wallet_name{name.toStdString()};
auto wallet{m_node.walletLoader().createWallet(wallet_name, secure_passphrase, wallet::WALLET_FLAG_DESCRIPTORS, m_warning_messages)};
@@ -158,9 +168,8 @@ bool WalletQmlController::createSingleSigWallet(const QString &name, const QStri
Q_EMIT selectedWalletChanged();
return true;
} else {
- m_error_message = util::ErrorString(wallet);
- const QString error = QString::fromStdString(m_error_message.translated);
- setWalletLoadError(error.isEmpty() ? tr("Wallet creation failed.") : error);
+ const bilingual_str error = util::ErrorString(wallet);
+ setWalletCreateError(QString::fromStdString(error.translated.empty() ? error.original : error.translated));
return false;
}
}
@@ -170,6 +179,10 @@ bool WalletQmlController::createExternalSignerWallet(const QString& name)
clearWalletLoadStatus();
clearWalletMigrationStatus();
m_warning_messages.clear();
+ if (!m_initialized) {
+ setWalletLoadError(tr("Wallets are still loading. Try again in a moment."));
+ return false;
+ }
const QString wallet_name = name.trimmed();
if (wallet_name.isEmpty()) {
@@ -234,9 +247,18 @@ bool WalletQmlController::createExternalSignerWallet(const QString& name)
void WalletQmlController::importWallet(const QString& path)
{
+ if (!m_initialized) {
+ setWalletLoadError(tr("Wallets are still loading. Try again in a moment."));
+ return;
+ }
startWalletImport(path);
}
+void WalletQmlController::clearWalletCreateStatus()
+{
+ setWalletCreateError(QString());
+}
+
void WalletQmlController::clearWalletLoadStatus()
{
setWalletLoadInProgress(false);
@@ -244,9 +266,13 @@ void WalletQmlController::clearWalletLoadStatus()
setWalletLoadWarnings(QString());
}
-void WalletQmlController::migrateWallet(const QString& path)
+void WalletQmlController::migrateWallet(const QString& path, const QString& passphrase)
{
- startWalletMigration(path);
+ if (!m_initialized) {
+ setWalletMigrationError(tr("Wallets are still loading. Try again in a moment."));
+ return;
+ }
+ startWalletMigration(path, passphrase);
}
void WalletQmlController::clearWalletMigrationStatus()
@@ -682,7 +708,7 @@ void WalletQmlController::startWalletLoad(const QString& path)
});
}
-void WalletQmlController::startWalletMigration(const QString& path)
+void WalletQmlController::startWalletMigration(const QString& path, const QString& passphrase)
{
clearWalletLoadStatus();
clearWalletMigrationStatus();
@@ -702,9 +728,9 @@ void WalletQmlController::startWalletMigration(const QString& path)
setWalletMigrationInProgress(true);
- QTimer::singleShot(0, m_worker, [this, wallet_reference]() {
- const SecureString empty_passphrase;
- auto result = m_node.walletLoader().migrateWallet(wallet_reference.toStdString(), empty_passphrase);
+ QTimer::singleShot(0, m_worker, [this, wallet_reference, passphrase]() {
+ const SecureString secure_passphrase{passphrase.toStdString()};
+ auto result = m_node.walletLoader().migrateWallet(wallet_reference.toStdString(), secure_passphrase);
if (!result) {
const QString error = QString::fromStdString(util::ErrorString(result).translated);
@@ -724,6 +750,14 @@ void WalletQmlController::startWalletMigration(const QString& path)
});
}
+void WalletQmlController::setWalletCreateError(const QString& error)
+{
+ if (m_wallet_create_error != error) {
+ m_wallet_create_error = error;
+ Q_EMIT walletCreateErrorChanged();
+ }
+}
+
void WalletQmlController::setWalletLoadInProgress(bool in_progress)
{
if (m_wallet_load_in_progress != in_progress) {
diff --git a/qml/walletqmlcontroller.h b/qml/walletqmlcontroller.h
index 228b88757f..c1b1a5b36f 100644
--- a/qml/walletqmlcontroller.h
+++ b/qml/walletqmlcontroller.h
@@ -30,6 +30,7 @@ class WalletQmlController : public QObject
Q_PROPERTY(QString walletImportErrorTitle READ walletImportErrorTitle NOTIFY walletLoadErrorChanged)
Q_PROPERTY(QString walletImportErrorDescription READ walletImportErrorDescription NOTIFY walletLoadErrorChanged)
Q_PROPERTY(QString walletImportErrorHelpText READ walletImportErrorHelpText NOTIFY walletLoadErrorChanged)
+ Q_PROPERTY(QString walletCreateError READ walletCreateError NOTIFY walletCreateErrorChanged)
Q_PROPERTY(bool walletMigrationInProgress READ walletMigrationInProgress NOTIFY walletMigrationInProgressChanged)
Q_PROPERTY(QString walletMigrationError READ walletMigrationError NOTIFY walletMigrationErrorChanged)
Q_PROPERTY(QString lastImportedWalletName READ lastImportedWalletName NOTIFY lastImportedWalletInfoChanged)
@@ -47,8 +48,9 @@ class WalletQmlController : public QObject
Q_INVOKABLE bool createSingleSigWallet(const QString &name, const QString &passphrase);
Q_INVOKABLE bool createExternalSignerWallet(const QString& name);
Q_INVOKABLE void importWallet(const QString& path);
+ Q_INVOKABLE void clearWalletCreateStatus();
Q_INVOKABLE void clearWalletLoadStatus();
- Q_INVOKABLE void migrateWallet(const QString& path);
+ Q_INVOKABLE void migrateWallet(const QString& path, const QString& passphrase = QString());
Q_INVOKABLE void clearWalletMigrationStatus();
Q_INVOKABLE QString normalizeWalletPath(const QString& path) const;
Q_INVOKABLE bool walletPathExists(const QString& path) const;
@@ -68,6 +70,7 @@ class WalletQmlController : public QObject
QString walletImportErrorTitle() const;
QString walletImportErrorDescription() const;
QString walletImportErrorHelpText() const;
+ QString walletCreateError() const { return m_wallet_create_error; }
bool walletMigrationInProgress() const { return m_wallet_migration_in_progress; }
QString walletMigrationError() const { return m_wallet_migration_error; }
QString lastImportedWalletName() const { return m_last_imported_wallet_name; }
@@ -85,6 +88,7 @@ class WalletQmlController : public QObject
void walletLoadInProgressChanged();
void walletLoadErrorChanged();
void walletLoadWarningsChanged();
+ void walletCreateErrorChanged();
void walletLoadSucceeded();
void walletImportSucceeded();
void walletMigrationInProgressChanged();
@@ -109,11 +113,12 @@ public Q_SLOTS:
void handleLoadWallet(std::unique_ptr wallet);
void startWalletImport(const QString& path);
void startWalletLoad(const QString& path);
- void startWalletMigration(const QString& path);
+ void startWalletMigration(const QString& path, const QString& passphrase);
QString resolveManagedWalletReference(const QString& path) const;
QString inferWalletLoadTarget(const QString& normalized_path) const;
QString inferRestoreWalletName(const QString& normalized_path) const;
QString describeImportedWalletKeyScheme(interfaces::Wallet& wallet) const;
+ void setWalletCreateError(const QString& error);
void setWalletLoadInProgress(bool in_progress);
void setWalletLoadError(const QString& error);
void setWalletLoadWarnings(const QString& warnings);
@@ -139,6 +144,7 @@ public Q_SLOTS:
bool m_wallet_load_requested{false};
QString m_wallet_load_error;
QString m_wallet_load_warnings;
+ QString m_wallet_create_error;
WalletLoadAction m_pending_wallet_load_action{WalletLoadAction::None};
bool m_wallet_migration_in_progress{false};
QString m_wallet_migration_error;
@@ -150,7 +156,6 @@ public Q_SLOTS:
QString m_external_signer_error;
QString m_suggested_external_signer_wallet_name;
- bilingual_str m_error_message;
std::vector m_warning_messages;
};
diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt
index 3b7ed7d38b..8571ceac16 100644
--- a/test/CMakeLists.txt
+++ b/test/CMakeLists.txt
@@ -24,7 +24,6 @@ add_executable(bitcoinqml_unit_tests
test_qmlinitexecutor_api.cpp
test_options_model.cpp
test_banlistmodel.cpp
- test_walletqmlmodel.cpp
test_bumptransactionmodel.cpp
test_walletqmlcontroller.cpp
test_display_settings.cpp
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_password_wallet.py b/test/functional/qml_test_password_wallet.py
new file mode 100644
index 0000000000..50941fb8aa
--- /dev/null
+++ b/test/functional/qml_test_password_wallet.py
@@ -0,0 +1,482 @@
+#!/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 tests for password wallet flows."""
+
+import argparse
+import os
+import re
+import signal
+import subprocess
+import sys
+import time
+from datetime import datetime
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "bitcoin", "test", "functional"))
+
+from qml_driver import QmlDriverError
+from qml_test_harness import dump_qml_tree
+from qml_wallet_test_lib import (
+ WalletFlowHarness,
+ find_legacy_bitcoind,
+ rpc_call,
+ wait_for_rpc,
+)
+from test_framework.descriptors import descsum_create
+
+
+WALLET_PASSWORD = "correct horse battery staple"
+FALLBACK_DESC_EXTERNAL = "wpkh(tprv8ZgxMBicQKsPdYeeZbPSKd2KYLmeVKtcFA7kqCxDvDR13MQ6us8HopUR2wLcS2ZKPhLyKsqpDL2FtL73LMHcgoCL7DXsciA8eX8nbjCR2eG/0h/*h)"
+FALLBACK_DESC_INTERNAL = "wpkh(tprv8ZgxMBicQKsPdYeeZbPSKd2KYLmeVKtcFA7kqCxDvDR13MQ6us8HopUR2wLcS2ZKPhLyKsqpDL2FtL73LMHcgoCL7DXsciA8eX8nbjCR2eG/1h/*h)"
+
+
+def parse_args():
+ parser = argparse.ArgumentParser(
+ description="Password wallet 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_password_wallet-{timestamp}")
+ os.makedirs(screenshot_root, exist_ok=True)
+ return screenshot_root
+
+
+class CheckpointRecorder:
+ def __init__(self, case_name, save_screenshots, screenshot_root):
+ self.case_name = case_name
+ 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):
+ self.index += 1
+ prefix = f"[{self.case_name}] checkpoint {self.index:02d}"
+ print(f"{prefix}: {label}")
+ if gui is None:
+ return
+
+ gui.settle()
+
+ if not self.save_screenshots:
+ return
+
+ case_dir = os.path.join(self.screenshot_root, self.case_name)
+ filename = f"{self.index:02d}-{self._sanitize_label(label)}.png"
+ screenshot_path = os.path.join(case_dir, filename)
+ screenshot = gui.save_screenshot(screenshot_path)
+ print(
+ f"{prefix}: screenshot saved to {screenshot['path']} "
+ f"({screenshot['width']}x{screenshot['height']})"
+ )
+
+
+def sanitize_object_suffix(value):
+ return re.sub(r"[^A-Za-z0-9_]+", "_", value)
+
+
+def start_node(binary, datadir, rpc_port, extra_args=None):
+ args = [binary, f"-datadir={datadir}"]
+ if extra_args:
+ args.extend(extra_args)
+ process = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ wait_for_rpc(rpc_port)
+ return process
+
+
+def stop_node(process, rpc_port=None):
+ if process and process.poll() is None:
+ if rpc_port is not None:
+ try:
+ rpc_call(rpc_port, "stop")
+ except Exception:
+ process.send_signal(signal.SIGTERM)
+ else:
+ process.send_signal(signal.SIGTERM)
+ try:
+ process.wait(timeout=20)
+ except subprocess.TimeoutExpired:
+ process.kill()
+ process.wait()
+
+
+def create_recipient_wallet(harness, wallet_name="recipient"):
+ harness.start_source_node()
+ rpc_call(harness.source_rpc_port, "createwallet", {"wallet_name": wallet_name})
+ return rpc_call(harness.source_rpc_port, "getnewaddress", wallet=wallet_name)
+
+
+def create_password_wallet(gui, wallet_name, password):
+ gui.wait_for_property("createWalletButton", "visible", True, timeout_ms=10000)
+ gui.wait_for_property("createWalletButton", "enabled", True, timeout_ms=25000)
+ gui.click("createWalletButton")
+ gui.click("createWalletIntroStartButton")
+ gui.set_text("createWalletNameInput", wallet_name)
+ gui.click("createWalletNameContinueButton")
+ gui.set_text("createWalletPasswordInput", password)
+ gui.set_text("createWalletPasswordRepeatInput", password)
+ gui.click("createWalletPasswordConfirmToggle")
+ gui.wait_for_property("createWalletPasswordContinueButton", "enabled", True, timeout_ms=25000)
+ gui.click("createWalletPasswordContinueButton")
+ gui.wait_for_property("createWalletConfirmNextButton", "visible", True, timeout_ms=10000)
+ gui.click("createWalletConfirmNextButton")
+ gui.wait_for_property("createWalletBackupDoneButton", "visible", True, timeout_ms=10000)
+ gui.click("createWalletBackupDoneButton")
+
+
+def dismiss_create_wallet_wizard(gui):
+ gui.wait_for_property("createWalletWizardExitButton", "visible", True, timeout_ms=10000)
+ gui.click("createWalletWizardExitButton")
+
+
+def wait_for_wallet_ready(harness, gui):
+ wait_for_rpc(harness.gui_rpc_port, timeout=60)
+ gui.wait_for_property("walletBadge", "loading", False, timeout_ms=25000)
+
+
+def mine_to_wallet(gui_rpc_port, wallet_name, blocks):
+ address = rpc_call(gui_rpc_port, "getnewaddress", wallet=wallet_name)
+ rpc_call(gui_rpc_port, "generatetoaddress", [blocks, address])
+
+
+def open_send_tab(gui):
+ gui.click("desktopWalletsSendTab")
+ gui.wait_for_property("walletSendTitle", "visible", True, timeout_ms=5000)
+
+
+def fill_send_form(gui, address, amount):
+ gui.set_text("sendAddressInput", address)
+ gui.set_text("sendAmountInput", amount)
+
+
+def assert_wallet_locked(gui_rpc_port, wallet_name):
+ info = rpc_call(gui_rpc_port, "getwalletinfo", wallet=wallet_name)
+ assert info["unlocked_until"] == 0, f"Expected locked wallet, got getwalletinfo={info}"
+
+
+def open_wallet_selector(gui):
+ gui.wait_for_property("walletBadge", "loading", False, timeout_ms=20000)
+ gui.click("walletBadge")
+ gui.wait_for_property("walletSelectPopup", "opened", True, timeout_ms=5000)
+
+
+def select_wallet(gui, wallet_name):
+ open_wallet_selector(gui)
+ wallet_selector_object_name = f"walletSelectItem_{sanitize_object_suffix(wallet_name)}"
+ gui.wait_for_object(wallet_selector_object_name, timeout_ms=5000)
+ gui.click(wallet_selector_object_name)
+
+
+def open_import_wallet_page(gui):
+ gui.wait_for_property("importWalletButton", "visible", True, timeout_ms=10000)
+ gui.wait_for_property("importWalletButton", "enabled", True, timeout_ms=25000)
+ gui.click("importWalletButton")
+ gui.wait_for_page("importWalletOptions", timeout_ms=10000)
+
+
+def trigger_automated_import(gui, backup_path):
+ gui.set_text("importWalletPathField", backup_path)
+ gui.click("importWalletChooseFileButton")
+
+
+def drain_change_keypool(gui_rpc_port, wallet_name):
+ while True:
+ try:
+ rpc_call(gui_rpc_port, "getrawchangeaddress", wallet=wallet_name)
+ except RuntimeError as err:
+ assert "Keypool ran out" in str(err), f"Unexpected keypool drain failure: {err}"
+ return
+
+
+def configure_fallback_wallet(harness, wallet_name):
+ process = start_node(harness.bitcoind_binary, harness.gui_datadir, harness.gui_rpc_port)
+ try:
+ rpc_call(harness.gui_rpc_port, "createwallet", {"wallet_name": wallet_name, "blank": True})
+ rpc_call(harness.gui_rpc_port, "encryptwallet", [WALLET_PASSWORD], wallet=wallet_name)
+ rpc_call(harness.gui_rpc_port, "walletpassphrase", [WALLET_PASSWORD, 60], wallet=wallet_name)
+ external_desc = descsum_create(FALLBACK_DESC_EXTERNAL)
+ internal_desc = descsum_create(FALLBACK_DESC_INTERNAL)
+ rpc_call(
+ harness.gui_rpc_port,
+ "importdescriptors",
+ [[
+ {
+ "desc": external_desc,
+ "timestamp": "now",
+ "active": True,
+ "range": [0, 0],
+ },
+ {
+ "desc": internal_desc,
+ "timestamp": "now",
+ "active": True,
+ "internal": True,
+ "range": [0, 0],
+ },
+ ]],
+ wallet=wallet_name,
+ )
+ rpc_call(harness.gui_rpc_port, "keypoolrefill", [1], wallet=wallet_name)
+ rpc_call(harness.gui_rpc_port, "walletlock", wallet=wallet_name)
+ mine_to_wallet(harness.gui_rpc_port, wallet_name, 101)
+ drain_change_keypool(harness.gui_rpc_port, wallet_name)
+ finally:
+ stop_node(process, harness.gui_rpc_port)
+
+
+def configure_managed_legacy_wallet(harness, wallet_name):
+ legacy_binary = find_legacy_bitcoind()
+ if not legacy_binary:
+ return None
+
+ process = start_node(
+ legacy_binary,
+ harness.gui_datadir,
+ harness.gui_rpc_port,
+ extra_args=["-deprecatedrpc=create_bdb"],
+ )
+ try:
+ rpc_call(
+ harness.gui_rpc_port,
+ "createwallet",
+ {
+ "wallet_name": wallet_name,
+ "descriptors": False,
+ },
+ )
+ rpc_call(harness.gui_rpc_port, "encryptwallet", [WALLET_PASSWORD], wallet=wallet_name)
+ finally:
+ stop_node(process, harness.gui_rpc_port)
+ return legacy_binary
+
+
+def run_case(case_name, port_offset, case_body, save_screenshots=False, screenshot_root=None):
+ harness = WalletFlowHarness(case_name, port_offset=port_offset)
+ checkpoints = CheckpointRecorder(case_name, save_screenshots, screenshot_root)
+ try:
+ print(f"[{case_name}] starting")
+ case_body(harness, checkpoints)
+ print(f"[{case_name}] completed")
+ return 0
+ except Exception as err: # noqa: BLE001 - preserve failure context for functional test output
+ print(f"\nFAILED [{case_name}]: {err}", file=sys.stderr)
+ import traceback
+ traceback.print_exc()
+ gui = harness.driver
+ if gui is not None:
+ try:
+ checkpoints.checkpoint("failure state", gui)
+ except Exception as screenshot_err: # noqa: BLE001 - preserve original failure context
+ print(f"[{case_name}] 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)
+ print(gui_output, file=sys.stderr)
+ if gui is not None:
+ dump_qml_tree(gui)
+ return 1
+ finally:
+ harness.stop()
+
+
+def case_created_wallet_send(harness, checkpoints):
+ wallet_name = "created_password_wallet"
+ recipient_addr = create_recipient_wallet(harness)
+
+ harness.start_gui(reset_gui_settings=True)
+ gui = harness.driver
+ checkpoints.checkpoint("GUI launched", gui)
+ harness.finish_onboarding()
+ checkpoints.checkpoint("onboarding completed", gui)
+
+ create_password_wallet(gui, wallet_name, WALLET_PASSWORD)
+ gui.wait_for_property("walletBadge", "text", wallet_name, timeout_ms=20000)
+ wait_for_wallet_ready(harness, gui)
+ checkpoints.checkpoint("encrypted wallet created", gui)
+
+ mine_to_wallet(harness.gui_rpc_port, wallet_name, 101)
+ assert_wallet_locked(harness.gui_rpc_port, wallet_name)
+ checkpoints.checkpoint("wallet funded and locked", gui)
+
+ harness.stop_gui()
+ harness.start_gui()
+ gui = harness.driver
+ gui.wait_for_property("walletBadge", "text", wallet_name, timeout_ms=20000)
+ assert_wallet_locked(harness.gui_rpc_port, wallet_name)
+ checkpoints.checkpoint("wallet restarted locked", gui)
+
+ gui.click("desktopWalletsActivityTab")
+ gui.wait_for_property("walletActivityTitle", "visible", True, timeout_ms=5000)
+ checkpoints.checkpoint("locked wallet activity visible", gui)
+
+ open_send_tab(gui)
+ fill_send_form(gui, recipient_addr, "1")
+ gui.click("sendReviewButton")
+ gui.wait_for_property("reviewPassphrasePopup", "opened", True, timeout_ms=10000)
+ checkpoints.checkpoint("review passphrase prompt displayed", gui)
+ gui.set_text("reviewPassphraseField", WALLET_PASSWORD)
+ gui.click("reviewPassphraseConfirmButton")
+ gui.wait_for_property("sendReviewSendButton", "visible", True, timeout_ms=20000)
+ assert_wallet_locked(harness.gui_rpc_port, wallet_name)
+ checkpoints.checkpoint("review built and wallet relocked", gui)
+
+ before = rpc_call(harness.gui_rpc_port, "getwalletinfo", wallet=wallet_name)["txcount"]
+ gui.click("sendReviewSendButton")
+ gui.wait_for_property("sendResultPopup", "opened", True, timeout_ms=20000)
+ checkpoints.checkpoint("prepared transaction broadcast", gui)
+
+ after = rpc_call(harness.gui_rpc_port, "getwalletinfo", wallet=wallet_name)["txcount"]
+ assert after > before, f"Expected additional wallet transaction, before={before}, after={after}"
+ assert_wallet_locked(harness.gui_rpc_port, wallet_name)
+
+
+def case_locked_review_fallback(harness, checkpoints):
+ wallet_name = "fallback_password_wallet"
+ recipient_addr = create_recipient_wallet(harness, wallet_name="fallback_recipient")
+ configure_fallback_wallet(harness, wallet_name)
+ checkpoints.checkpoint("fallback wallet fixture prepared")
+
+ harness.start_gui(reset_gui_settings=True)
+ gui = harness.driver
+ checkpoints.checkpoint("GUI launched", gui)
+ harness.finish_onboarding()
+ dismiss_create_wallet_wizard(gui)
+ wait_for_wallet_ready(harness, gui)
+ rpc_call(harness.gui_rpc_port, "loadwallet", [wallet_name])
+ gui.wait_for_property("walletBadge", "text", wallet_name, timeout_ms=20000)
+ assert_wallet_locked(harness.gui_rpc_port, wallet_name)
+ checkpoints.checkpoint("fallback wallet selected", gui)
+
+ open_send_tab(gui)
+ fill_send_form(gui, recipient_addr, "1")
+ gui.click("sendReviewButton")
+ gui.wait_for_property("reviewPassphrasePopup", "opened", True, timeout_ms=10000)
+ checkpoints.checkpoint("review fallback passphrase prompt displayed", gui)
+
+ gui.set_text("reviewPassphraseField", WALLET_PASSWORD)
+ gui.click("reviewPassphraseConfirmButton")
+ gui.wait_for_property("sendReviewSendButton", "visible", True, timeout_ms=20000)
+ assert_wallet_locked(harness.gui_rpc_port, wallet_name)
+ checkpoints.checkpoint("review rebuilt and wallet relocked", gui)
+
+ gui.click("sendReviewSendButton")
+ gui.wait_for_property("sendResultPopup", "opened", True, timeout_ms=20000)
+ checkpoints.checkpoint("fallback transaction broadcast", gui)
+ assert_wallet_locked(harness.gui_rpc_port, wallet_name)
+
+
+def case_import_encrypted_wallet(harness, checkpoints):
+ wallet_name = "imported_password_wallet"
+ backup_path = os.path.join(harness.tmpdir, f"{wallet_name}.bak")
+ harness.start_source_node()
+ rpc_call(harness.source_rpc_port, "createwallet", {"wallet_name": wallet_name, "passphrase": WALLET_PASSWORD})
+ source_address = rpc_call(harness.source_rpc_port, "getnewaddress", wallet=wallet_name)
+ rpc_call(harness.source_rpc_port, "generatetoaddress", [101, source_address])
+ rpc_call(harness.source_rpc_port, "backupwallet", [backup_path], wallet=wallet_name)
+ harness.stop_source_node()
+ checkpoints.checkpoint("encrypted backup fixture created")
+
+ harness.start_gui(reset_gui_settings=True)
+ gui = harness.driver
+ checkpoints.checkpoint("GUI launched", gui)
+ harness.finish_onboarding()
+ open_import_wallet_page(gui)
+ checkpoints.checkpoint("import flow opened", gui)
+
+ trigger_automated_import(gui, backup_path)
+ gui.wait_for_page("importWalletSuccessPage", timeout_ms=20000)
+ gui.click("importWalletSuccessOverviewButton")
+ gui.wait_for_property("walletBadge", "text", wallet_name, timeout_ms=20000)
+ wait_for_wallet_ready(harness, gui)
+ assert_wallet_locked(harness.gui_rpc_port, wallet_name)
+ checkpoints.checkpoint("encrypted wallet imported locked", gui)
+
+ gui.click("desktopWalletsActivityTab")
+ gui.wait_for_property("walletActivityTitle", "visible", True, timeout_ms=5000)
+ checkpoints.checkpoint("imported locked wallet activity visible", gui)
+
+
+def case_managed_legacy_migration(harness, checkpoints):
+ wallet_name = "legacy_encrypted_wallet"
+ legacy_binary = configure_managed_legacy_wallet(harness, wallet_name)
+ if not legacy_binary:
+ print("SKIPPED [qml_password_wallet_managed_legacy_migration]: legacy bitcoind not found.")
+ print("Set BITCOIND_LEGACY or provide releases/v28.0/bin/bitcoind to exercise this flow.")
+ return
+ checkpoints.checkpoint("managed legacy wallet fixture prepared")
+
+ harness.start_gui(reset_gui_settings=True)
+ gui = harness.driver
+ checkpoints.checkpoint("GUI launched", gui)
+ harness.finish_onboarding()
+ dismiss_create_wallet_wizard(gui)
+ wait_for_wallet_ready(harness, gui)
+ checkpoints.checkpoint("wallet overview displayed", gui)
+
+ select_wallet(gui, wallet_name)
+ gui.wait_for_property("walletMigrationPopup", "opened", True, timeout_ms=10000)
+ checkpoints.checkpoint("legacy migration prompt displayed", gui)
+
+ gui.click("walletMigrationConfirmButton")
+ gui.wait_for_property("walletMigrationPassphrasePopup", "opened", True, timeout_ms=10000)
+ assert gui.get_text("walletMigrationPassphraseErrorText") == ""
+ checkpoints.checkpoint("migration passphrase prompt displayed", gui)
+
+ gui.set_text("walletMigrationPassphraseField", "wrong password")
+ gui.click("walletMigrationPassphraseConfirmButton")
+ gui.wait_for_property(
+ "walletMigrationPassphraseErrorText",
+ "text",
+ lambda text: "passphrase" in text.lower(),
+ timeout_ms=10000,
+ )
+ checkpoints.checkpoint("wrong migration password rejected", gui)
+
+ gui.set_text("walletMigrationPassphraseField", WALLET_PASSWORD)
+ gui.click("walletMigrationPassphraseConfirmButton")
+ gui.wait_for_property("walletBadge", "text", wallet_name, timeout_ms=20000)
+ assert_wallet_locked(harness.gui_rpc_port, wallet_name)
+ checkpoints.checkpoint("legacy wallet migrated and loaded", gui)
+
+
+def run_test(args):
+ screenshot_root = None
+ if args.save_screenshots:
+ screenshot_root = make_screenshot_root()
+ print(f"Checkpoint screenshots will be saved under: {screenshot_root}")
+
+ cases = [
+ ("qml_password_wallet_created_send", 300, case_created_wallet_send),
+ ("qml_password_wallet_review_fallback", 310, case_locked_review_fallback),
+ ("qml_password_wallet_import_encrypted", 320, case_import_encrypted_wallet),
+ ("qml_password_wallet_managed_legacy_migration", 330, case_managed_legacy_migration),
+ ]
+
+ exit_code = 0
+ for case_name, port_offset, case_body in cases:
+ exit_code |= run_case(
+ case_name,
+ port_offset,
+ case_body,
+ save_screenshots=args.save_screenshots,
+ screenshot_root=screenshot_root,
+ )
+ return exit_code
+
+
+if __name__ == "__main__":
+ sys.exit(run_test(parse_args()))
diff --git a/test/functional/qml_test_send_receive.py b/test/functional/qml_test_send_receive.py
index bb3bbde012..6ff79798f9 100644
--- a/test/functional/qml_test_send_receive.py
+++ b/test/functional/qml_test_send_receive.py
@@ -281,8 +281,9 @@ def run_test(*, save_screenshots=False, screenshot_root=None):
gui.click("feeSelectionDropdownButton")
gui.wait_for_property("feeSelectionPopup", "opened", True, timeout_ms=5000)
checkpoints.checkpoint("fee dropdown with include fee enabled", gui)
- gui.click("sendAmountInput")
+ gui.click(f"feeSelectionOption{LOW_FEE_OPTION_INDEX}")
gui.wait_for_property("feeSelectionPopup", "opened", False, timeout_ms=5000)
+ gui.wait_for_property("feeSelectionControl", "selectedIndex", LOW_FEE_OPTION_INDEX, timeout_ms=5000)
estimated_fee_with_subtract_text = gui.get_text("feeSelectionEstimateLabel")
estimated_fee_with_subtract_sats = btc_text_to_sats(estimated_fee_with_subtract_text)
diff --git a/test/functional/qml_test_send_review.py b/test/functional/qml_test_send_review.py
index 9dd684a088..0de1e5d1b4 100644
--- a/test/functional/qml_test_send_review.py
+++ b/test/functional/qml_test_send_review.py
@@ -155,6 +155,7 @@ def prepare_single_send(gui, address, amount, amount_unit="btc"):
gui.wait_for_property("sendReviewButton", "enabled", True, timeout_ms=10000)
gui.click("sendReviewButton")
gui.wait_for_page("sendReviewPage", timeout_ms=10000)
+ gui.wait_for_property("sendReviewSendButton", "visible", True, timeout_ms=10000)
def prepare_multi_send(gui, first_address, first_amount_btc, second_address, second_amount_sat):
diff --git a/test/functional/qml_test_wallet_migration.py b/test/functional/qml_test_wallet_migration.py
new file mode 100644
index 0000000000..eeccf9f20e
--- /dev/null
+++ b/test/functional/qml_test_wallet_migration.py
@@ -0,0 +1,257 @@
+#!/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 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
+
+
+WALLET_PASSWORD = "correct horse battery staple"
+LEGACY_MIGRATION_WARNING = (
+ "This wallet is a legacy wallet and will need to be migrated with migratewallet before it can be loaded"
+)
+
+
+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 sanitize_object_suffix(value):
+ return re.sub(r"[^A-Za-z0-9_]+", "_", value)
+
+
+def create_legacy_wallet_fixture(harness, legacy_binary, wallet_name, *, encrypted=False):
+ harness.start_source_node(
+ binary=legacy_binary,
+ extra_args=["-deprecatedrpc=create_bdb"],
+ )
+ try:
+ rpc_call(
+ harness.source_rpc_port,
+ "createwallet",
+ {
+ "wallet_name": wallet_name,
+ "descriptors": False,
+ },
+ )
+ if encrypted:
+ rpc_call(
+ harness.source_rpc_port,
+ "encryptwallet",
+ [WALLET_PASSWORD],
+ wallet=wallet_name,
+ )
+ else:
+ rpc_call(harness.source_rpc_port, "unloadwallet", [wallet_name])
+ finally:
+ harness.stop_source_node()
+
+ 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)
+ return target_wallet_path
+
+
+def verify_legacy_wallet_warning(gui_rpc_port, wallet_name):
+ wallet_dir = rpc_call(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"] == [
+ LEGACY_MIGRATION_WARNING
+ ], f"Expected migration warning for {wallet_name}, got {legacy_entry}"
+ print(f"[qml_test_wallet_migration] legacy wallet dir entry: {legacy_entry}")
+
+
+def open_wallet_selector(gui):
+ gui.click("walletBadge")
+ gui.wait_for_property("walletSelectPopup", "opened", True, timeout_ms=5000)
+
+
+def select_wallet_for_migration(gui, wallet_name):
+ open_wallet_selector(gui)
+ gui.wait_for_object(f"walletSelectItem_{sanitize_object_suffix(wallet_name)}", timeout_ms=5000)
+ gui.click(f"walletSelectItem_{sanitize_object_suffix(wallet_name)}")
+ gui.wait_for_property("walletMigrationPopup", "opened", True, timeout_ms=10000)
+
+
+def verify_migrated_wallet(harness, wallet_name, target_wallet_path, *, encrypted=False):
+ 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"
+ if encrypted:
+ assert wallet_info["unlocked_until"] == 0, (
+ f"Expected migrated encrypted wallet to be locked, got {wallet_info}"
+ )
+ print(f"[qml_test_wallet_migration] migrated wallet info: {wallet_info}")
+
+
+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()
+ 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
+
+ checkpoints.checkpoint(f"legacy bitcoind located: {legacy_binary}")
+ unencrypted_wallet_name = "legacy_flow"
+ encrypted_wallet_name = "legacy_encrypted_flow"
+ try:
+ unencrypted_wallet_path = create_legacy_wallet_fixture(
+ harness,
+ legacy_binary,
+ unencrypted_wallet_name,
+ )
+ checkpoints.checkpoint(
+ f"unencrypted legacy wallet fixture copied to GUI datadir: {unencrypted_wallet_path}"
+ )
+ encrypted_wallet_path = create_legacy_wallet_fixture(
+ harness,
+ legacy_binary,
+ encrypted_wallet_name,
+ encrypted=True,
+ )
+ checkpoints.checkpoint(
+ f"encrypted legacy wallet fixture copied to GUI datadir: {encrypted_wallet_path}"
+ )
+ except RuntimeError as err:
+ print(f"SKIPPED: could not create a legacy wallet fixture: {err}")
+ return 77
+
+ 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)
+
+ verify_legacy_wallet_warning(harness.gui_rpc_port, unencrypted_wallet_name)
+ verify_legacy_wallet_warning(harness.gui_rpc_port, encrypted_wallet_name)
+ checkpoints.checkpoint("legacy wallet warnings verified", gui)
+
+ select_wallet_for_migration(gui, unencrypted_wallet_name)
+ checkpoints.checkpoint("unencrypted migration prompt displayed", gui)
+ gui.click("walletMigrationConfirmButton")
+ gui.wait_for_property("walletBadge", "text", unencrypted_wallet_name, timeout_ms=30000)
+ gui.wait_for_property("walletMigrationPopup", "opened", False, timeout_ms=5000)
+ verify_migrated_wallet(harness, unencrypted_wallet_name, unencrypted_wallet_path)
+ checkpoints.checkpoint("unencrypted wallet migrated and loaded", gui)
+
+ select_wallet_for_migration(gui, encrypted_wallet_name)
+ checkpoints.checkpoint("encrypted migration prompt displayed", gui)
+ gui.click("walletMigrationConfirmButton")
+ gui.wait_for_property("walletMigrationPassphrasePopup", "opened", True, timeout_ms=10000)
+ assert gui.get_text("walletMigrationPassphraseErrorText") == ""
+ checkpoints.checkpoint("encrypted migration passphrase prompt displayed", gui)
+
+ gui.set_text("walletMigrationPassphraseField", "wrong password")
+ gui.click("walletMigrationPassphraseConfirmButton")
+ gui.wait_for_property(
+ "walletMigrationPassphraseErrorText",
+ "text",
+ lambda text: "passphrase" in text.lower(),
+ timeout_ms=10000,
+ )
+ checkpoints.checkpoint("wrong encrypted migration password rejected", gui)
+
+ gui.set_text("walletMigrationPassphraseField", WALLET_PASSWORD)
+ gui.click("walletMigrationPassphraseConfirmButton")
+ gui.wait_for_property("walletBadge", "text", encrypted_wallet_name, timeout_ms=30000)
+ gui.wait_for_property("walletMigrationPassphrasePopup", "opened", False, timeout_ms=5000)
+ verify_migrated_wallet(harness, encrypted_wallet_name, encrypted_wallet_path, encrypted=True)
+ checkpoints.checkpoint("encrypted wallet migrated and loaded", gui)
+
+ print("Migration flows 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()
+ 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)
+ print(gui_output, file=sys.stderr)
+ if gui is not None:
+ dump_qml_tree(gui)
+ return 1
+ finally:
+ harness.stop()
+
+
+if __name__ == "__main__":
+ 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))
diff --git a/test/functional/qml_test_wallet_rbf.py b/test/functional/qml_test_wallet_rbf.py
index 8dd983f6f4..9a9d1b639a 100644
--- a/test/functional/qml_test_wallet_rbf.py
+++ b/test/functional/qml_test_wallet_rbf.py
@@ -88,12 +88,12 @@ def exists():
def ensure_activity_tab_selected(gui, *, timeout=10, attempts=3):
for attempt in range(1, attempts + 1):
- if gui.get_property("walletActivityTabButton", "checked"):
+ if gui.get_property("desktopWalletsActivityTab", "checked"):
return
- gui.click("walletActivityTabButton")
+ gui.click("desktopWalletsActivityTab")
try:
- gui.wait_for_property("walletActivityTabButton", "checked", True, timeout_ms=int(timeout * 1000))
+ gui.wait_for_property("desktopWalletsActivityTab", "checked", True, timeout_ms=int(timeout * 1000))
return
except Exception:
if attempt == attempts:
diff --git a/test/functional/qml_wallet_test_lib.py b/test/functional/qml_wallet_test_lib.py
index 7c5010eb21..48f4513bee 100644
--- a/test/functional/qml_wallet_test_lib.py
+++ b/test/functional/qml_wallet_test_lib.py
@@ -72,7 +72,7 @@ def rpc_call(port, method, params=None, wallet=None):
if wallet:
path = f"/wallet/{wallet}"
- conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
+ conn = http.client.HTTPConnection("127.0.0.1", port, timeout=60)
credentials = base64.b64encode(f"{RPC_USER}:{RPC_PASS}".encode("utf-8")).decode("ascii")
conn.request(
"POST",
diff --git a/test/qml/qml_tests_main.cpp b/test/qml/qml_tests_main.cpp
index b4fcc94264..0526f5ac49 100644
--- a/test/qml/qml_tests_main.cpp
+++ b/test/qml/qml_tests_main.cpp
@@ -529,9 +529,14 @@ class MockWalletQmlModel : public QObject
Q_PROPERTY(bool feeEstimatePending MEMBER m_fee_estimate_pending NOTIFY feeEstimatePendingChanged)
Q_PROPERTY(int feeEstimateRevision MEMBER m_fee_estimate_revision NOTIFY feeEstimateRevisionChanged)
Q_PROPERTY(bool prepareTransactionResult MEMBER m_prepare_transaction_result NOTIFY prepareTransactionResultChanged)
+ Q_PROPERTY(bool sendTransactionResult MEMBER m_send_transaction_result NOTIFY sendTransactionResultChanged)
Q_PROPERTY(int prepareTransactionCalls READ prepareTransactionCalls NOTIFY prepareTransactionCallsChanged)
Q_PROPERTY(int scheduleFeeEstimatesCalls READ scheduleFeeEstimatesCalls NOTIFY scheduleFeeEstimatesCallsChanged)
Q_PROPERTY(int sendTransactionCalls READ sendTransactionCalls NOTIFY sendTransactionCallsChanged)
+ Q_PROPERTY(bool isEncrypted MEMBER m_is_encrypted NOTIFY securityStateChanged)
+ Q_PROPERTY(bool isLocked MEMBER m_is_locked NOTIFY securityStateChanged)
+ Q_PROPERTY(QString transactionError MEMBER m_transaction_error NOTIFY transactionErrorChanged)
+ Q_PROPERTY(bool transactionNeedsUnlock MEMBER m_transaction_needs_unlock NOTIFY transactionNeedsUnlockChanged)
public:
QString m_name{QStringLiteral("testwallet")};
@@ -629,8 +634,17 @@ class MockWalletQmlModel : public QObject
{
++m_prepare_transaction_calls;
Q_EMIT prepareTransactionCallsChanged();
+ if (m_prepare_transaction_result) {
+ setTransactionStatus({}, false);
+ } else {
+ setTransactionStatus(QStringLiteral("Amount plus fee exceeds available balance"), false);
+ }
return m_prepare_transaction_result;
}
+ Q_INVOKABLE bool prepareTransactionWithPassphrase(const QString&)
+ {
+ return prepareTransaction();
+ }
Q_INVOKABLE void scheduleFeeEstimates()
{
++m_schedule_fee_estimates_calls;
@@ -652,10 +666,14 @@ class MockWalletQmlModel : public QObject
++m_fee_estimate_revision;
Q_EMIT feeEstimateRevisionChanged();
}
- Q_INVOKABLE void sendTransaction()
+ Q_INVOKABLE bool sendTransaction()
{
++m_send_transaction_calls;
Q_EMIT sendTransactionCallsChanged();
+ if (m_send_transaction_result) {
+ setTransactionStatus({}, false);
+ }
+ return m_send_transaction_result;
}
Q_INVOKABLE void commitPaymentRequest()
{
@@ -677,16 +695,37 @@ class MockWalletQmlModel : public QObject
void feeEstimatePendingChanged();
void feeEstimateRevisionChanged();
void prepareTransactionResultChanged();
+ void sendTransactionResultChanged();
void prepareTransactionCallsChanged();
void scheduleFeeEstimatesCallsChanged();
void sendTransactionCallsChanged();
+ void securityStateChanged();
+ void transactionErrorChanged();
+ void transactionNeedsUnlockChanged();
private:
+ void setTransactionStatus(const QString& error, bool needs_unlock)
+ {
+ if (m_transaction_error != error) {
+ m_transaction_error = error;
+ Q_EMIT transactionErrorChanged();
+ }
+ if (m_transaction_needs_unlock != needs_unlock) {
+ m_transaction_needs_unlock = needs_unlock;
+ Q_EMIT transactionNeedsUnlockChanged();
+ }
+ }
+
QHash m_fee_estimates{{1, QStringLiteral("0.00000750 ₿")}, {2, QStringLiteral("0.00000500 ₿")}, {6, QStringLiteral("0.00000250 ₿")}};
bool m_custom_fee_enabled{false};
QString m_custom_fee_rate;
QString m_custom_fee_estimate;
bool m_fee_estimate_pending{false};
+ bool m_send_transaction_result{true};
+ bool m_is_encrypted{false};
+ bool m_is_locked{false};
+ QString m_transaction_error;
+ bool m_transaction_needs_unlock{false};
int m_fee_estimate_revision{1};
int m_prepare_transaction_calls{0};
int m_schedule_fee_estimates_calls{0};
diff --git a/test/test_unit_tests_main.cpp b/test/test_unit_tests_main.cpp
index 206a239ca7..f1e6788f32 100644
--- a/test/test_unit_tests_main.cpp
+++ b/test/test_unit_tests_main.cpp
@@ -22,6 +22,5 @@ int main(int argc, char* argv[])
for (const auto& test : qttestregistry::SortedEntries()) {
status |= test.run(argc, argv);
}
-
return status;
}
diff --git a/test/test_walletqmlcontroller.cpp b/test/test_walletqmlcontroller.cpp
index 983e5f9290..18e3081be5 100644
--- a/test/test_walletqmlcontroller.cpp
+++ b/test/test_walletqmlcontroller.cpp
@@ -4,18 +4,29 @@
#include
-#include
-
#include
+#include
+#include
#include
+#include
#include
#include
+#include
+
+#include
#ifndef BITCOINQML_NO_TEST_MAIN
const TranslateFn G_TRANSLATION_FUN{nullptr};
#endif
namespace {
+constexpr auto NOT_INITIALIZED_ERROR{"Wallets are still loading. Try again in a moment."};
+
+std::unique_ptr MakeNoopHandler()
+{
+ return interfaces::MakeCleanupHandler([] {});
+}
+
class FakeExternalSigner : public interfaces::ExternalSigner
{
public:
@@ -35,6 +46,93 @@ std::vector> MakeSigners(std::initia
}
return signers;
}
+
+class FakeWalletLoader : public interfaces::WalletLoader
+{
+public:
+ int create_wallet_calls{0};
+ int migrate_wallet_calls{0};
+ int handle_load_wallet_calls{0};
+ int get_wallets_calls{0};
+ int list_wallet_dir_calls{0};
+
+ std::function>(const std::string&, const SecureString&, uint64_t, std::vector&)>
+ create_wallet_fn = [](const std::string&, const SecureString&, uint64_t, std::vector&) {
+ return util::Error{Untranslated("Unexpected createWallet call")};
+ };
+ std::function(const std::string&, const SecureString&)>
+ migrate_wallet_fn = [](const std::string&, const SecureString&) {
+ return util::Error{Untranslated("Unexpected migrateWallet call")};
+ };
+ 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& name,
+ const SecureString& passphrase,
+ uint64_t wallet_creation_flags,
+ std::vector& warnings) override
+ {
+ ++create_wallet_calls;
+ return create_wallet_fn(name, passphrase, wallet_creation_flags, warnings);
+ }
+ util::Result> loadWallet(const std::string&, std::vector&) override
+ {
+ 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& name, const SecureString& passphrase) override
+ {
+ ++migrate_wallet_calls;
+ return migrate_wallet_fn(name, passphrase);
+ }
+ 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 +143,12 @@ private Q_SLOTS:
void externalSignerCreationRequiresConfiguredPath();
void externalSignerCreationRequiresExactlyOneSigner();
void externalSignerSuggestionUsesSignerName();
+ void createWalletBeforeInitializationReturnsFalseAndSetsError();
+ void importWalletBeforeInitializationSetsLoadError();
+ void migrateWalletBeforeInitializationSetsMigrationError();
+ void selectWalletBeforeInitializationSetsLoadError();
+ void initializedControllerPropagatesCreateErrors();
+ void initializedControllerForwardsMigrationPassphrase();
};
void WalletQmlControllerTests::externalSignerCreationRequiresConfiguredPath()
@@ -111,6 +215,117 @@ void WalletQmlControllerTests::externalSignerSuggestionUsesSignerName()
QCOMPARE(controller.suggestedExternalSignerWalletName(), QString("Coldcard_Mk4"));
}
+void WalletQmlControllerTests::createWalletBeforeInitializationReturnsFalseAndSetsError()
+{
+ using ::testing::StrictMock;
+
+ StrictMock node;
+ WalletQmlController controller(node);
+
+ QVERIFY(!controller.createSingleSigWallet("test_wallet", "secret"));
+ QCOMPARE(controller.walletCreateError(), QString{NOT_INITIALIZED_ERROR});
+ QVERIFY(!controller.isWalletLoaded());
+}
+
+void WalletQmlControllerTests::importWalletBeforeInitializationSetsLoadError()
+{
+ using ::testing::StrictMock;
+
+ StrictMock node;
+ WalletQmlController controller(node);
+
+ controller.importWallet("/tmp/test_wallet.dat");
+ QCOMPARE(controller.walletLoadError(), QString{NOT_INITIALIZED_ERROR});
+}
+
+void WalletQmlControllerTests::migrateWalletBeforeInitializationSetsMigrationError()
+{
+ using ::testing::StrictMock;
+
+ StrictMock node;
+ WalletQmlController controller(node);
+
+ controller.migrateWallet("legacy_wallet", "secret");
+ QCOMPARE(controller.walletMigrationError(), QString{NOT_INITIALIZED_ERROR});
+}
+
+void WalletQmlControllerTests::selectWalletBeforeInitializationSetsLoadError()
+{
+ using ::testing::StrictMock;
+
+ StrictMock node;
+ WalletQmlController controller(node);
+
+ controller.setSelectedWallet("test_wallet");
+ QCOMPARE(controller.walletLoadError(), QString{NOT_INITIALIZED_ERROR});
+}
+
+void WalletQmlControllerTests::initializedControllerPropagatesCreateErrors()
+{
+ using ::testing::StrictMock;
+
+ StrictMock node;
+ FakeWalletLoader loader;
+ ExpectControllerInitialization(node, loader);
+
+ WalletQmlController controller(node);
+ controller.initialize();
+
+ QVERIFY(controller.initialized());
+ QVERIFY(controller.noWalletsFound());
+
+ bool saw_expected_passphrase{false};
+ bool saw_expected_flags{false};
+ loader.create_wallet_fn = [&](const std::string&,
+ const SecureString& passphrase,
+ uint64_t wallet_creation_flags,
+ std::vector&) {
+ saw_expected_passphrase = (passphrase == SecureString{"secret"});
+ saw_expected_flags = (wallet_creation_flags == wallet::WALLET_FLAG_DESCRIPTORS);
+ return util::Result>{
+ util::Error{Untranslated("Wallet creation failed.")}};
+ };
+
+ QVERIFY(!controller.createSingleSigWallet("test_wallet", "secret"));
+ QVERIFY(saw_expected_passphrase);
+ QVERIFY(saw_expected_flags);
+ QCOMPARE(loader.create_wallet_calls, 1);
+ QCOMPARE(controller.walletCreateError(), QString{"Wallet creation failed."});
+ QVERIFY(!controller.isWalletLoaded());
+}
+
+void WalletQmlControllerTests::initializedControllerForwardsMigrationPassphrase()
+{
+ using ::testing::StrictMock;
+
+ StrictMock node;
+ FakeWalletLoader loader;
+ loader.wallet_dir_entries = {{"legacy_wallet", "bdb"}};
+ ExpectControllerInitialization(node, loader);
+
+ WalletQmlController controller(node);
+ controller.initialize();
+
+ QSignalSpy failed_spy(&controller, &WalletQmlController::walletMigrationFailed);
+
+ bool saw_expected_name{false};
+ bool saw_expected_passphrase{false};
+ loader.migrate_wallet_fn = [&](const std::string& name, const SecureString& passphrase) {
+ saw_expected_name = (name == "legacy_wallet");
+ saw_expected_passphrase = (passphrase == SecureString{"secret"});
+ return util::Result{
+ util::Error{Untranslated("Migration failed.")}};
+ };
+
+ controller.migrateWallet("legacy_wallet", "secret");
+ QVERIFY(failed_spy.wait(5000));
+ QVERIFY(saw_expected_name);
+ QVERIFY(saw_expected_passphrase);
+ QCOMPARE(loader.migrate_wallet_calls, 1);
+ QCOMPARE(controller.walletMigrationError(), QString{"Migration failed."});
+ QVERIFY(!controller.walletMigrationInProgress());
+}
+
#ifdef BITCOINQML_NO_TEST_MAIN
#include
BITCOINQML_REGISTER_QT_TEST(WalletQmlControllerTests)
diff --git a/test/test_walletqmlmodel.cpp b/test/test_walletqmlmodel.cpp
index 877267ad71..736b727b36 100644
--- a/test/test_walletqmlmodel.cpp
+++ b/test/test_walletqmlmodel.cpp
@@ -11,7 +11,9 @@
#include
#include
+#include
#include
+#include
#include
#include
@@ -83,6 +85,111 @@ void SetValidRecipient(WalletQmlModel& model,
QVERIFY2(recipient->isValid(), "Recipient must be valid before scheduling fee estimates");
}
+
+class FakePasswordWallet : public StubWallet
+{
+public:
+ bool encrypted{true};
+ bool locked{true};
+ bool private_keys_disabled{false};
+ CAmount balance{50'000};
+ int unlock_calls{0};
+ int lock_calls{0};
+ int commit_calls{0};
+ std::vector unlock_passphrases;
+ std::vector create_transaction_sign_args;
+ std::vector fill_psbt_sign_args;
+
+ std::function(const std::vector&,
+ const wallet::CCoinControl&,
+ bool,
+ int&,
+ CAmount&)>
+ create_transaction_fn = [](const std::vector&,
+ const wallet::CCoinControl&,
+ bool,
+ int& change_pos,
+ CAmount& fee) {
+ change_pos = -1;
+ fee = 250;
+ return MakeTransactionRef(CMutableTransaction{});
+ };
+ std::function unlock_fn = [this](const SecureString& passphrase) {
+ ++unlock_calls;
+ unlock_passphrases.emplace_back(passphrase.begin(), passphrase.end());
+ locked = false;
+ return true;
+ };
+ std::function(std::optional,
+ bool,
+ bool,
+ size_t*,
+ PartiallySignedTransaction&,
+ bool&)>
+ fill_psbt_fn = [this](std::optional,
+ bool sign,
+ bool,
+ size_t*,
+ PartiallySignedTransaction&,
+ bool& complete) {
+ fill_psbt_sign_args.push_back(sign);
+ complete = sign;
+ return std::nullopt;
+ };
+
+ bool isCrypted() override { return encrypted; }
+ bool lock() override
+ {
+ ++lock_calls;
+ locked = true;
+ return true;
+ }
+ bool unlock(const SecureString& wallet_passphrase) override { return unlock_fn(wallet_passphrase); }
+ bool isLocked() override { return locked; }
+ util::Result createTransaction(const std::vector& recipients,
+ const wallet::CCoinControl& coin_control,
+ bool sign,
+ int& change_pos,
+ CAmount& fee) override
+ {
+ create_transaction_sign_args.push_back(sign);
+ return create_transaction_fn(recipients, coin_control, sign, change_pos, fee);
+ }
+ void commitTransaction(CTransactionRef, interfaces::WalletValueMap, interfaces::WalletOrderForm) override
+ {
+ ++commit_calls;
+ }
+ std::optional fillPSBT(std::optional sighash_type,
+ bool sign,
+ bool bip32derivs,
+ size_t* n_signed,
+ PartiallySignedTransaction& psbtx,
+ bool& complete) override
+ {
+ return fill_psbt_fn(sighash_type, sign, bip32derivs, n_signed, psbtx, complete);
+ }
+ CAmount getBalance() override { return balance; }
+ bool privateKeysDisabled() override { return private_keys_disabled; }
+ OutputType getDefaultAddressType() override { return OutputType::BECH32; }
+};
+
+std::unique_ptr MakeWalletModel(FakePasswordWallet*& wallet_out)
+{
+ auto wallet = std::make_unique();
+ wallet_out = wallet.get();
+ return std::make_unique(std::move(wallet));
+}
+
+void SetPasswordRecipient(WalletQmlModel& model, qint64 satoshis)
+{
+ auto* recipient = model.sendRecipientList()->currentRecipient();
+ QVERIFY(recipient != nullptr);
+
+ recipient->address()->setAddress(VALID_MAINNET_ADDRESS, 0);
+ recipient->amount()->setSatoshi(satoshis);
+
+ QVERIFY2(recipient->isValid(), "Recipient must be valid before preparing a transaction");
+}
} // namespace
class WalletQmlModelTests : public QObject
@@ -104,6 +211,10 @@ private Q_SLOTS:
void scheduleFeeEstimates_usesSelectedCoinsInCoinControl();
void scheduleFeeEstimates_debouncesRapidRestarts();
void transactionChangedEmitsBalanceChanged();
+ void prepareTransactionOnLockedWalletRequiresPassword();
+ void prepareTransactionWithPrivateKeysDisabledDoesNotRequirePassword();
+ void sendTransactionCommitsPreparedTransactionWithoutUnlockingAgain();
+ void sendTransactionWithPrivateKeysDisabledDoesNotCommit();
};
void WalletQmlModelTests::initTestCase()
@@ -576,6 +687,75 @@ void WalletQmlModelTests::transactionChangedEmitsBalanceChanged()
QCOMPARE(model.balance(), QStringLiteral("75.00000000"));
}
+void WalletQmlModelTests::prepareTransactionOnLockedWalletRequiresPassword()
+{
+ FakePasswordWallet* wallet{nullptr};
+ auto model = MakeWalletModel(wallet);
+ SetPasswordRecipient(*model, 1'000);
+
+ QVERIFY(!model->prepareTransaction());
+ QVERIFY(model->isEncrypted());
+ QVERIFY(model->isLocked());
+ QVERIFY(model->transactionNeedsUnlock());
+ QCOMPARE(model->transactionError(), QString("Enter your wallet password to prepare this transaction."));
+ QVERIFY(wallet->create_transaction_sign_args.empty());
+ QCOMPARE(wallet->unlock_calls, 0);
+ QCOMPARE(wallet->lock_calls, 0);
+}
+
+void WalletQmlModelTests::prepareTransactionWithPrivateKeysDisabledDoesNotRequirePassword()
+{
+ FakePasswordWallet* wallet{nullptr};
+ auto model = MakeWalletModel(wallet);
+ SetPasswordRecipient(*model, 1'000);
+ wallet->private_keys_disabled = true;
+
+ QVERIFY(model->prepareTransaction());
+ QVERIFY(model->isEncrypted());
+ QVERIFY(model->isLocked());
+ QVERIFY(!model->transactionNeedsUnlock());
+ QVERIFY(wallet->create_transaction_sign_args == std::vector{false});
+ QCOMPARE(wallet->unlock_calls, 0);
+ QCOMPARE(wallet->lock_calls, 0);
+}
+
+void WalletQmlModelTests::sendTransactionCommitsPreparedTransactionWithoutUnlockingAgain()
+{
+ FakePasswordWallet* wallet{nullptr};
+ auto model = MakeWalletModel(wallet);
+ SetPasswordRecipient(*model, 1'000);
+
+ QVERIFY(model->prepareTransactionWithPassphrase("secret"));
+ QVERIFY(wallet->locked);
+ QCOMPARE(wallet->unlock_calls, 1);
+ QCOMPARE(wallet->lock_calls, 1);
+
+ QVERIFY(model->sendTransaction());
+ QCOMPARE(wallet->unlock_calls, 1);
+ QCOMPARE(wallet->lock_calls, 1);
+ QCOMPARE(wallet->commit_calls, 1);
+ QVERIFY(wallet->locked);
+ QVERIFY(wallet->fill_psbt_sign_args.empty());
+ QVERIFY(model->transactionError().isEmpty());
+ QVERIFY(!model->transactionNeedsUnlock());
+}
+
+void WalletQmlModelTests::sendTransactionWithPrivateKeysDisabledDoesNotCommit()
+{
+ FakePasswordWallet* wallet{nullptr};
+ auto model = MakeWalletModel(wallet);
+ SetPasswordRecipient(*model, 1'000);
+ wallet->private_keys_disabled = true;
+
+ QVERIFY(model->prepareTransaction());
+ QVERIFY(wallet->locked);
+
+ QVERIFY(!model->sendTransaction());
+ QCOMPARE(model->transactionError(), QString("This wallet cannot sign transactions."));
+ QCOMPARE(wallet->commit_calls, 0);
+ QVERIFY(wallet->fill_psbt_sign_args.empty());
+}
+
#ifdef BITCOINQML_NO_TEST_MAIN
#include
BITCOINQML_REGISTER_QT_TEST(WalletQmlModelTests)