diff --git a/qml/bitcoin_qml.qrc b/qml/bitcoin_qml.qrc index 2dd1f5f2f8..7c3f7e3955 100644 --- a/qml/bitcoin_qml.qrc +++ b/qml/bitcoin_qml.qrc @@ -23,6 +23,8 @@ components/ThemeSettings.qml components/TotalBytesIndicator.qml components/Tooltip.qml + components/WalletMigrationPopup.qml + components/WalletPassphrasePopup.qml controls/AddWalletButton.qml controls/CaretRightIcon.qml controls/ContinueButton.qml diff --git a/qml/components/BitcoinAddressInputField.qml b/qml/components/BitcoinAddressInputField.qml index c6b049e2fe..c0babbfe3d 100644 --- a/qml/components/BitcoinAddressInputField.qml +++ b/qml/components/BitcoinAddressInputField.qml @@ -16,6 +16,8 @@ ColumnLayout { property string errorText: "" property string labelText: qsTr("Send to") property bool enabled: true + property alias text: addressInput.text + property string inputObjectName: "" signal editingFinished() @@ -40,6 +42,7 @@ ColumnLayout { TextArea { id: addressInput + objectName: root.inputObjectName anchors.left: label.right anchors.right: parent.right anchors.top: parent.top 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 62f8f89b36..3d283e4003 100644 --- a/qml/models/walletqmlmodel.cpp +++ b/qml/models/walletqmlmodel.cpp @@ -5,6 +5,7 @@ #include +#include #include #include #include @@ -16,13 +17,17 @@ #include #include #include +#include #include #include #include +#include +#include #include #include #include +#include namespace { struct QmlReceiveRequestRecipient @@ -58,6 +63,18 @@ 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); +} + +bool NeedsUnlockForPreviewBuild(const QString& error) +{ + return error.contains("Transaction needs a change address", Qt::CaseInsensitive) || + error.contains("Keypool ran out", Qt::CaseInsensitive) || + error.contains("generate it", Qt::CaseInsensitive); +} } // namespace WalletQmlModel::WalletQmlModel(std::unique_ptr wallet, QObject *parent) @@ -68,6 +85,8 @@ WalletQmlModel::WalletQmlModel(std::unique_ptr wallet, QObje m_coins_list_model = new CoinsListModel(this); m_send_recipients = new SendRecipientsListModel(this); m_current_payment_request = new PaymentRequest(this); + refreshSecurityState(); + subscribeToWalletSignals(); } WalletQmlModel::WalletQmlModel(QObject* parent) @@ -81,6 +100,7 @@ WalletQmlModel::WalletQmlModel(QObject* parent) WalletQmlModel::~WalletQmlModel() { + unsubscribeFromWalletSignals(); delete m_activity_list_model; delete m_coins_list_model; delete m_send_recipients; @@ -250,7 +270,24 @@ std::unique_ptr WalletQmlModel::handleTransactionChanged(Tr 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; + } + + bool relock{false}; + if (!unlockForAction(passphrase, relock)) { return false; } @@ -266,12 +303,17 @@ 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; } int nChangePosRet = -1; CAmount nFeeRequired = 0; - const auto& res = m_wallet->createTransaction(vecSend, m_coin_control, true, nChangePosRet, nFeeRequired); + const auto& res = m_wallet->createTransaction(vecSend, m_coin_control, false, nChangePosRet, nFeeRequired); if (res) { if (m_current_transaction) { delete m_current_transaction; @@ -280,27 +322,99 @@ bool WalletQmlModel::prepareTransaction() m_current_transaction = new WalletQmlModelTransaction(m_send_recipients, this); m_current_transaction->setWtx(newTx); m_current_transaction->setTransactionFee(nFeeRequired); + if (relock) { + m_wallet->lock(); + refreshSecurityState(); + } Q_EMIT currentTransactionChanged(); return true; - } else { - return false; } + + if (relock) { + m_wallet->lock(); + refreshSecurityState(); + } + const QString error = LocalizedString(util::ErrorString(res)); + const bool needs_unlock = !passphrase.has_value() && m_is_encrypted && m_is_locked && NeedsUnlockForPreviewBuild(error); + setTransactionStatus(error, needs_unlock); + return false; } -void WalletQmlModel::sendTransaction() +bool WalletQmlModel::sendTransaction() { + return sendTransactionInternal(std::nullopt); +} + +bool WalletQmlModel::sendTransactionWithPassphrase(const QString& passphrase) +{ + return sendTransactionInternal(passphrase); +} + +bool WalletQmlModel::sendTransactionInternal(const std::optional& passphrase) +{ + clearTransactionStatus(); if (!m_wallet || !m_current_transaction) { - return; + setTransactionStatus(tr("Review a transaction before sending it.")); + return false; + } + if (m_wallet->isCrypted() && m_wallet->isLocked() && !passphrase.has_value()) { + setTransactionStatus(tr("Enter your wallet password to sign this transaction.")); + return false; } - CTransactionRef newTx = m_current_transaction->getWtx(); - if (!newTx) { - return; + CTransactionRef preview_tx = m_current_transaction->getWtx(); + if (!preview_tx) { + setTransactionStatus(tr("Review a transaction before sending it.")); + return false; } + bool relock{false}; + if (!unlockForAction(passphrase, relock)) { + return false; + } + + CMutableTransaction mutable_tx(*preview_tx); + PartiallySignedTransaction psbtx(mutable_tx); + bool complete{false}; + if (const auto err = m_wallet->fillPSBT(std::nullopt, /*sign=*/false, /*bip32derivs=*/true, nullptr, psbtx, complete)) { + if (relock) { + m_wallet->lock(); + refreshSecurityState(); + } + setTransactionStatus(LocalizedString(common::PSBTErrorString(*err))); + return false; + } + if (const auto err = m_wallet->fillPSBT(std::nullopt, /*sign=*/true, /*bip32derivs=*/false, nullptr, psbtx, complete)) { + if (relock) { + m_wallet->lock(); + refreshSecurityState(); + } + setTransactionStatus(LocalizedString(common::PSBTErrorString(*err))); + return false; + } + + CMutableTransaction finalized_tx; + if (!FinalizeAndExtractPSBT(psbtx, finalized_tx)) { + if (relock) { + m_wallet->lock(); + refreshSecurityState(); + } + setTransactionStatus(tr("The transaction could not be finalized.")); + return false; + } + + CTransactionRef new_tx = MakeTransactionRef(std::move(finalized_tx)); interfaces::WalletValueMap value_map; interfaces::WalletOrderForm order_form; - m_wallet->commitTransaction(newTx, value_map, order_form); + m_wallet->commitTransaction(new_tx, value_map, order_form); + m_current_transaction->setWtx(new_tx); + + if (relock) { + m_wallet->lock(); + refreshSecurityState(); + } + clearTransactionStatus(); + return true; } interfaces::Wallet::CoinsList WalletQmlModel::listCoins() const @@ -375,3 +489,71 @@ void WalletQmlModel::setFeeTargetBlocks(unsigned int target_blocks) Q_EMIT feeTargetBlocksChanged(); } } + +void WalletQmlModel::subscribeToWalletSignals() +{ + if (!m_wallet) { + return; + } + m_handler_status_changed = m_wallet->handleStatusChanged([this]() { + QMetaObject::invokeMethod(this, [this]() { + refreshSecurityState(); + }, Qt::QueuedConnection); + }); +} + +void WalletQmlModel::unsubscribeFromWalletSignals() +{ + if (m_handler_status_changed) { + m_handler_status_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 4e4636a090..5625b52954 100644 --- a/qml/models/walletqmlmodel.h +++ b/qml/models/walletqmlmodel.h @@ -18,6 +18,7 @@ #include #include +#include #include #include @@ -34,6 +35,10 @@ class WalletQmlModel : public QObject Q_PROPERTY(WalletQmlModelTransaction* currentTransaction READ currentTransaction NOTIFY currentTransactionChanged) Q_PROPERTY(unsigned int targetBlocks READ feeTargetBlocks WRITE setFeeTargetBlocks NOTIFY feeTargetBlocksChanged) Q_PROPERTY(bool isWalletLoaded READ isWalletLoaded NOTIFY walletIsLoadedChanged) + 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); @@ -51,7 +56,9 @@ class WalletQmlModel : public QObject PaymentRequest* currentPaymentRequest() const { return m_current_payment_request; } WalletQmlModelTransaction* currentTransaction() const { return m_current_transaction; } Q_INVOKABLE bool prepareTransaction(); - Q_INVOKABLE void sendTransaction(); + Q_INVOKABLE bool prepareTransactionWithPassphrase(const QString& passphrase); + Q_INVOKABLE bool sendTransaction(); + Q_INVOKABLE bool sendTransactionWithPassphrase(const QString& passphrase); Q_INVOKABLE QString newAddress(QString label); std::set getWalletTxs() const; @@ -79,6 +86,10 @@ class WalletQmlModel : public QObject bool isWalletLoaded() const { return m_is_wallet_loaded; } void setWalletLoaded(bool loaded); + 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(); @@ -86,9 +97,20 @@ class WalletQmlModel : public QObject void currentTransactionChanged(); void feeTargetBlocksChanged(); void walletIsLoadedChanged(); + void securityStateChanged(); + void transactionErrorChanged(); + void transactionNeedsUnlockChanged(); private: unsigned int nextPaymentRequestId() const; + void subscribeToWalletSignals(); + void unsubscribeFromWalletSignals(); + void refreshSecurityState(); + bool prepareTransactionInternal(const std::optional& passphrase); + bool sendTransactionInternal(const std::optional& passphrase); + 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}; @@ -98,6 +120,11 @@ class WalletQmlModel : public QObject WalletQmlModelTransaction* m_current_transaction{nullptr}; wallet::CCoinControl m_coin_control; 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; }; #endif // BITCOIN_QML_MODELS_WALLETQMLMODEL_H diff --git a/qml/pages/main.qml b/qml/pages/main.qml index 2225048669..6f1b894969 100644 --- a/qml/pages/main.qml +++ b/qml/pages/main.qml @@ -70,6 +70,7 @@ ApplicationWindow { onFinished: { optionsModel.onboard() if (AppMode.walletEnabled && AppMode.isDesktop) { + nodeModel.startNodeInitializionThread() main.push([ desktopWallets, {}, createWalletWizard, { "launchContext": CreateWalletWizard.Context.Onboarding } @@ -144,6 +145,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 a4f835511c..1c960a424b 100644 --- a/qml/pages/wallet/Activity.qml +++ b/qml/pages/wallet/Activity.qml @@ -32,6 +32,7 @@ PageStack { header: CoreText { id: title + objectName: "walletActivityTitle" horizontalAlignment: Text.AlignLeft text: qsTr("Activity") font.pixelSize: 21 diff --git a/qml/pages/wallet/CreateBackup.qml b/qml/pages/wallet/CreateBackup.qml index 603b5ae989..1924f7df18 100644 --- a/qml/pages/wallet/CreateBackup.qml +++ b/qml/pages/wallet/CreateBackup.qml @@ -62,6 +62,7 @@ Page { } ContinueButton { + objectName: "createWalletBackupViewFileButton" Layout.preferredWidth: Math.min(300, parent.width - 2 * Layout.leftMargin) Layout.topMargin: 30 Layout.leftMargin: 20 @@ -78,6 +79,7 @@ Page { } ContinueButton { + objectName: "createWalletBackupDoneButton" Layout.preferredWidth: Math.min(300, parent.width - 2 * Layout.leftMargin) Layout.topMargin: 30 Layout.leftMargin: 20 @@ -89,4 +91,4 @@ Page { } } } -} \ No newline at end of file +} diff --git a/qml/pages/wallet/CreateConfirm.qml b/qml/pages/wallet/CreateConfirm.qml index d99e7d41a6..c1849827a7 100644 --- a/qml/pages/wallet/CreateConfirm.qml +++ b/qml/pages/wallet/CreateConfirm.qml @@ -62,6 +62,7 @@ Page { } ContinueButton { + objectName: "createWalletConfirmNextButton" Layout.preferredWidth: Math.min(300, parent.width - 2 * Layout.leftMargin) Layout.topMargin: 30 Layout.leftMargin: 20 @@ -73,4 +74,4 @@ Page { } } } -} \ No newline at end of file +} diff --git a/qml/pages/wallet/CreateIntro.qml b/qml/pages/wallet/CreateIntro.qml index 802a1d2a63..1d7cd38cb5 100644 --- a/qml/pages/wallet/CreateIntro.qml +++ b/qml/pages/wallet/CreateIntro.qml @@ -102,6 +102,7 @@ Page { } ContinueButton { + objectName: "createWalletIntroStartButton" Layout.preferredWidth: Math.min(300, parent.width - 2 * Layout.leftMargin) Layout.topMargin: 30 Layout.leftMargin: 20 @@ -113,4 +114,4 @@ Page { } } } -} \ No newline at end of file +} diff --git a/qml/pages/wallet/CreateName.qml b/qml/pages/wallet/CreateName.qml index e216801162..e1d1271154 100644 --- a/qml/pages/wallet/CreateName.qml +++ b/qml/pages/wallet/CreateName.qml @@ -44,6 +44,7 @@ Page { CoreTextField { id: walletNameInput + objectName: "createWalletNameField" focus: true Layout.fillWidth: true Layout.leftMargin: 20 @@ -57,6 +58,7 @@ Page { ContinueButton { id: continueButton + objectName: "createWalletNameContinueButton" Layout.preferredWidth: Math.min(300, parent.width - 2 * Layout.leftMargin) Layout.leftMargin: 20 Layout.rightMargin: 20 @@ -70,4 +72,4 @@ Page { } } } -} \ No newline at end of file +} diff --git a/qml/pages/wallet/CreatePassword.qml b/qml/pages/wallet/CreatePassword.qml index 595443e2ac..28e17536c9 100644 --- a/qml/pages/wallet/CreatePassword.qml +++ b/qml/pages/wallet/CreatePassword.qml @@ -18,6 +18,8 @@ Page { required property string walletName; + Component.onCompleted: walletController.clearWalletCreateStatus() + header: NavigationBar2 { id: navbar leftItem: NavButton { @@ -28,10 +30,13 @@ Page { } } rightItem: NavButton { + objectName: "createWalletPasswordSkipButton" text: qsTr("Skip") + enabled: walletController.initialized onClicked: { - walletController.createSingleSigWallet(walletName, "") - root.next() + if (walletController.createSingleSigWallet(walletName, "")) { + root.next() + } } } } @@ -61,6 +66,7 @@ Page { CoreTextField { id: password + objectName: "createWalletPasswordField" Layout.fillWidth: true Layout.topMargin: 5 Layout.leftMargin: 20 @@ -79,6 +85,7 @@ Page { } CoreTextField { id: passwordRepeat + objectName: "createWalletPasswordRepeatField" Layout.fillWidth: true Layout.leftMargin: 20 Layout.rightMargin: 20 @@ -88,6 +95,7 @@ Page { Setting { id: confirmToggle + objectName: "createWalletPasswordConfirmToggle" Layout.fillWidth: true Layout.leftMargin: 20 Layout.rightMargin: 20 @@ -101,17 +109,31 @@ Page { } ContinueButton { + objectName: "createWalletPasswordContinueButton" Layout.preferredWidth: Math.min(300, parent.width - 2 * Layout.leftMargin) Layout.topMargin: 40 Layout.leftMargin: 20 Layout.rightMargin: Layout.leftMargin Layout.alignment: Qt.AlignCenter text: qsTr("Continue") - enabled: password.text != "" && passwordRepeat.text != "" && password.text == passwordRepeat.text && confirmToggle.loadedItem.checked + enabled: walletController.initialized && password.text != "" && passwordRepeat.text != "" && password.text == passwordRepeat.text && confirmToggle.loadedItem.checked onClicked: { - walletController.createSingleSigWallet(walletName, password.text) - root.next() + 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 + } } -} \ No newline at end of file +} diff --git a/qml/pages/wallet/CreateWalletWizard.qml b/qml/pages/wallet/CreateWalletWizard.qml index 1f7ec6ea6a..2fbf0dd51f 100644 --- a/qml/pages/wallet/CreateWalletWizard.qml +++ b/qml/pages/wallet/CreateWalletWizard.qml @@ -78,6 +78,7 @@ PageStack { Layout.rightMargin: Layout.leftMargin Layout.bottomMargin: 20 Layout.alignment: Qt.AlignCenter + enabled: walletController.initialized text: qsTr("Create wallet") onClicked: { root.push(intro) @@ -90,6 +91,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 b712f13656..13591ad887 100644 --- a/qml/pages/wallet/DesktopWallets.qml +++ b/qml/pages/wallet/DesktopWallets.qml @@ -17,6 +17,8 @@ Page { id: root background: null + property string pendingMigrationPath: "" + ButtonGroup { id: navigationTabs } signal addWallet() @@ -27,6 +29,31 @@ Page { function onOpenWalletSettingsRequested() { settingsTabButton.checked = true } + 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) { + migrationRequiredPopup.close() + migrationPassphrasePopup.busy = false + migrationPassphrasePopup.errorText = walletController.walletMigrationError + migrationPassphrasePopup.open() + } else { + migrationRequiredPopup.busy = false + migrationRequiredPopup.errorText = walletController.walletMigrationError + migrationRequiredPopup.open() + } + } } header: NavigationBar2 { @@ -68,16 +95,19 @@ Page { visible: walletController.isWalletLoaded NavigationTab { id: activityTabButton + objectName: "walletActivityTab" text: qsTr("Activity") property int index: 0 ButtonGroup.group: navigationTabs } NavigationTab { + objectName: "walletSendTab" text: qsTr("Send") property int index: 1 ButtonGroup.group: navigationTabs } NavigationTab { + objectName: "walletReceiveTab" text: qsTr("Receive") property int index: 2 ButtonGroup.group: navigationTabs @@ -167,5 +197,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/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 0de43147c8..a989db035b 100644 --- a/qml/pages/wallet/MultipleSendReview.qml +++ b/qml/pages/wallet/MultipleSendReview.qml @@ -142,14 +142,57 @@ Page { ContinueButton { id: confirmationButton + objectName: "sendTransactionButton" Layout.fillWidth: true Layout.topMargin: 30 text: qsTr("Send") onClicked: { - root.wallet.sendTransaction() - root.transactionSent() + if (root.wallet.isEncrypted && root.wallet.isLocked) { + sendPassphrasePopup.errorText = "" + sendPassphrasePopup.open() + return + } + if (root.wallet.sendTransaction()) { + root.transactionSent() + } } } + + CoreText { + objectName: "sendTransactionErrorText" + Layout.fillWidth: true + visible: text.length > 0 + text: root.wallet.transactionError + color: Theme.color.red + font.pixelSize: 15 + wrapMode: Text.WordWrap + } + } + } + + WalletPassphrasePopup { + id: sendPassphrasePopup + parent: Overlay.overlay + width: Math.min(420, root.width - 40) + popupObjectName: "sendPassphrasePopup" + passphraseFieldObjectName: "sendPassphraseField" + errorTextObjectName: "sendPassphraseErrorText" + cancelButtonObjectName: "sendPassphraseCancelButton" + confirmButtonObjectName: "sendPassphraseConfirmButton" + titleText: qsTr("Enter wallet password") + descriptionText: qsTr("Enter your wallet password to sign and send this transaction.") + confirmText: qsTr("Sign and send") + busyConfirmText: qsTr("Signing...") + onSubmitted: (passphrase) => { + sendPassphrasePopup.busy = true + if (root.wallet.sendTransactionWithPassphrase(passphrase)) { + sendPassphrasePopup.busy = false + sendPassphrasePopup.close() + root.transactionSent() + return + } + sendPassphrasePopup.busy = false + sendPassphrasePopup.errorText = root.wallet.transactionError } } } diff --git a/qml/pages/wallet/Send.qml b/qml/pages/wallet/Send.qml index b1f5b85f3b..b55fbc6892 100644 --- a/qml/pages/wallet/Send.qml +++ b/qml/pages/wallet/Send.qml @@ -76,6 +76,7 @@ PageStack { CoreText { id: title + objectName: "walletSendTitle" anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter text: qsTr("Send bitcoin") @@ -170,10 +171,12 @@ PageStack { } BitcoinAddressInputField { + objectName: "sendAddressInput" Layout.fillWidth: true enabled: walletController.initialized address: root.recipient.address errorText: root.recipient.addressError + inputObjectName: "sendAddressField" } Separator { @@ -198,6 +201,7 @@ PageStack { TextField { id: amountInput + objectName: "sendAmountField" anchors.left: amountLabel.right anchors.verticalCenter: parent.verticalCenter leftPadding: 0 @@ -210,6 +214,11 @@ PageStack { placeholderText: "0.00000000" selectByMouse: true text: root.recipient.amount.display + onTextChanged: { + if (text !== root.recipient.amount.display) { + root.recipient.amount.display = text + } + } onTextEdited: root.recipient.amount.display = text onEditingFinished: root.recipient.amount.format() onActiveFocusChanged: { @@ -277,6 +286,7 @@ PageStack { } LabeledTextInput { + objectName: "sendNoteInput" id: label Layout.fillWidth: true labelText: qsTr("Note to self") @@ -316,16 +326,30 @@ PageStack { ContinueButton { id: continueButton + objectName: "sendReviewButton" Layout.fillWidth: true Layout.topMargin: 30 text: qsTr("Review") enabled: root.recipient.isValid onClicked: { if (root.wallet.prepareTransaction()) { - root.transactionPrepared(settings.multipleRecipientsEnabled); + root.transactionPrepared(settings.multipleRecipientsEnabled) + } else if (root.wallet.transactionNeedsUnlock) { + reviewPassphrasePopup.errorText = "" + reviewPassphrasePopup.open() } } } + + CoreText { + objectName: "sendReviewErrorText" + Layout.fillWidth: true + visible: text.length > 0 && !root.wallet.transactionNeedsUnlock + text: root.wallet.transactionError + color: Theme.color.red + font.pixelSize: 15 + wrapMode: Text.WordWrap + } } } } @@ -336,4 +360,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("This wallet needs to create a change address before the transaction review can be shown.") + 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/SendResult.qml b/qml/pages/wallet/SendResult.qml index 3b119aa7c4..e6f112afcc 100644 --- a/qml/pages/wallet/SendResult.qml +++ b/qml/pages/wallet/SendResult.qml @@ -13,6 +13,7 @@ import "../../components" Popup { id: root + objectName: "sendResultPopup" modal: true anchors.centerIn: parent diff --git a/qml/pages/wallet/SendReview.qml b/qml/pages/wallet/SendReview.qml index fb2d073637..02cbc1f6d1 100644 --- a/qml/pages/wallet/SendReview.qml +++ b/qml/pages/wallet/SendReview.qml @@ -126,14 +126,57 @@ Page { ContinueButton { id: confimationButton + objectName: "sendTransactionButton" Layout.fillWidth: true Layout.topMargin: 30 text: qsTr("Send") onClicked: { - root.wallet.sendTransaction() - root.transactionSent() + if (root.wallet.isEncrypted && root.wallet.isLocked) { + sendPassphrasePopup.errorText = "" + sendPassphrasePopup.open() + return + } + if (root.wallet.sendTransaction()) { + root.transactionSent() + } } } + + CoreText { + objectName: "sendTransactionErrorText" + Layout.fillWidth: true + visible: text.length > 0 + text: root.wallet.transactionError + color: Theme.color.red + font.pixelSize: 15 + wrapMode: Text.WordWrap + } + } + } + + WalletPassphrasePopup { + id: sendPassphrasePopup + parent: Overlay.overlay + width: Math.min(420, root.width - 40) + popupObjectName: "sendPassphrasePopup" + passphraseFieldObjectName: "sendPassphraseField" + errorTextObjectName: "sendPassphraseErrorText" + cancelButtonObjectName: "sendPassphraseCancelButton" + confirmButtonObjectName: "sendPassphraseConfirmButton" + titleText: qsTr("Enter wallet password") + descriptionText: qsTr("Enter your wallet password to sign and send this transaction.") + confirmText: qsTr("Sign and send") + busyConfirmText: qsTr("Signing...") + onSubmitted: (passphrase) => { + sendPassphrasePopup.busy = true + if (root.wallet.sendTransactionWithPassphrase(passphrase)) { + sendPassphrasePopup.busy = false + sendPassphrasePopup.close() + root.transactionSent() + return + } + sendPassphrasePopup.busy = false + sendPassphrasePopup.errorText = root.wallet.transactionError } } } diff --git a/qml/pages/wallet/WalletSelect.qml b/qml/pages/wallet/WalletSelect.qml index 9a8a124c6b..29aec1810d 100644 --- a/qml/pages/wallet/WalletSelect.qml +++ b/qml/pages/wallet/WalletSelect.qml @@ -78,6 +78,7 @@ Popup { delegate: WalletBadge { required property string name; + objectName: "walletSelectItem_" + name.replace(/[^A-Za-z0-9_]/g, "_") width: 220 height: 32 text: name diff --git a/qml/walletqmlcontroller.cpp b/qml/walletqmlcontroller.cpp index 6c9e4a641a..ae026b0198 100644 --- a/qml/walletqmlcontroller.cpp +++ b/qml/walletqmlcontroller.cpp @@ -101,6 +101,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) { @@ -134,10 +139,15 @@ void WalletQmlController::unloadWallets() m_wallets.clear(); } -void WalletQmlController::createSingleSigWallet(const QString &name, const QString &passphrase) +bool WalletQmlController::createSingleSigWallet(const QString &name, const QString &passphrase) { + clearWalletCreateStatus(); clearWalletLoadStatus(); clearWalletMigrationStatus(); + 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)}; @@ -146,17 +156,30 @@ void WalletQmlController::createSingleSigWallet(const QString &name, const QStri m_selected_wallet = new WalletQmlModel(std::move(*wallet)); m_wallets.push_back(m_selected_wallet); setNoWalletsFound(false); + setWalletLoaded(true); Q_EMIT selectedWalletChanged(); + return true; } else { - m_error_message = util::ErrorString(wallet); + const bilingual_str error = util::ErrorString(wallet); + setWalletCreateError(QString::fromStdString(error.translated.empty() ? error.original : error.translated)); + return false; } } 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); @@ -164,9 +187,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() @@ -568,7 +595,7 @@ void WalletQmlController::startWalletLoad(const QString& path) }); } -void WalletQmlController::startWalletMigration(const QString& path) +void WalletQmlController::startWalletMigration(const QString& path, const QString& passphrase) { clearWalletLoadStatus(); clearWalletMigrationStatus(); @@ -588,9 +615,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); @@ -610,6 +637,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 d9943588ce..fdfe405e1e 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) @@ -40,10 +41,11 @@ class WalletQmlController : public QObject ~WalletQmlController(); Q_INVOKABLE void setSelectedWallet(QString path); - Q_INVOKABLE void createSingleSigWallet(const QString &name, const QString &passphrase); + Q_INVOKABLE bool createSingleSigWallet(const QString &name, const QString &passphrase); 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; @@ -62,6 +64,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; } @@ -75,6 +78,7 @@ class WalletQmlController : public QObject void walletLoadInProgressChanged(); void walletLoadErrorChanged(); void walletLoadWarningsChanged(); + void walletCreateErrorChanged(); void walletLoadSucceeded(); void walletImportSucceeded(); void walletMigrationInProgressChanged(); @@ -98,11 +102,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); @@ -126,13 +131,13 @@ 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; QString m_last_imported_wallet_name; QString m_last_imported_wallet_key_scheme; - bilingual_str m_error_message; std::vector m_warning_messages; }; diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index a104d4242a..ac38ef0730 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -19,6 +19,8 @@ add_executable(bitcoinqml_unit_tests test_networkstyle.cpp test_qmlinitexecutor_api.cpp test_options_model.cpp + test_walletqmlmodel.cpp + test_walletqmlcontroller.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../bitcoin/src/util/chaintype.cpp ) diff --git a/test/functional/qml_test_password_wallet.py b/test/functional/qml_test_password_wallet.py new file mode 100644 index 0000000000..c7af376920 --- /dev/null +++ b/test/functional/qml_test_password_wallet.py @@ -0,0 +1,479 @@ +#!/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("createWalletNameField", wallet_name) + gui.click("createWalletNameContinueButton") + gui.set_text("createWalletPasswordField", password) + gui.set_text("createWalletPasswordRepeatField", 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("walletSendTab") + gui.wait_for_property("walletSendTitle", "visible", True, timeout_ms=5000) + + +def fill_send_form(gui, address, amount): + gui.set_text("sendAddressField", address) + gui.set_text("sendAmountField", 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) + gui.click(f"walletSelectItem_{sanitize_object_suffix(wallet_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("walletActivityTab") + 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("sendTransactionButton", "visible", True, timeout_ms=10000) + assert gui.get_property("reviewPassphrasePopup", "opened") is False, "Review should not require unlock when change keypool is available" + checkpoints.checkpoint("review built while locked", gui) + + before = rpc_call(harness.gui_rpc_port, "getwalletinfo", wallet=wallet_name)["txcount"] + gui.click("sendTransactionButton") + gui.wait_for_property("sendPassphrasePopup", "opened", True, timeout_ms=5000) + checkpoints.checkpoint("final send passphrase prompt displayed", gui) + gui.set_text("sendPassphraseField", WALLET_PASSWORD) + gui.click("sendPassphraseConfirmButton") + gui.wait_for_property("sendResultPopup", "opened", True, timeout_ms=20000) + checkpoints.checkpoint("signed 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("sendTransactionButton", "visible", True, timeout_ms=20000) + assert_wallet_locked(harness.gui_rpc_port, wallet_name) + checkpoints.checkpoint("review rebuilt and wallet relocked", gui) + + gui.click("sendTransactionButton") + gui.wait_for_property("sendPassphrasePopup", "opened", True, timeout_ms=5000) + checkpoints.checkpoint("final send prompt displayed after review fallback", gui) + gui.set_text("sendPassphraseField", WALLET_PASSWORD) + gui.click("sendPassphraseConfirmButton") + 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("walletActivityTab") + 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) + checkpoints.checkpoint("migration passphrase prompt displayed", gui) + + gui.set_text("walletMigrationPassphraseField", "wrong password") + gui.click("walletMigrationPassphraseConfirmButton") + error_text = gui.get_text("walletMigrationPassphraseErrorText") + assert "passphrase" in error_text.lower(), f"Unexpected migration error text: {error_text!r}" + 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_wallet_test_lib.py b/test/functional/qml_wallet_test_lib.py index fed967d22c..5bc250f905 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", @@ -197,8 +197,7 @@ def start_gui(self, reset_gui_settings=False): self.gui_process = subprocess.Popen(args, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE) self.driver = QmlDriver(self.socket_path, timeout=GUI_STARTUP_TIMEOUT) - def stop(self): - self.stop_source_node() + def stop_gui(self): if self.gui_process and self.gui_process.poll() is None: self.gui_process.send_signal(signal.SIGTERM) try: @@ -210,6 +209,10 @@ def stop(self): if self.driver: self.driver.close() self.driver = None + + def stop(self): + self.stop_source_node() + self.stop_gui() if os.getenv("KEEP_QML_TEST_TMPDIR") == "1": print(f"Preserving test directory: {self.tmpdir}") else: diff --git a/test/test_unit_tests_main.cpp b/test/test_unit_tests_main.cpp index fbb490e5bc..0ca4367b32 100644 --- a/test/test_unit_tests_main.cpp +++ b/test/test_unit_tests_main.cpp @@ -16,6 +16,8 @@ int RunImageProviderTests(int argc, char* argv[]); int RunNetworkStyleTests(int argc, char* argv[]); int RunQmlInitExecutorApiTests(int argc, char* argv[]); int RunOptionsModelTests(int argc, char* argv[]); +int RunWalletQmlModelTests(int argc, char* argv[]); +int RunWalletQmlControllerTests(int argc, char* argv[]); int main(int argc, char* argv[]) { @@ -30,6 +32,8 @@ int main(int argc, char* argv[]) status |= RunNetworkStyleTests(argc, argv); status |= RunQmlInitExecutorApiTests(argc, argv); status |= RunOptionsModelTests(argc, argv); + status |= RunWalletQmlModelTests(argc, argv); + status |= RunWalletQmlControllerTests(argc, argv); return status; } diff --git a/test/test_walletqmlcontroller.cpp b/test/test_walletqmlcontroller.cpp new file mode 100644 index 0000000000..c4fd8b74f4 --- /dev/null +++ b/test/test_walletqmlcontroller.cpp @@ -0,0 +1,236 @@ +// 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. + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { +constexpr auto NOT_INITIALIZED_ERROR{"Wallets are still loading. Try again in a moment."}; + +std::unique_ptr MakeNoopHandler() +{ + return interfaces::MakeCleanupHandler([] {}); +} + +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::AtLeast; + using ::testing::ReturnRef; + + ON_CALL(node, walletLoader()).WillByDefault(ReturnRef(loader)); + EXPECT_CALL(node, walletLoader()).Times(AtLeast(3)).WillRepeatedly(ReturnRef(loader)); +} +} // namespace + +class WalletQmlControllerTests : public QObject +{ + Q_OBJECT + +private Q_SLOTS: + void createWalletBeforeInitializationReturnsFalseAndSetsError(); + void importWalletBeforeInitializationSetsLoadError(); + void migrateWalletBeforeInitializationSetsMigrationError(); + void selectWalletBeforeInitializationSetsLoadError(); + void initializedControllerPropagatesCreateErrors(); + void initializedControllerForwardsMigrationPassphrase(); +}; + +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::_; + 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()); +} + +int RunWalletQmlControllerTests(int argc, char* argv[]) +{ + WalletQmlControllerTests tc; + return QTest::qExec(&tc, argc, argv); +} + +#include "test_walletqmlcontroller.moc" diff --git a/test/test_walletqmlmodel.cpp b/test/test_walletqmlmodel.cpp new file mode 100644 index 0000000000..2eabb38daf --- /dev/null +++ b/test/test_walletqmlmodel.cpp @@ -0,0 +1,276 @@ +// 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. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +constexpr auto REGTEST_ADDRESS{"bcrt1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3xueyj"}; + +std::unique_ptr MakeNoopHandler() +{ + return interfaces::MakeCleanupHandler([] {}); +} + +class FakeWallet : public interfaces::Wallet +{ +public: + bool encrypted{true}; + bool locked{true}; + 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 encryptWallet(const SecureString&) override { return true; } + 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; } + bool changeWalletPassphrase(const SecureString&, const SecureString&) override { return true; } + void abortRescan() override {} + bool backupWallet(const std::string&) override { return true; } + std::string getWalletName() override { return "fake-wallet"; } + util::Result getNewDestination(const OutputType, const std::string&) override + { + return CTxDestination{CNoDestination{}}; + } + bool getPubKey(const CScript&, const CKeyID&, CPubKey&) override { return false; } + SigningResult signMessage(const std::string&, const PKHash&, std::string&) override + { + return SigningResult::PRIVATE_KEY_NOT_AVAILABLE; + } + bool isSpendable(const CTxDestination&) override { return false; } + bool setAddressBook(const CTxDestination&, const std::string&, const std::optional&) override { return true; } + bool delAddressBook(const CTxDestination&) override { return true; } + bool getAddress(const CTxDestination&, std::string*, wallet::isminetype*, wallet::AddressPurpose*) override { return false; } + std::vector getAddresses() override { return {}; } + std::vector getAddressReceiveRequests() override { return {}; } + bool setAddressReceiveRequest(const CTxDestination&, const std::string&, const std::string&) override { return true; } + util::Result displayAddress(const CTxDestination&) override { return {}; } + bool lockCoin(const COutPoint&, const bool) override { return true; } + bool unlockCoin(const COutPoint&) override { return true; } + bool isLockedCoin(const COutPoint&) override { return false; } + void listLockedCoins(std::vector& outputs) override { outputs.clear(); } + 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; + } + bool transactionCanBeAbandoned(const Txid&) override { return false; } + bool abandonTransaction(const Txid&) override { return false; } + bool transactionCanBeBumped(const Txid&) override { return false; } + bool createBumpTransaction(const Txid&, const wallet::CCoinControl&, std::vector&, CAmount&, CAmount&, CMutableTransaction&) override + { + return false; + } + bool signBumpTransaction(CMutableTransaction&) override { return false; } + bool commitBumpTransaction(const Txid&, CMutableTransaction&&, std::vector&, Txid&) override { return false; } + CTransactionRef getTx(const Txid&) override { return {}; } + interfaces::WalletTx getWalletTx(const Txid&) override { return {}; } + std::set getWalletTxs() override { return {}; } + bool tryGetTxStatus(const Txid&, interfaces::WalletTxStatus&, int&, int64_t&) override { return false; } + interfaces::WalletTx getWalletTxDetails(const Txid&, interfaces::WalletTxStatus&, interfaces::WalletOrderForm&, bool&, int&) override { return {}; } + 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); + } + interfaces::WalletBalances getBalances() override { return {.balance = balance}; } + bool tryGetBalances(interfaces::WalletBalances& balances_out, uint256&) override + { + balances_out = {.balance = balance}; + return true; + } + CAmount getBalance() override { return balance; } + CAmount getAvailableBalance(const wallet::CCoinControl&) override { return balance; } + wallet::isminetype txinIsMine(const CTxIn&) override { return wallet::ISMINE_NO; } + wallet::isminetype txoutIsMine(const CTxOut&) override { return wallet::ISMINE_NO; } + CAmount getDebit(const CTxIn&, wallet::isminefilter) override { return 0; } + CAmount getCredit(const CTxOut&, wallet::isminefilter) override { return 0; } + CoinsList listCoins() override { return {}; } + std::vector getCoins(const std::vector&) override { return {}; } + CAmount getRequiredFee(unsigned int) override { return 0; } + CAmount getMinimumFee(unsigned int, const wallet::CCoinControl&, int*, FeeReason*) override { return 0; } + unsigned int getConfirmTarget() override { return 6; } + bool hdEnabled() override { return true; } + bool canGetAddresses() override { return true; } + bool privateKeysDisabled() override { return false; } + bool taprootEnabled() override { return true; } + bool hasExternalSigner() override { return false; } + OutputType getDefaultAddressType() override { return OutputType::BECH32; } + CAmount getDefaultMaxTxFee() override { return COIN; } + void remove() override {} + std::unique_ptr handleUnload(UnloadFn) override { return MakeNoopHandler(); } + std::unique_ptr handleShowProgress(ShowProgressFn) override { return MakeNoopHandler(); } + std::unique_ptr handleStatusChanged(StatusChangedFn) override { return MakeNoopHandler(); } + std::unique_ptr handleAddressBookChanged(AddressBookChangedFn) override { return MakeNoopHandler(); } + std::unique_ptr handleTransactionChanged(TransactionChangedFn) override { return MakeNoopHandler(); } + std::unique_ptr handleCanGetAddressesChanged(CanGetAddressesChangedFn) override { return MakeNoopHandler(); } +}; + +std::unique_ptr MakeWalletModel(FakeWallet*& wallet_out) +{ + auto wallet = std::make_unique(); + wallet_out = wallet.get(); + return std::make_unique(std::move(wallet)); +} + +void ConfigureRecipient(WalletQmlModel& model, qint64 satoshis) +{ + auto* recipient = model.sendRecipientList()->currentRecipient(); + recipient->setAddress(QString::fromLatin1(REGTEST_ADDRESS)); + recipient->amount()->setSatoshi(satoshis); +} +} // namespace + +class WalletQmlModelTests : public QObject +{ + Q_OBJECT + +private Q_SLOTS: + void initTestCase(); + void prepareTransactionOnLockedWalletMarksUnlockNeeded(); + void sendTransactionOnLockedWalletRequiresPassword(); + void sendTransactionWithPassphraseUnlocksCommitsAndRelocks(); +}; + +void WalletQmlModelTests::initTestCase() +{ + SelectParams(ChainType::REGTEST); +} + +void WalletQmlModelTests::prepareTransactionOnLockedWalletMarksUnlockNeeded() +{ + FakeWallet* wallet{nullptr}; + auto model = MakeWalletModel(wallet); + ConfigureRecipient(*model, 1'000); + + wallet->create_transaction_fn = [](const std::vector&, + const wallet::CCoinControl&, + bool, + int&, + CAmount&) -> util::Result { + return util::Error{Untranslated("Transaction needs a change address, but we can't generate it. Error: Keypool ran out, please call keypoolrefill first")}; + }; + + QVERIFY(!model->prepareTransaction()); + QVERIFY(model->isEncrypted()); + QVERIFY(model->isLocked()); + QVERIFY(model->transactionNeedsUnlock()); + QCOMPARE(model->transactionError(), QString("Transaction needs a change address, but we can't generate it. Error: Keypool ran out, please call keypoolrefill first")); + QVERIFY(wallet->create_transaction_sign_args == std::vector{false}); + QCOMPARE(wallet->unlock_calls, 0); + QCOMPARE(wallet->lock_calls, 0); +} + +void WalletQmlModelTests::sendTransactionOnLockedWalletRequiresPassword() +{ + FakeWallet* wallet{nullptr}; + auto model = MakeWalletModel(wallet); + ConfigureRecipient(*model, 1'000); + + QVERIFY(model->prepareTransactionWithPassphrase("secret")); + QVERIFY(wallet->locked); + QCOMPARE(wallet->unlock_calls, 1); + QCOMPARE(wallet->lock_calls, 1); + + QVERIFY(!model->sendTransaction()); + QCOMPARE(model->transactionError(), QString("Enter your wallet password to sign this transaction.")); + QCOMPARE(wallet->commit_calls, 0); + QVERIFY(wallet->fill_psbt_sign_args.empty()); +} + +void WalletQmlModelTests::sendTransactionWithPassphraseUnlocksCommitsAndRelocks() +{ + FakeWallet* wallet{nullptr}; + auto model = MakeWalletModel(wallet); + ConfigureRecipient(*model, 1'000); + + QVERIFY(model->prepareTransactionWithPassphrase("secret")); + QVERIFY(wallet->locked); + + QVERIFY(model->sendTransactionWithPassphrase("secret")); + QCOMPARE(wallet->unlock_calls, 2); + QCOMPARE(wallet->lock_calls, 2); + QCOMPARE(wallet->commit_calls, 1); + QVERIFY(wallet->locked); + QVERIFY(wallet->fill_psbt_sign_args == std::vector({false, true})); + QVERIFY(model->transactionError().isEmpty()); + QVERIFY(!model->transactionNeedsUnlock()); +} + +int RunWalletQmlModelTests(int argc, char* argv[]) +{ + WalletQmlModelTests tc; + return QTest::qExec(&tc, argc, argv); +} + +#include "test_walletqmlmodel.moc"