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"