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)