diff --git a/.github/workflows/gui-functional-tests.yml b/.github/workflows/gui-functional-tests.yml
index cbfe88ebf0..99733f1605 100644
--- a/.github/workflows/gui-functional-tests.yml
+++ b/.github/workflows/gui-functional-tests.yml
@@ -59,3 +59,4 @@ jobs:
python3 test/functional/qml_test_peers.py
python3 test/functional/qml_test_proxy.py
python3 test/functional/qml_test_debug_log.py
+ python3 test/functional/qml_test_watchonly_wallet.py
diff --git a/qml/bitcoin_qml.qrc b/qml/bitcoin_qml.qrc
index e8d921f444..3553a53cbb 100644
--- a/qml/bitcoin_qml.qrc
+++ b/qml/bitcoin_qml.qrc
@@ -67,6 +67,7 @@
controls/ToggleButton.qml
controls/utils.js
controls/ValueInput.qml
+ controls/WalletTypeListItem.qml
pages/initerrormessage.qml
pages/main.qml
pages/node/BannedPeers.qml
@@ -105,7 +106,10 @@
pages/wallet/CreatePassword.qml
pages/wallet/ImportWalletOptions.qml
pages/wallet/ImportWalletSuccess.qml
+ pages/wallet/CreateTypeSelector.qml
pages/wallet/CreateWalletWizard.qml
+ pages/wallet/WatchOnlyIntro.qml
+ pages/wallet/WatchOnlyXpub.qml
pages/wallet/DesktopWallets.qml
pages/wallet/MultipleSendReview.qml
pages/wallet/RequestPayment.qml
diff --git a/qml/controls/WalletTypeListItem.qml b/qml/controls/WalletTypeListItem.qml
new file mode 100644
index 0000000000..f7b988367e
--- /dev/null
+++ b/qml/controls/WalletTypeListItem.qml
@@ -0,0 +1,63 @@
+// Copyright (c) 2025 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
+
+AbstractButton {
+ id: root
+
+ property string title: ""
+ property string description: ""
+ property string iconSource: ""
+ property color iconColor: Theme.color.neutral9
+
+ padding: 15
+ implicitWidth: 450
+ opacity: enabled ? 1.0 : 0.4
+
+ background: Rectangle {
+ border.width: 1
+ border.color: root.hovered && root.enabled ? Theme.color.neutral9 : Theme.color.neutral5
+ radius: 10
+ color: "transparent"
+ FocusBorder {
+ visible: root.visualFocus
+ borderRadius: 14
+ }
+ }
+
+ contentItem: RowLayout {
+ spacing: 10
+ Icon {
+ source: root.iconSource
+ color: root.iconColor
+ size: 24
+ }
+ ColumnLayout {
+ spacing: 2
+ Layout.fillWidth: true
+ CoreText {
+ Layout.fillWidth: true
+ text: root.title
+ font.pixelSize: 18
+ bold: true
+ color: Theme.color.neutral9
+ horizontalAlignment: Text.AlignLeft
+ }
+ CoreText {
+ Layout.fillWidth: true
+ text: root.description
+ font.pixelSize: 15
+ color: Theme.color.neutral7
+ horizontalAlignment: Text.AlignLeft
+ wrapMode: Text.WordWrap
+ }
+ }
+ CaretRightIcon {
+ Layout.alignment: Qt.AlignVCenter
+ }
+ }
+}
diff --git a/qml/pages/wallet/CreateName.qml b/qml/pages/wallet/CreateName.qml
index 4bab89b3b9..e1bc37fffd 100644
--- a/qml/pages/wallet/CreateName.qml
+++ b/qml/pages/wallet/CreateName.qml
@@ -20,6 +20,7 @@ Page {
property int walletType: CreateName.WalletType.SingleSig
property string initialWalletName: ""
readonly property bool externalSignerWallet: walletType === CreateName.WalletType.ExternalSigner
+ property bool loading: false
background: null
Component.onCompleted: {
@@ -86,10 +87,13 @@ Page {
Layout.leftMargin: 20
Layout.rightMargin: 20
Layout.alignment: Qt.AlignCenter
- enabled: walletNameInput.text.length > 0
- text: root.externalSignerWallet ? qsTr("Create wallet") : qsTr("Continue")
+ enabled: walletNameInput.text.length > 0 && !root.loading
+ text: {
+ if (root.loading) return qsTr("Initializing...")
+ if (root.externalSignerWallet) return qsTr("Create wallet")
+ return qsTr("Continue")
+ }
onClicked: {
- console.log("Creating wallet with name: " + walletNameInput.text)
root.walletName = walletNameInput.text
root.next()
}
diff --git a/qml/pages/wallet/CreatePassword.qml b/qml/pages/wallet/CreatePassword.qml
index 4d3043db88..4c96ea5cd3 100644
--- a/qml/pages/wallet/CreatePassword.qml
+++ b/qml/pages/wallet/CreatePassword.qml
@@ -17,7 +17,7 @@ Page {
signal next
background: null
- required property string walletName;
+ required property string walletName
Component.onCompleted: walletController.clearWalletLoadStatus()
diff --git a/qml/pages/wallet/CreateTypeSelector.qml b/qml/pages/wallet/CreateTypeSelector.qml
new file mode 100644
index 0000000000..ec4de54305
--- /dev/null
+++ b/qml/pages/wallet/CreateTypeSelector.qml
@@ -0,0 +1,108 @@
+// Copyright (c) 2025 The Bitcoin Core developers
+// Distributed under the MIT software license, see the accompanying
+// file COPYING or http://www.opensource.org/licenses/mit-license.php.
+
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Layouts 1.15
+
+import "../../controls"
+import "../../components"
+
+Page {
+ id: root
+ objectName: "createTypeSelector"
+ signal back
+ signal regularSelected
+ signal watchOnlySelected
+ signal importSelected
+ background: null
+
+ header: NavigationBar2 {
+ leftItem: NavButton {
+ objectName: "typeSelectorBackButton"
+ iconSource: "image://images/caret-left"
+ text: qsTr("Back")
+ onClicked: root.back()
+ }
+ centerItem: Item {
+ CoreText {
+ anchors.centerIn: parent
+ text: qsTr("Choose a wallet type")
+ font.pixelSize: 18
+ bold: true
+ color: Theme.color.neutral9
+ }
+ }
+ }
+
+ Flickable {
+ anchors.fill: parent
+ contentHeight: columnLayout.height
+ clip: true
+
+ ColumnLayout {
+ id: columnLayout
+ width: Math.min(parent.width, 450)
+ anchors.horizontalCenter: parent.horizontalCenter
+ spacing: 10
+
+ Item { Layout.preferredHeight: 10 }
+
+ WalletTypeListItem {
+ objectName: "walletTypeRegular"
+ Layout.fillWidth: true
+ Layout.leftMargin: 20
+ Layout.rightMargin: 20
+ title: qsTr("Regular")
+ description: qsTr("Fully managed in this application.")
+ iconSource: "image://images/singlesig-wallet"
+ onClicked: root.regularSelected()
+ }
+
+ WalletTypeListItem {
+ objectName: "walletTypeMultiKey"
+ Layout.fillWidth: true
+ Layout.leftMargin: 20
+ Layout.rightMargin: 20
+ title: qsTr("Multi-key")
+ description: qsTr("Requires 2 or more wallets or hardware devices to coordinate.")
+ iconSource: "image://images/singlesig-wallet"
+ enabled: false
+ }
+
+ WalletTypeListItem {
+ objectName: "walletTypeViewOnly"
+ Layout.fillWidth: true
+ Layout.leftMargin: 20
+ Layout.rightMargin: 20
+ title: qsTr("Watch-only")
+ description: qsTr("Keep an eye on another wallet you have.")
+ iconSource: "image://images/visible"
+ onClicked: root.watchOnlySelected()
+ }
+
+ WalletTypeListItem {
+ objectName: "walletTypeImport"
+ Layout.fillWidth: true
+ Layout.leftMargin: 20
+ Layout.rightMargin: 20
+ title: qsTr("Import wallet")
+ description: qsTr("Load an existing wallet.dat file.")
+ iconSource: "image://images/file"
+ onClicked: root.importSelected()
+ }
+
+ WalletTypeListItem {
+ objectName: "walletTypeCustom"
+ Layout.fillWidth: true
+ Layout.leftMargin: 20
+ Layout.rightMargin: 20
+ title: qsTr("Custom")
+ description: qsTr("For unique wallet configurations.")
+ iconSource: "image://images/gear-outline"
+ enabled: false
+ }
+ }
+ }
+}
diff --git a/qml/pages/wallet/CreateWalletWizard.qml b/qml/pages/wallet/CreateWalletWizard.qml
index 73300fe76a..c8435d436c 100644
--- a/qml/pages/wallet/CreateWalletWizard.qml
+++ b/qml/pages/wallet/CreateWalletWizard.qml
@@ -18,9 +18,31 @@ PageStack {
signal finished()
property string walletName: ""
+ property string walletType: ""
+ property string xpub: ""
property int launchContext: CreateWalletWizard.Context.Onboarding
+ property bool waitingForInit: false
- Component.onCompleted: walletController.refreshExternalSignerStatus()
+ Component.onCompleted: {
+ if (!walletController.initialized) {
+ nodeModel.startNodeInitializionThread()
+ }
+ walletController.refreshExternalSignerStatus()
+ }
+
+ Connections {
+ target: walletController
+ enabled: root.waitingForInit
+ function onInitializedChanged() {
+ if (walletController.initialized) {
+ root.waitingForInit = false
+ walletController.createWatchOnlyWallet(root.walletName, root.xpub)
+ if (walletController.isWalletLoaded) {
+ root.push(watchOnlyConfirm)
+ }
+ }
+ }
+ }
onVisibleChanged: {
if (visible) {
walletController.refreshExternalSignerStatus()
@@ -76,8 +98,8 @@ PageStack {
header: qsTr("Add a wallet")
headerBold: true
description: walletController.canCreateExternalSignerWallet
- ? qsTr("Supported wallet types are external signer, view-only, single-key,\nand multi-key.")
- : qsTr("Supported wallet types are view-only, single-key,\nand multi-key.")
+ ? qsTr("Supported wallet types are external signer, watch-only, single-key,\nand multi-key.")
+ : qsTr("Supported wallet types are watch-only, single-key,\nand multi-key.")
}
ContinueButton {
@@ -90,7 +112,7 @@ PageStack {
Layout.alignment: Qt.AlignCenter
text: qsTr("Create wallet")
onClicked: {
- root.push(intro)
+ root.push(typeSelector)
}
}
@@ -157,6 +179,51 @@ PageStack {
}
}
}
+ Component {
+ id: typeSelector
+ CreateTypeSelector {
+ onBack: root.pop()
+ onRegularSelected: {
+ root.walletType = "singlesig"
+ root.push(intro)
+ }
+ onWatchOnlySelected: {
+ root.walletType = "watchonly"
+ root.push(watchOnlyIntro)
+ }
+ onImportSelected: {
+ walletController.clearWalletLoadStatus()
+ root.push(import_options)
+ }
+ }
+ }
+ Component {
+ id: watchOnlyIntro
+ WatchOnlyIntro {
+ onBack: root.pop()
+ onNext: root.push(watchOnlyXpub)
+ }
+ }
+ Component {
+ id: watchOnlyXpub
+ WatchOnlyXpub {
+ id: xpubPage
+ onBack: root.pop()
+ onNext: {
+ root.xpub = xpubPage.xpub
+ root.push(name)
+ }
+ }
+ }
+ Component {
+ id: watchOnlyConfirm
+ CreateConfirm {
+ headerText: qsTr("Your watch-only wallet has been created")
+ descriptionText: qsTr("You can view transactions and balances. Spending requires an external signer. No backup is needed — your wallet can be recreated from the same extended public key.")
+ onBack: root.pop()
+ onNext: root.finished()
+ }
+ }
Component {
id: import_options
ImportWalletOptions {
@@ -185,9 +252,22 @@ PageStack {
id: name
CreateName {
id: createName
+ loading: root.waitingForInit
+ onBack: root.pop()
onNext: {
root.walletName = createName.walletName
- root.push(password)
+ if (root.walletType === "watchonly") {
+ if (walletController.initialized) {
+ walletController.createWatchOnlyWallet(root.walletName, root.xpub)
+ if (walletController.isWalletLoaded) {
+ root.push(watchOnlyConfirm)
+ }
+ } else {
+ root.waitingForInit = true
+ }
+ } else {
+ root.push(password)
+ }
}
}
}
diff --git a/qml/pages/wallet/WalletBadge.qml b/qml/pages/wallet/WalletBadge.qml
index 39d5726043..8c9a3bb52f 100644
--- a/qml/pages/wallet/WalletBadge.qml
+++ b/qml/pages/wallet/WalletBadge.qml
@@ -29,7 +29,6 @@ Button {
checkable: true
hoverEnabled: AppMode.isDesktop
implicitHeight: 60
- implicitWidth: contentItem.width
bottomPadding: 0
topPadding: 0
clip: true
@@ -41,9 +40,11 @@ Button {
contentItem: Item {
RowLayout {
visible: root.loading
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: 5
anchors.rightMargin: 5
- anchors.centerIn: parent
spacing: 5
Skeleton {
@@ -79,9 +80,11 @@ Button {
NumberAnimation { duration: 400 }
}
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: 5
anchors.rightMargin: 5
- anchors.centerIn: parent
clip: true
spacing: 5
Icon {
@@ -112,9 +115,11 @@ Button {
NumberAnimation { duration: 400 }
}
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: 5
anchors.rightMargin: 5
- anchors.centerIn: parent
clip: true
spacing: 5
Icon {
@@ -133,6 +138,7 @@ Button {
horizontalAlignment: Text.AlignLeft
Layout.fillWidth: true
wrap: false
+ elide: Text.ElideRight
id: buttonText
font.pixelSize: 13
text: root.text
diff --git a/qml/pages/wallet/WatchOnlyIntro.qml b/qml/pages/wallet/WatchOnlyIntro.qml
new file mode 100644
index 0000000000..55b61abe51
--- /dev/null
+++ b/qml/pages/wallet/WatchOnlyIntro.qml
@@ -0,0 +1,121 @@
+// Copyright (c) 2025 The Bitcoin Core developers
+// Distributed under the MIT software license, see the accompanying
+// file COPYING or http://www.opensource.org/licenses/mit-license.php.
+
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Layouts 1.15
+
+import "../../controls"
+import "../../components"
+
+Page {
+ id: root
+ objectName: "watchOnlyIntro"
+ signal back
+ signal next
+ background: null
+
+ header: NavigationBar2 {
+ leftItem: NavButton {
+ iconSource: "image://images/caret-left"
+ text: qsTr("Back")
+ onClicked: root.back()
+ }
+ centerItem: Item {
+ CoreText {
+ anchors.centerIn: parent
+ text: qsTr("Create wallet")
+ font.pixelSize: 18
+ bold: true
+ color: Theme.color.neutral9
+ }
+ }
+ }
+
+ ColumnLayout {
+ id: columnLayout
+ width: Math.min(parent.width, 450)
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ Item {
+ Layout.alignment: Qt.AlignCenter
+ Layout.preferredHeight: circle.height
+ Layout.preferredWidth: circle.width
+ Rectangle {
+ id: circle
+ width: 60
+ height: width
+ radius: width / 2
+ color: Theme.color.blue
+ }
+ Icon {
+ source: "image://images/info"
+ color: Theme.color.white
+ width: 30
+ height: width
+ anchors.centerIn: circle
+ }
+ }
+
+ CoreText {
+ Layout.topMargin: 25
+ Layout.fillWidth: true
+ text: qsTr("You are about to add a watch-only bitcoin wallet")
+ font.pixelSize: 21
+ bold: true
+ color: Theme.color.neutral9
+ }
+
+ CoreText {
+ Layout.topMargin: 20
+ Layout.fillWidth: true
+ text: qsTr("You can view transactions and the balance.")
+ font.pixelSize: 18
+ color: Theme.color.neutral7
+ }
+
+ Separator {
+ Layout.topMargin: 10
+ Layout.leftMargin: 20
+ Layout.rightMargin: 20
+ Layout.fillWidth: true
+ color: Theme.color.neutral4
+ }
+
+ CoreText {
+ Layout.topMargin: 10
+ Layout.fillWidth: true
+ text: qsTr("Transactions can be initiated, but require an external signer to complete.")
+ font.pixelSize: 18
+ color: Theme.color.neutral7
+ }
+
+ Separator {
+ Layout.topMargin: 10
+ Layout.leftMargin: 20
+ Layout.rightMargin: 20
+ Layout.fillWidth: true
+ color: Theme.color.neutral4
+ }
+
+ CoreText {
+ Layout.topMargin: 10
+ Layout.fillWidth: true
+ text: qsTr("Setup requires an extended public key (xpub).")
+ font.pixelSize: 18
+ color: Theme.color.neutral7
+ }
+
+ ContinueButton {
+ objectName: "watchOnlyIntroNextButton"
+ Layout.preferredWidth: Math.min(300, parent.width - 2 * Layout.leftMargin)
+ Layout.topMargin: 30
+ Layout.leftMargin: 20
+ Layout.rightMargin: 20
+ Layout.alignment: Qt.AlignCenter
+ text: qsTr("Next")
+ onClicked: root.next()
+ }
+ }
+}
diff --git a/qml/pages/wallet/WatchOnlyXpub.qml b/qml/pages/wallet/WatchOnlyXpub.qml
new file mode 100644
index 0000000000..4c03665376
--- /dev/null
+++ b/qml/pages/wallet/WatchOnlyXpub.qml
@@ -0,0 +1,118 @@
+// Copyright (c) 2025 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 org.bitcoincore.qt 1.0
+
+import "../../controls"
+import "../../components"
+
+Page {
+ id: root
+ objectName: "watchOnlyXpub"
+ signal back
+ signal next
+ property string xpub: ""
+ background: null
+
+ readonly property bool isValidXpub: {
+ var t = xpubInput.text.trim()
+ if (t.length < 100) return false
+ return walletController.validateXpub(t)
+ }
+
+ header: NavigationBar2 {
+ leftItem: NavButton {
+ iconSource: "image://images/caret-left"
+ text: qsTr("Back")
+ onClicked: root.back()
+ }
+ centerItem: Item {
+ CoreText {
+ anchors.centerIn: parent
+ text: qsTr("Watch-only wallet")
+ font.pixelSize: 18
+ bold: true
+ color: Theme.color.neutral9
+ }
+ }
+ }
+
+ ColumnLayout {
+ id: columnLayout
+ width: Math.min(parent.width, 450)
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ CoreText {
+ Layout.topMargin: 30
+ Layout.leftMargin: 20
+ Layout.rightMargin: 20
+ font.pixelSize: 15
+ color: Theme.color.neutral9
+ text: qsTr("Enter extended public key (XPUB)")
+ horizontalAlignment: Text.AlignLeft
+ }
+
+ Item {
+ Layout.fillWidth: true
+ Layout.topMargin: 5
+ Layout.leftMargin: 20
+ Layout.rightMargin: 20
+ implicitHeight: 56
+
+ CoreTextField {
+ id: xpubInput
+ objectName: "watchOnlyXpubInput"
+ anchors.fill: parent
+ focus: true
+ placeholderText: qsTr("Enter your key...")
+ rightPadding: 46
+ }
+
+ Icon {
+ objectName: "watchOnlyXpubPasteButton"
+ enabled: true
+ anchors.right: parent.right
+ anchors.rightMargin: 12
+ anchors.verticalCenter: parent.verticalCenter
+ source: "image://images/copy"
+ color: Theme.color.neutral9
+ size: 24
+ ToolTip.visible: hovered
+ ToolTip.text: qsTr("Paste from clipboard")
+ onClicked: {
+ xpubInput.text = Clipboard.text()
+ }
+ }
+ }
+
+ CoreText {
+ Layout.fillWidth: true
+ Layout.leftMargin: 20
+ Layout.rightMargin: 20
+ visible: xpubInput.text.trim().length > 0 && !root.isValidXpub
+ text: qsTr("Enter a valid extended public key (xpub)")
+ color: Theme.color.orange
+ font.pixelSize: 13
+ horizontalAlignment: Text.AlignLeft
+ }
+
+ ContinueButton {
+ objectName: "watchOnlyXpubNextButton"
+ Layout.preferredWidth: Math.min(300, parent.width - 2 * Layout.leftMargin)
+ Layout.topMargin: 30
+ Layout.leftMargin: 20
+ Layout.rightMargin: 20
+ Layout.alignment: Qt.AlignCenter
+ text: qsTr("Next")
+ enabled: root.isValidXpub
+ onClicked: {
+ root.xpub = xpubInput.text.trim()
+ root.next()
+ }
+ }
+ }
+}
diff --git a/qml/walletqmlcontroller.cpp b/qml/walletqmlcontroller.cpp
index a6d1c4f1be..fbb01fa860 100644
--- a/qml/walletqmlcontroller.cpp
+++ b/qml/walletqmlcontroller.cpp
@@ -9,9 +9,12 @@
#include
#include
#include
+#include
+#include