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