From dfd19a97964c5ca18f954a269a90da9b48c19c4d Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Wed, 15 Jul 2026 11:55:39 -0300 Subject: [PATCH] feat(wallet): add reusable wallet modules Add program-neutral wallet access, a stable account model, reusable QML controls, transaction confirmation, submitted-transaction presentation, and isolated contract tests. --- apps/amm/CMakeLists.txt | 19 +- apps/amm/flake.lock | 41 +- apps/amm/flake.nix | 43 +- apps/amm/qml/Logos/Wallet/qmldir | 5 + apps/amm/qml/NavBar.qml | 11 +- .../liquidity/LiquidityConfirmationDialog.qml | 238 --------- .../LiquidityConfirmationSummary.qml | 91 ++++ .../swap/SwapConfirmationDialog.qml | 225 -------- .../swap/SwapConfirmationSummary.qml | 88 +++ .../qml/components/wallet/AccountControl.qml | 490 ----------------- .../qml/components/wallet/AccountDelegate.qml | 84 --- .../components/wallet/CreateAccountDialog.qml | 102 ---- .../components/wallet/CreateWalletDialog.qml | 201 ------- .../qml/components/wallet/LogosCopyButton.qml | 39 -- .../components/wallet/WalletIconButton.qml | 25 - .../qml/components/wallet/icons/settings.svg | 1 - apps/amm/qml/pages/LiquidityPage.qml | 15 +- apps/amm/qml/pages/SwapPage.qml | 42 +- apps/amm/src/AccountModel.cpp | 79 --- apps/amm/src/AccountModel.h | 47 -- apps/amm/src/AmmUiBackend.cpp | 377 ++----------- apps/amm/src/AmmUiBackend.h | 56 +- apps/amm/src/AmmUiBackend.rep | 7 - apps/shared/wallet/CMakeLists.txt | 168 ++++++ .../wallet/qml/SubmittedTransaction.qml | 86 +++ .../qml/TransactionConfirmationDialog.qml | 169 ++++++ apps/shared/wallet/qml/WalletControl.qml | 503 ++++++++++++++++++ .../wallet/qml/internal/AccountDelegate.qml | 77 +++ .../shared/wallet/qml/internal/CopyButton.qml | 26 + .../qml/internal/CreateAccountDialog.qml | 94 ++++ .../qml/internal/CreateWalletDialog.qml | 190 +++++++ .../wallet/qml/internal/WalletIconButton.qml | 31 ++ .../qml/internal/WalletMessageDialog.qml | 52 ++ .../wallet/qml/internal}/icons/account.svg | 0 .../wallet/qml/internal}/icons/back.svg | 0 .../wallet/qml/internal}/icons/checkmark.svg | 0 .../wallet/qml/internal}/icons/copy.svg | 0 .../wallet/qml/internal}/icons/power.svg | 0 .../shared/wallet/src/LogosWalletProvider.cpp | 382 +++++++++++++ apps/shared/wallet/src/LogosWalletProvider.h | 37 ++ apps/shared/wallet/src/WalletAccountModel.cpp | 61 +++ apps/shared/wallet/src/WalletAccountModel.h | 42 ++ apps/shared/wallet/src/WalletController.cpp | 238 +++++++++ apps/shared/wallet/src/WalletController.h | 67 +++ apps/shared/wallet/src/WalletProvider.cpp | 26 + apps/shared/wallet/src/WalletProvider.h | 107 ++++ .../tests/cpp/LogosWalletProviderTest.cpp | 452 ++++++++++++++++ .../wallet/tests/cpp/fixtures/logos_sdk.h | 118 ++++ apps/shared/wallet/tests/qml/main.cpp | 3 + .../tests/qml/tst_SubmittedTransaction.qml | 43 ++ .../qml/tst_TransactionConfirmationDialog.qml | 125 +++++ .../wallet/tests/qml/tst_WalletControl.qml | 339 ++++++++++++ .../wallet/tests/support/FakeWalletProvider.h | 75 +++ flake.nix | 21 + 54 files changed, 3899 insertions(+), 1959 deletions(-) create mode 100644 apps/amm/qml/Logos/Wallet/qmldir delete mode 100644 apps/amm/qml/components/liquidity/LiquidityConfirmationDialog.qml create mode 100644 apps/amm/qml/components/liquidity/LiquidityConfirmationSummary.qml delete mode 100644 apps/amm/qml/components/swap/SwapConfirmationDialog.qml create mode 100644 apps/amm/qml/components/swap/SwapConfirmationSummary.qml delete mode 100644 apps/amm/qml/components/wallet/AccountControl.qml delete mode 100644 apps/amm/qml/components/wallet/AccountDelegate.qml delete mode 100644 apps/amm/qml/components/wallet/CreateAccountDialog.qml delete mode 100644 apps/amm/qml/components/wallet/CreateWalletDialog.qml delete mode 100644 apps/amm/qml/components/wallet/LogosCopyButton.qml delete mode 100644 apps/amm/qml/components/wallet/WalletIconButton.qml delete mode 100644 apps/amm/qml/components/wallet/icons/settings.svg delete mode 100644 apps/amm/src/AccountModel.cpp delete mode 100644 apps/amm/src/AccountModel.h create mode 100644 apps/shared/wallet/CMakeLists.txt create mode 100644 apps/shared/wallet/qml/SubmittedTransaction.qml create mode 100644 apps/shared/wallet/qml/TransactionConfirmationDialog.qml create mode 100644 apps/shared/wallet/qml/WalletControl.qml create mode 100644 apps/shared/wallet/qml/internal/AccountDelegate.qml create mode 100644 apps/shared/wallet/qml/internal/CopyButton.qml create mode 100644 apps/shared/wallet/qml/internal/CreateAccountDialog.qml create mode 100644 apps/shared/wallet/qml/internal/CreateWalletDialog.qml create mode 100644 apps/shared/wallet/qml/internal/WalletIconButton.qml create mode 100644 apps/shared/wallet/qml/internal/WalletMessageDialog.qml rename apps/{amm/qml/components/wallet => shared/wallet/qml/internal}/icons/account.svg (100%) rename apps/{amm/qml/components/wallet => shared/wallet/qml/internal}/icons/back.svg (100%) rename apps/{amm/qml/components/wallet => shared/wallet/qml/internal}/icons/checkmark.svg (100%) rename apps/{amm/qml/components/wallet => shared/wallet/qml/internal}/icons/copy.svg (100%) rename apps/{amm/qml/components/wallet => shared/wallet/qml/internal}/icons/power.svg (100%) create mode 100644 apps/shared/wallet/src/LogosWalletProvider.cpp create mode 100644 apps/shared/wallet/src/LogosWalletProvider.h create mode 100644 apps/shared/wallet/src/WalletAccountModel.cpp create mode 100644 apps/shared/wallet/src/WalletAccountModel.h create mode 100644 apps/shared/wallet/src/WalletController.cpp create mode 100644 apps/shared/wallet/src/WalletController.h create mode 100644 apps/shared/wallet/src/WalletProvider.cpp create mode 100644 apps/shared/wallet/src/WalletProvider.h create mode 100644 apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp create mode 100644 apps/shared/wallet/tests/cpp/fixtures/logos_sdk.h create mode 100644 apps/shared/wallet/tests/qml/main.cpp create mode 100644 apps/shared/wallet/tests/qml/tst_SubmittedTransaction.qml create mode 100644 apps/shared/wallet/tests/qml/tst_TransactionConfirmationDialog.qml create mode 100644 apps/shared/wallet/tests/qml/tst_WalletControl.qml create mode 100644 apps/shared/wallet/tests/support/FakeWalletProvider.h diff --git a/apps/amm/CMakeLists.txt b/apps/amm/CMakeLists.txt index 40f11ac..8d66e29 100644 --- a/apps/amm/CMakeLists.txt +++ b/apps/amm/CMakeLists.txt @@ -1,12 +1,25 @@ -cmake_minimum_required(VERSION 3.14) +cmake_minimum_required(VERSION 3.21) project(AmmUiPlugin LANGUAGES CXX) +find_package(Qt6 6.8 REQUIRED COMPONENTS Core Gui Network Qml Quick QuickControls2) +qt_standard_project_setup(REQUIRES 6.8) + if(DEFINED ENV{LOGOS_MODULE_BUILDER_ROOT}) include($ENV{LOGOS_MODULE_BUILDER_ROOT}/cmake/LogosModule.cmake) else() message(FATAL_ERROR "LogosModule.cmake not found. Set LOGOS_MODULE_BUILDER_ROOT.") endif() +set(LOGOS_WALLET_SOURCE_DIR + "${CMAKE_CURRENT_SOURCE_DIR}/../shared/wallet" + CACHE PATH "Path to the shared Logos wallet module" +) +set(LOGOS_WALLET_GENERATED_DIR + "${CMAKE_CURRENT_SOURCE_DIR}/generated_code" + CACHE PATH "Path to generated Logos SDK sources" +) +add_subdirectory("${LOGOS_WALLET_SOURCE_DIR}" "${CMAKE_CURRENT_BINARY_DIR}/shared-wallet") + # ui_qml module with a hand-written C++ backend (QtRO .rep view contract + # generated *SimpleSource/*ViewPluginBase). Mirrors the LEZ wallet UI module. logos_module( @@ -18,8 +31,6 @@ logos_module( src/AmmUiPlugin.cpp src/AmmUiBackend.h src/AmmUiBackend.cpp - src/AccountModel.h - src/AccountModel.cpp FIND_PACKAGES Qt6Gui Qt6Network @@ -28,4 +39,6 @@ logos_module( Qt6::Network EXTERNAL_LIBS amm_client_ffi + LINK_TARGETS + logos_wallet_access ) diff --git a/apps/amm/flake.lock b/apps/amm/flake.lock index 9bc7175..8f79e67 100644 --- a/apps/amm/flake.lock +++ b/apps/amm/flake.lock @@ -2238,17 +2238,17 @@ "rust-rapidsnark": "rust-rapidsnark" }, "locked": { - "lastModified": 1782310268, - "narHash": "sha256-tIIIO+V+w/C/KLtKnLIv8O8/GoJ4PzgJSWrlQCXJ2P4=", + "lastModified": 1784202021, + "narHash": "sha256-BG94Ob5tbJyw7GclNBdnOWqcBLRld8sc+xeOU8LI5RQ=", "owner": "logos-blockchain", - "repo": "lssa", - "rev": "fb8cbac40e0bda4f152415ff4f181cdc6bca6d4a", + "repo": "logos-execution-zone", + "rev": "a7e06a660940a00093b1760560d37ff84aff5a05", "type": "github" }, "original": { "owner": "logos-blockchain", - "repo": "lssa", - "rev": "fb8cbac40e0bda4f152415ff4f181cdc6bca6d4a", + "repo": "logos-execution-zone", + "rev": "a7e06a660940a00093b1760560d37ff84aff5a05", "type": "github" } }, @@ -14438,11 +14438,11 @@ ] }, "locked": { - "lastModified": 1782082589, - "narHash": "sha256-Qeqxp0HYb3oTpmfD5YlFPJzpAJa7Ilb9o4sMeVvmHRI=", + "lastModified": 1782920676, + "narHash": "sha256-Vb81kiYbi8yYDZbUSW6v7QhV6uO/pj7F+lCAW1coAsY=", "owner": "logos-co", "repo": "logos-protocol", - "rev": "315a3a2e0af61bc47aad5601ee44cd7689975820", + "rev": "d7ad26d369c4e464a99f2a357f10c5947c7174e1", "type": "github" }, "original": { @@ -16410,17 +16410,17 @@ "nix-bundle-lgx": "nix-bundle-lgx_28" }, "locked": { - "lastModified": 1782313954, - "narHash": "sha256-psh5EtcIZh6kzFD4pQDT8bae9JS9eVtk5nPWxR2aGSA=", + "lastModified": 1782741385, + "narHash": "sha256-kN6xb1b/Jvu9Ninaqtmi/C4fx8poisGYbThIBIXwvHw=", "owner": "logos-blockchain", "repo": "logos-execution-zone-module", - "rev": "d2e9400ac06c3cdbfc2405b4f153fff9841a453c", + "rev": "d70225ced646934d2294fd9e8f8b03615c104b80", "type": "github" }, "original": { "owner": "logos-blockchain", "repo": "logos-execution-zone-module", - "rev": "d2e9400ac06c3cdbfc2405b4f153fff9841a453c", + "rev": "d70225ced646934d2294fd9e8f8b03615c104b80", "type": "github" } }, @@ -26763,7 +26763,8 @@ "root": { "inputs": { "logos-module-builder": "logos-module-builder", - "logos_execution_zone": "logos_execution_zone" + "logos_execution_zone": "logos_execution_zone", + "shared_wallet": "shared_wallet" } }, "rust-overlay": { @@ -26849,6 +26850,18 @@ "rev": "e91187f8ccb5bbfc7bb00dac88169112428da78f", "type": "github" } + }, + "shared_wallet": { + "flake": false, + "locked": { + "path": "../shared/wallet", + "type": "path" + }, + "original": { + "path": "../shared/wallet", + "type": "path" + }, + "parent": [] } }, "root": "root", diff --git a/apps/amm/flake.nix b/apps/amm/flake.nix index 1b36c1a..44121c5 100644 --- a/apps/amm/flake.nix +++ b/apps/amm/flake.nix @@ -4,11 +4,28 @@ inputs = { logos-module-builder.url = "github:logos-co/logos-module-builder"; + # Shared C++ wallet access and Logos.Wallet QML sources. + shared_wallet = { + url = "path:../shared/wallet"; + flake = false; + }; + # Core wallet module (the LEZ wallet FFI Qt plugin). The input name must # match the metadata.json `dependencies` entry so the builder can resolve - # it as a module dependency. This rev pins LEZ (lssa) at fb8cbac4, which - # includes the macOS Metal-build fix, so no `--override-input` is needed. - logos_execution_zone.url = "github:logos-blockchain/logos-execution-zone-module?rev=d2e9400ac06c3cdbfc2405b4f153fff9841a453c"; + # it as a module dependency. This revision exposes generic transaction + # submission by deployed program ID. + logos_execution_zone = { + url = "github:logos-blockchain/logos-execution-zone-module?rev=d70225ced646934d2294fd9e8f8b03615c104b80"; + + # The module pins the monorepo at v0.2.0-rc6 (e37876a), which owns the + # xcrun wrapper in its flake.nix. Override that transitive input to the + # head of PR #629 (fix(macos): nuke xcrun cache) so the Metal/xcrun build + # works on macOS. Note: #629 branches off `dev`, so this also pulls dev's + # drift from rc6 — if the wallet_ffi ABI mismatches the module build, fall + # back to cherry-picking #629's one line onto e37876a and pin that commit. + inputs.logos-execution-zone.url = + "github:logos-blockchain/logos-execution-zone?rev=a7e06a660940a00093b1760560d37ff84aff5a05"; + }; }; # NOTE: this flake is no longer built standalone. The amm_client_ffi crate @@ -22,10 +39,28 @@ # as a named attribute (there is no bare `default`): run it from the repo root # with `nix run .#amm-ui`, and build just the FFI crate with # `nix build .#amm_client_ffi`. - outputs = inputs@{ logos-module-builder, ... }: + outputs = inputs@{ logos-module-builder, shared_wallet, ... }: logos-module-builder.lib.mkLogosQmlModule { src = ./.; configFile = ./metadata.json; flakeInputs = inputs; + preConfigure = '' + cmakeFlagsArray+=("-DLOGOS_WALLET_SOURCE_DIR=${shared_wallet}") + ''; + postInstall = '' + # The builder installs the view under lib/qml after this hook. Its + # import descriptor points back to this compiled shared QML module. + test -f ${./qml}/Logos/Wallet/qmldir + + walletQmlDir="shared-wallet/qml/Logos/Wallet" + if [ ! -d "$walletQmlDir" ]; then + echo "Built Logos.Wallet QML module not found" + exit 1 + fi + walletQmlInstallDir="$out/lib/Logos/Wallet" + mkdir -p "$walletQmlInstallDir" + cp -r "$walletQmlDir/." "$walletQmlInstallDir/" + test -f "$walletQmlInstallDir/qmldir" + ''; }; } diff --git a/apps/amm/qml/Logos/Wallet/qmldir b/apps/amm/qml/Logos/Wallet/qmldir new file mode 100644 index 0000000..866351b --- /dev/null +++ b/apps/amm/qml/Logos/Wallet/qmldir @@ -0,0 +1,5 @@ +module Logos.Wallet +optional plugin logos_wallet_qmlplugin ../../../Logos/Wallet +classname Logos_WalletPlugin +prefer :/qt/qml/Logos/Wallet/ +depends QtQuick diff --git a/apps/amm/qml/NavBar.qml b/apps/amm/qml/NavBar.qml index f5b7857..3c06f91 100644 --- a/apps/amm/qml/NavBar.qml +++ b/apps/amm/qml/NavBar.qml @@ -2,8 +2,7 @@ import QtQuick 2.15 import QtQuick.Layouts 1.15 import Logos.Theme - -import "components/wallet" +import Logos.Wallet // Self-contained navigation bar — styling is independent of any view's theme. // Use currentIndex to read the active tab; tabChanged(index) fires on selection. @@ -94,11 +93,15 @@ Item { } // Wallet / account control on the far right. - AccountControl { + WalletControl { id: accountControl Layout.leftMargin: 12 - backend: root.backend + wallet: root.backend accountModel: root.accountModel + viewportWidth: root.width + watchCall: function(result, success, failure) { + logos.watch(result, success, failure) + } } } } diff --git a/apps/amm/qml/components/liquidity/LiquidityConfirmationDialog.qml b/apps/amm/qml/components/liquidity/LiquidityConfirmationDialog.qml deleted file mode 100644 index 16b0dc6..0000000 --- a/apps/amm/qml/components/liquidity/LiquidityConfirmationDialog.qml +++ /dev/null @@ -1,238 +0,0 @@ -import QtQuick 2.15 -import QtQuick.Controls 2.15 -import QtQuick.Layouts 1.15 - -FocusScope { - id: root - - property var snapshot: ({}) - property bool open: false - readonly property bool isAdd: root.snapshot.action === "add" - - signal canceled - signal confirmed(var snapshot) - - visible: root.open - z: 20 - - Keys.onEscapePressed: root.cancel() - - function openWithSnapshot(nextSnapshot) { - root.snapshot = nextSnapshot; - root.open = true; - root.forceActiveFocus(); - cancelButton.forceActiveFocus(); - } - - function cancel() { - root.open = false; - root.canceled(); - } - - function confirm() { - const confirmedSnapshot = root.snapshot; - root.open = false; - root.confirmed(confirmedSnapshot); - } - - Rectangle { - anchors.fill: parent - color: "#99000000" - - MouseArea { - anchors.fill: parent - } - } - - Rectangle { - id: panel - - anchors.centerIn: parent - color: "#1D1D1D" - implicitHeight: dialogContent.implicitHeight + 24 - radius: 8 - width: Math.max(0, Math.min(360, root.width - 32)) - border.color: "#343434" - border.width: 1 - - ColumnLayout { - id: dialogContent - - anchors.fill: parent - anchors.margins: 12 - spacing: 12 - - Text { - color: "#E7E1D8" - font.bold: true - font.pixelSize: 16 - text: root.isAdd ? qsTr("Confirm add liquidity") : qsTr("Confirm remove liquidity") - - Layout.fillWidth: true - } - - ColumnLayout { - spacing: 8 - visible: root.isAdd - - Layout.fillWidth: true - - SummaryRow { - label: qsTr("Deposit %1").arg(root.snapshot.tokenA || "") - value: root.snapshot.depositA || "" - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Deposit %1").arg(root.snapshot.tokenB || "") - value: root.snapshot.depositB || "" - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Receive at least") - value: root.snapshot.minLpReceived || "" - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Current ratio") - value: root.snapshot.currentRatio || "" - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Fee tier") - value: root.snapshot.feeTier || "" - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Slippage tolerance") - value: root.snapshot.slippageTolerance || "" - - Layout.fillWidth: true - } - } - - ColumnLayout { - spacing: 8 - visible: !root.isAdd - - Layout.fillWidth: true - - SummaryRow { - label: qsTr("Burn LP") - value: qsTr("%1 (%2)").arg(root.snapshot.burnText || "").arg(root.snapshot.burnPercent || "") - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Receive at least %1").arg(root.snapshot.tokenA || "") - value: root.snapshot.minTokenAReceived || "" - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Receive at least %1").arg(root.snapshot.tokenB || "") - value: root.snapshot.minTokenBReceived || "" - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Slippage tolerance") - value: root.snapshot.slippageTolerance || "" - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Post-removal share") - value: root.snapshot.postRemovalShare || "" - - Layout.fillWidth: true - } - } - - RowLayout { - spacing: 8 - - Layout.fillWidth: true - - Button { - id: cancelButton - - activeFocusOnTab: true - focusPolicy: Qt.StrongFocus - hoverEnabled: true - text: qsTr("Cancel") - - Accessible.name: cancelButton.text - - Layout.fillWidth: true - Layout.minimumHeight: 44 - - onClicked: root.cancel() - - contentItem: Text { - color: cancelButton.hovered || cancelButton.activeFocus ? "#151515" : "#E7E1D8" - elide: Text.ElideRight - font.bold: true - font.pixelSize: 13 - horizontalAlignment: Text.AlignHCenter - text: cancelButton.text - verticalAlignment: Text.AlignVCenter - } - - background: Rectangle { - border.color: cancelButton.activeFocus ? "#F26A21" : "#343434" - border.width: 1 - color: cancelButton.pressed ? "#343434" : cancelButton.hovered || cancelButton.activeFocus ? "#E7E1D8" : "#151515" - radius: 6 - } - } - - Button { - id: confirmButton - - activeFocusOnTab: true - focusPolicy: Qt.StrongFocus - hoverEnabled: true - text: qsTr("Confirm") - - Accessible.name: confirmButton.text - - Layout.fillWidth: true - Layout.minimumHeight: 44 - - onClicked: root.confirm() - - contentItem: Text { - color: "#151515" - elide: Text.ElideRight - font.bold: true - font.pixelSize: 13 - horizontalAlignment: Text.AlignHCenter - text: confirmButton.text - verticalAlignment: Text.AlignVCenter - } - - background: Rectangle { - border.color: "#F26A21" - border.width: 1 - color: confirmButton.pressed ? "#D95C1E" : confirmButton.hovered || confirmButton.activeFocus ? "#FF8A3D" : "#F26A21" - radius: 6 - } - } - } - } - } -} diff --git a/apps/amm/qml/components/liquidity/LiquidityConfirmationSummary.qml b/apps/amm/qml/components/liquidity/LiquidityConfirmationSummary.qml new file mode 100644 index 0000000..68af40d --- /dev/null +++ b/apps/amm/qml/components/liquidity/LiquidityConfirmationSummary.qml @@ -0,0 +1,91 @@ +import QtQuick +import QtQuick.Layouts + +ColumnLayout { + id: root + + property var snapshot: ({}) + readonly property bool isAdd: root.snapshot.action === "add" + + spacing: 8 + + ColumnLayout { + Layout.fillWidth: true + spacing: 8 + visible: root.isAdd + + SummaryRow { + Layout.fillWidth: true + label: qsTr("Deposit %1").arg(root.snapshot.tokenA || "") + value: root.snapshot.depositA || "" + } + + SummaryRow { + Layout.fillWidth: true + label: qsTr("Deposit %1").arg(root.snapshot.tokenB || "") + value: root.snapshot.depositB || "" + } + + SummaryRow { + Layout.fillWidth: true + label: qsTr("Receive at least") + value: root.snapshot.minLpReceived || "" + } + + SummaryRow { + Layout.fillWidth: true + label: qsTr("Current ratio") + value: root.snapshot.currentRatio || "" + } + + SummaryRow { + Layout.fillWidth: true + label: qsTr("Fee tier") + value: root.snapshot.feeTier || "" + } + + SummaryRow { + Layout.fillWidth: true + label: qsTr("Slippage tolerance") + value: root.snapshot.slippageTolerance || "" + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 8 + visible: !root.isAdd + + SummaryRow { + Layout.fillWidth: true + label: qsTr("Burn LP") + value: qsTr("%1 (%2)") + .arg(root.snapshot.burnText || "") + .arg(root.snapshot.burnPercent || "") + } + + SummaryRow { + Layout.fillWidth: true + label: qsTr("Receive at least %1").arg(root.snapshot.tokenA || "") + value: root.snapshot.minTokenAReceived || "" + } + + SummaryRow { + Layout.fillWidth: true + label: qsTr("Receive at least %1").arg(root.snapshot.tokenB || "") + value: root.snapshot.minTokenBReceived || "" + } + + SummaryRow { + Layout.fillWidth: true + label: qsTr("Slippage tolerance") + value: root.snapshot.slippageTolerance || "" + } + + SummaryRow { + Layout.fillWidth: true + label: qsTr("Post-removal share") + value: root.snapshot.postRemovalShare || "" + } + } +} diff --git a/apps/amm/qml/components/swap/SwapConfirmationDialog.qml b/apps/amm/qml/components/swap/SwapConfirmationDialog.qml deleted file mode 100644 index 54f61b4..0000000 --- a/apps/amm/qml/components/swap/SwapConfirmationDialog.qml +++ /dev/null @@ -1,225 +0,0 @@ -import QtQuick 2.15 -import QtQuick.Controls 2.15 -import QtQuick.Layouts 1.15 - -FocusScope { - id: root - - property var theme - property var snapshot: ({}) - property bool open: false - - signal canceled - signal confirmed(var snapshot) - - visible: root.open - z: 20 - - Keys.onEscapePressed: root.cancel() - - function openWithSnapshot(nextSnapshot) { - root.snapshot = nextSnapshot; - root.open = true; - root.forceActiveFocus(); - cancelButton.forceActiveFocus(); - } - - function cancel() { - root.open = false; - root.canceled(); - } - - function confirm() { - const confirmedSnapshot = root.snapshot; - root.open = false; - root.confirmed(confirmedSnapshot); - } - - Rectangle { - anchors.fill: parent - color: "#99000000" - - MouseArea { - anchors.fill: parent - } - } - - Rectangle { - id: panel - - anchors.centerIn: parent - color: root.theme.colors.cardBg - implicitHeight: dialogContent.implicitHeight + 32 - radius: 16 - width: Math.max(0, Math.min(380, root.width - 32)) - border.color: root.theme.colors.border - border.width: 1 - - MouseArea { - anchors.fill: parent - } - - ColumnLayout { - id: dialogContent - - anchors.fill: parent - anchors.margins: 16 - spacing: 14 - - Text { - color: root.theme.colors.textPrimary - font.bold: true - font.pixelSize: 17 - text: qsTr("Confirm swap") - Layout.fillWidth: true - } - - ColumnLayout { - spacing: 10 - Layout.fillWidth: true - - Rectangle { - Layout.fillWidth: true - color: root.theme.colors.inputBg - radius: 12 - implicitHeight: payColumn.implicitHeight + 24 - - ColumnLayout { - id: payColumn - anchors.fill: parent - anchors.margins: 12 - spacing: 4 - - Text { - text: qsTr("You pay") - color: root.theme.colors.textSecondary - font.pixelSize: 12 - Layout.fillWidth: true - } - Text { - text: qsTr("%1 %2") - .arg(root.snapshot.sellAmount || "") - .arg(root.snapshot.sellToken || "") - color: root.theme.colors.textPrimary - font.bold: true - font.pixelSize: 18 - elide: Text.ElideRight - Layout.fillWidth: true - } - } - } - - Rectangle { - Layout.fillWidth: true - color: root.theme.colors.inputBg - radius: 12 - implicitHeight: receiveColumn.implicitHeight + 24 - - ColumnLayout { - id: receiveColumn - anchors.fill: parent - anchors.margins: 12 - spacing: 4 - - Text { - text: qsTr("You receive at least") - color: root.theme.colors.textSecondary - font.pixelSize: 12 - Layout.fillWidth: true - } - Text { - text: qsTr("%1 %2") - .arg(root.snapshot.minReceived || "") - .arg(root.snapshot.buyToken || "") - color: root.theme.colors.textPrimary - font.bold: true - font.pixelSize: 18 - elide: Text.ElideRight - Layout.fillWidth: true - } - } - } - } - - SwapSummary { - Layout.fillWidth: true - theme: root.theme - swapModeText: root.snapshot.swapModeText || "" - feeText: root.snapshot.feeAmount || "" - priceImpactText: root.snapshot.priceImpactPercent || "" - priceImpactPercent: Number(root.snapshot.priceImpactPercentValue) || 0 - slippageText: root.snapshot.slippageTolerance || "" - minReceivedText: qsTr("%1 %2") - .arg(root.snapshot.minReceived || "") - .arg(root.snapshot.buyToken || "") - } - - RowLayout { - spacing: 10 - Layout.fillWidth: true - Layout.topMargin: 4 - - Button { - id: cancelButton - activeFocusOnTab: true - focusPolicy: Qt.StrongFocus - hoverEnabled: true - text: qsTr("Cancel") - Layout.fillWidth: true - Layout.minimumHeight: 48 - onClicked: root.cancel() - - contentItem: Text { - color: root.theme.colors.textPrimary - elide: Text.ElideRight - font.bold: true - font.pixelSize: 14 - horizontalAlignment: Text.AlignHCenter - text: cancelButton.text - verticalAlignment: Text.AlignVCenter - } - background: Rectangle { - border.color: root.theme.colors.borderStrong - border.width: 1 - color: cancelButton.pressed - ? root.theme.colors.panelHoverBg - : cancelButton.hovered || cancelButton.activeFocus - ? root.theme.colors.panelBg - : "transparent" - radius: 14 - } - } - - Button { - id: confirmButton - activeFocusOnTab: true - focusPolicy: Qt.StrongFocus - hoverEnabled: true - text: qsTr("Confirm Swap") - Layout.fillWidth: true - Layout.minimumHeight: 48 - onClicked: root.confirm() - - contentItem: Text { - color: "#ffffff" - elide: Text.ElideRight - font.bold: true - font.pixelSize: 14 - horizontalAlignment: Text.AlignHCenter - text: confirmButton.text - verticalAlignment: Text.AlignVCenter - } - background: Rectangle { - border.width: 0 - color: confirmButton.pressed - ? "#D95C1E" - : confirmButton.hovered || confirmButton.activeFocus - ? root.theme.colors.ctaHoverBg - : root.theme.colors.ctaBg - radius: 14 - } - } - } - } - } -} diff --git a/apps/amm/qml/components/swap/SwapConfirmationSummary.qml b/apps/amm/qml/components/swap/SwapConfirmationSummary.qml new file mode 100644 index 0000000..c3ff22e --- /dev/null +++ b/apps/amm/qml/components/swap/SwapConfirmationSummary.qml @@ -0,0 +1,88 @@ +import QtQuick +import QtQuick.Layouts + +ColumnLayout { + id: root + + property var theme + property var snapshot: ({}) + + spacing: 10 + + Rectangle { + Layout.fillWidth: true + color: root.theme.colors.inputBg + radius: 8 + implicitHeight: payColumn.implicitHeight + 24 + + ColumnLayout { + id: payColumn + anchors.fill: parent + anchors.margins: 12 + spacing: 4 + + Text { + Layout.fillWidth: true + text: qsTr("You pay") + color: root.theme.colors.textSecondary + font.pixelSize: 12 + } + + Text { + Layout.fillWidth: true + text: qsTr("%1 %2") + .arg(root.snapshot.sellAmount || "") + .arg(root.snapshot.sellToken || "") + color: root.theme.colors.textPrimary + font.bold: true + font.pixelSize: 18 + elide: Text.ElideRight + } + } + } + + Rectangle { + Layout.fillWidth: true + color: root.theme.colors.inputBg + radius: 8 + implicitHeight: receiveColumn.implicitHeight + 24 + + ColumnLayout { + id: receiveColumn + anchors.fill: parent + anchors.margins: 12 + spacing: 4 + + Text { + Layout.fillWidth: true + text: qsTr("You receive at least") + color: root.theme.colors.textSecondary + font.pixelSize: 12 + } + + Text { + Layout.fillWidth: true + text: qsTr("%1 %2") + .arg(root.snapshot.minReceived || "") + .arg(root.snapshot.buyToken || "") + color: root.theme.colors.textPrimary + font.bold: true + font.pixelSize: 18 + elide: Text.ElideRight + } + } + } + + SwapSummary { + Layout.fillWidth: true + theme: root.theme + swapModeText: root.snapshot.swapModeText || "" + feeText: root.snapshot.feeAmount || "" + priceImpactText: root.snapshot.priceImpactPercent || "" + priceImpactPercent: Number(root.snapshot.priceImpactPercentValue) || 0 + slippageText: root.snapshot.slippageTolerance || "" + minReceivedText: qsTr("%1 %2") + .arg(root.snapshot.minReceived || "") + .arg(root.snapshot.buyToken || "") + } +} diff --git a/apps/amm/qml/components/wallet/AccountControl.qml b/apps/amm/qml/components/wallet/AccountControl.qml deleted file mode 100644 index 9956eeb..0000000 --- a/apps/amm/qml/components/wallet/AccountControl.qml +++ /dev/null @@ -1,490 +0,0 @@ -import QtQuick -import QtQml -import QtQuick.Controls -import QtQuick.Layouts - -import Logos.Theme -import Logos.Controls - -// Header wallet control (Uniswap-style), with two states: -// - not connected → a "Connect" button that opens the create-wallet modal -// - connected → a single button showing the active account address; -// clicking it opens a popup (top-right, just under the -// button) holding the account selector, create-account and -// disconnect actions. -// The selected account address is exposed via selectedAddress for the -// trade/liquidity flows to use as the "from" account. -Item { - id: root - - // Backend replica (logos.module("amm_ui")) and its account model. - property var backend: null - property var accountModel: null - - readonly property bool connected: backend !== null && backend.isWalletOpen - - // Index of the active account. selectedAddress/selectedName are derived from - // the model mirror below so they stay valid while the popup (and its list) - // is closed. - property int selectedIndex: 0 - - // Non-visual mirror of the account model: realizes every row regardless of - // popup visibility, so the active account is addressable by index at all - // times (a ListView only realizes rows while it is shown). - Instantiator { - id: accounts - model: root.accountModel - delegate: QtObject { - readonly property string address: model.address ?? "" - readonly property string name: model.name ?? "" - readonly property string balance: model.balance ?? "" - readonly property bool isPublic: model.isPublic ?? false - } - } - - function entryAt(i) { - return (i >= 0 && i < accounts.count) ? accounts.objectAt(i) : null - } - - readonly property string selectedAddress: { - const e = root.entryAt(root.selectedIndex) - return e ? e.address : "" - } - readonly property string selectedName: { - const e = root.entryAt(root.selectedIndex) - return e ? e.name : "" - } - readonly property string selectedBalance: { - const e = root.entryAt(root.selectedIndex) - return e ? e.balance : "" - } - readonly property bool selectedIsPublic: { - const e = root.entryAt(root.selectedIndex) - return e ? e.isPublic : false - } - - // Keep the selection within bounds as accounts are added/removed. - function clampSelection() { - if (accounts.count === 0) { root.selectedIndex = 0; return } - if (root.selectedIndex < 0) root.selectedIndex = 0 - else if (root.selectedIndex >= accounts.count) root.selectedIndex = accounts.count - 1 - } - Connections { - target: root.accountModel - ignoreUnknownSignals: true - function onModelReset() { root.clampSelection() } - function onRowsInserted() { root.clampSelection() } - function onRowsRemoved() { root.clampSelection() } - } - - // 0x123456…cdef style truncation for the connected button label. - function truncated(addr) { - if (!addr) return "" - return addr.length > 13 ? (addr.substring(0, 6) + "…" + addr.substring(addr.length - 4)) : addr - } - - // Copy on the QML/view side. Routing this through the backend would call - // QGuiApplication::clipboard() in the (headless) module host process, which - // has no clipboard — that call tears the backend down, dropping the wallet - // connection. A hidden TextEdit copies via the GUI process that owns it. - TextEdit { id: clipboardProxy; visible: false } - function copyToClipboard(text) { - if (!text) return - clipboardProxy.text = text - clipboardProxy.selectAll() - clipboardProxy.copy() - clipboardProxy.deselect() - clipboardProxy.text = "" - } - - implicitWidth: root.connected ? connectedButton.width : connectButton.width - implicitHeight: 40 - - // ── Disconnected: Connect ──────────────────────────────────────────── - LogosButton { - id: connectButton - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - height: 40 - visible: !root.connected - enabled: root.backend !== null - text: qsTr("Connect") - onClicked: { - // Re-open an existing wallet; only show the create modal on first run. - if (root.backend && root.backend.walletExists) - logos.watch(root.backend.openExisting(), - function(ok) { if (!ok) console.warn("openExisting failed") }, - function(error) { console.warn("openExisting error:", error) }) - else - createWalletDialog.open() - } - } - - // ── Connected: address pill that toggles the wallet menu ───────────── - Rectangle { - id: connectedButton - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - visible: root.connected - implicitHeight: 40 - implicitWidth: connectedRow.implicitWidth + Theme.spacing.medium * 2 - radius: height / 2 - // Keep an opaque dark fill in both states: the navbar is white, and the - // active "muted" fill is translucent gray, which renders light over white - // and makes the white label unreadable. Signal "open" with an accent - // border instead. - color: Theme.palette.backgroundSecondary - border.width: 1 - border.color: walletMenu.opened ? Theme.palette.overlayOrange : "transparent" - - RowLayout { - id: connectedRow - anchors.centerIn: parent - spacing: Theme.spacing.small - - Rectangle { - Layout.preferredWidth: 8 - Layout.preferredHeight: 8 - radius: 4 - color: "#39c06a" - } - LogosText { - text: root.truncated(root.selectedAddress) || qsTr("Connected") - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.text - } - LogosText { - text: walletMenu.opened ? "▴" : "▾" - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textSecondary - } - } - - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - // CloseOnPressOutside already dismisses the popup on this same press - // (the button is outside it), so `opened` is false by the time this - // fires. Without the recency guard the dismissing click would just - // reopen it. If it just closed, leave it closed. - onClicked: { - if (walletMenu.opened || (Date.now() - walletMenu.lastClosedMs) < 200) - walletMenu.close() - else - walletMenu.open() - } - } - } - - // ── Wallet menu popup (top-right, under the connected button) ───────── - Popup { - id: walletMenu - parent: connectedButton - y: connectedButton.height + Theme.spacing.small - x: connectedButton.width - width // right-align under the button - width: 360 - padding: Theme.spacing.medium - closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside - - // Timestamp of the last dismissal, used by the toggle button to tell a - // genuine "open" click from the press that just closed the popup. - property real lastClosedMs: 0 - onClosed: { - walletMenu.lastClosedMs = Date.now() - // Always reopen on the main (selected-account) view. - if (viewStack.depth > 1) - viewStack.pop(null, StackView.Immediate) - } - - background: Rectangle { - color: Theme.palette.backgroundTertiary - border.width: 1 - border.color: Theme.palette.backgroundElevated - radius: Theme.spacing.radiusLarge - } - - // Two stacked views: the main view (active account + actions) and the - // accounts view (full list + create). The popup height follows the - // active view's natural height, animated so the resize isn't abrupt. - contentItem: StackView { - id: viewStack - clip: true - implicitWidth: walletMenu.availableWidth - implicitHeight: currentItem ? currentItem.implicitHeight : 0 - initialItem: mainView - - Behavior on implicitHeight { - NumberAnimation { duration: 160; easing.type: Easing.OutCubic } - } - - pushEnter: Transition { NumberAnimation { property: "opacity"; from: 0; to: 1; duration: 120 } } - pushExit: Transition { NumberAnimation { property: "opacity"; from: 1; to: 0; duration: 120 } } - popEnter: Transition { NumberAnimation { property: "opacity"; from: 0; to: 1; duration: 120 } } - popExit: Transition { NumberAnimation { property: "opacity"; from: 1; to: 0; duration: 120 } } - } - - // ── Main view: account icon + power icon, then the active account ── - Component { - id: mainView - - ColumnLayout { - spacing: Theme.spacing.medium - - // Top-right actions: open the account list / disconnect. - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacing.small - - Item { Layout.fillWidth: true } - - WalletIconButton { - iconSource: Qt.resolvedUrl("icons/account.svg") - onClicked: viewStack.push(accountsView) - } - WalletIconButton { - iconSource: Qt.resolvedUrl("icons/settings.svg") - onClicked: viewStack.push(settingsView) - } - WalletIconButton { - iconSource: Qt.resolvedUrl("icons/power.svg") - onClicked: { - walletMenu.close() - if (root.backend) root.backend.disconnectWallet() - } - } - } - - // Active account card. - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: cardColumn.implicitHeight + Theme.spacing.medium * 2 - radius: Theme.spacing.radiusLarge - color: Theme.palette.backgroundMuted - - ColumnLayout { - id: cardColumn - anchors.fill: parent - anchors.margins: Theme.spacing.medium - spacing: Theme.spacing.small - - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacing.small - - LogosText { - text: root.selectedName - font.pixelSize: Theme.typography.secondaryText - font.bold: true - } - Rectangle { - Layout.preferredWidth: tagLabel.implicitWidth + Theme.spacing.small * 2 - Layout.preferredHeight: tagLabel.implicitHeight + 4 - radius: 4 - color: Theme.palette.backgroundSecondary - LogosText { - id: tagLabel - anchors.centerIn: parent - text: root.selectedIsPublic ? qsTr("Public") : qsTr("Private") - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textSecondary - } - } - Item { Layout.fillWidth: true } - LogosText { - text: root.selectedBalance.length > 0 ? root.selectedBalance : "—" - font.bold: true - } - } - - RowLayout { - Layout.fillWidth: true - spacing: 0 - LogosText { - Layout.fillWidth: true - verticalAlignment: Text.AlignVCenter - text: root.selectedAddress - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textMuted - elide: Text.ElideMiddle - } - LogosCopyButton { - Layout.preferredHeight: 40 - Layout.preferredWidth: 40 - visible: root.selectedAddress.length > 0 - onCopyText: root.copyToClipboard(root.selectedAddress) - icon.color: Theme.palette.textMuted - } - } - } - } - } - } - - // ── Accounts view: back + full list + create ────────────────────── - Component { - id: accountsView - - ColumnLayout { - spacing: Theme.spacing.medium - - // Header: back to the main view + title. - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacing.small - - WalletIconButton { - iconSource: Qt.resolvedUrl("icons/back.svg") - onClicked: viewStack.pop() - } - LogosText { - Layout.fillWidth: true - text: qsTr("Accounts") - font.bold: true - color: Theme.palette.text - } - } - - // Account list: tap a row to make it the active account, then - // return to the main view so the selection is reflected. - ListView { - Layout.fillWidth: true - Layout.preferredHeight: Math.min(contentHeight, 260) - clip: true - model: root.accountModel - spacing: Theme.spacing.small - ScrollIndicator.vertical: ScrollIndicator { } - - delegate: AccountDelegate { - width: ListView.view.width - highlighted: index === root.selectedIndex - onClicked: { - root.selectedIndex = index - viewStack.pop() - } - onCopyRequested: (text) => root.copyToClipboard(text) - } - } - - LogosButton { - Layout.fillWidth: true - height: 40 - text: qsTr("Add") - // Leave the wallet menu open behind the (modal) dialog. - onClicked: createAccountDialog.open() - } - } - } - - // ── Settings view: back + editable network (sequencer) ──────────── - Component { - id: settingsView - - ColumnLayout { - spacing: Theme.spacing.medium - - // Header: back to the main view + title. - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacing.small - - WalletIconButton { - iconSource: Qt.resolvedUrl("icons/back.svg") - onClicked: viewStack.pop() - } - LogosText { - Layout.fillWidth: true - text: qsTr("Settings") - font.bold: true - color: Theme.palette.text - } - } - - LogosText { - text: qsTr("Network (sequencer URL)") - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textSecondary - } - - LogosTextField { - id: seqField - Layout.fillWidth: true - placeholderText: "http://127.0.0.1:3040" - // Initialize from the live value without binding, so typing - // isn't clobbered when sequencerAddr updates after a save. - Component.onCompleted: text = root.backend ? root.backend.sequencerAddr : "" - } - - LogosText { - id: seqStatus - Layout.fillWidth: true - visible: text.length > 0 - wrapMode: Text.WordWrap - font.pixelSize: Theme.typography.secondaryText - property bool ok: false - color: ok ? Theme.palette.success : Theme.palette.error - } - - LogosButton { - Layout.fillWidth: true - height: 40 - text: qsTr("Save") - onClicked: { - if (!root.backend) return - seqStatus.text = "" - logos.watch(root.backend.changeSequencerAddr(seqField.text), - function(ok) { - seqStatus.ok = ok - seqStatus.text = ok ? qsTr("Network updated.") - : qsTr("Failed to update network.") - }, - function(error) { - seqStatus.ok = false - seqStatus.text = qsTr("Error: %1").arg(error) - }) - } - } - } - } - } - - // ── Dialogs ────────────────────────────────────────────────────────── - CreateWalletDialog { - id: createWalletDialog - walletHome: root.backend ? root.backend.walletHome : "" - onCreateWallet: function(password) { - if (!root.backend) return - // createNewDefault returns the new wallet's seed phrase (empty on - // failure). On success we hand it to the dialog, which switches to - // its backup page — we do NOT close here, so the user can't skip it. - logos.watch(root.backend.createNewDefault(password), - function(mnemonic) { - if (mnemonic && mnemonic.length > 0) - createWalletDialog.mnemonic = mnemonic - else - createWalletDialog.createError = qsTr("Failed to create wallet. Please try again.") - }, - function(error) { - createWalletDialog.createError = qsTr("Error creating wallet: %1").arg(error) - }) - } - onCopyRequested: function(text) { - if (root.backend) root.backend.copyToClipboard(text) - } - } - - CreateAccountDialog { - id: createAccountDialog - onCreatePublicRequested: { - if (!root.backend) return - logos.watch(root.backend.createAccountPublic(), - function(_id) { /* model updates via NOTIFY after refresh */ }, - function(error) { console.warn("createAccountPublic failed:", error) }) - } - onCreatePrivateRequested: { - if (!root.backend) return - logos.watch(root.backend.createAccountPrivate(), - function(_id) { /* model updates via NOTIFY after refresh */ }, - function(error) { console.warn("createAccountPrivate failed:", error) }) - } - } -} diff --git a/apps/amm/qml/components/wallet/AccountDelegate.qml b/apps/amm/qml/components/wallet/AccountDelegate.qml deleted file mode 100644 index 14c03ee..0000000 --- a/apps/amm/qml/components/wallet/AccountDelegate.qml +++ /dev/null @@ -1,84 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts - -import Logos.Theme -import Logos.Controls - -// One account row in the account dropdown. Ported from the LEZ wallet UI. -ItemDelegate { - id: root - - // Emitted when the user clicks the copy icon; the parent view connects this - // to its QML-side clipboard helper (AccountControl.copyToClipboard). - signal copyRequested(string text) - - leftPadding: Theme.spacing.medium - rightPadding: Theme.spacing.medium - topPadding: Theme.spacing.medium - bottomPadding: Theme.spacing.medium - - background: Rectangle { - color: root.highlighted || root.hovered ? - Theme.palette.backgroundMuted : - Theme.palette.backgroundTertiary - radius: Theme.spacing.radiusLarge - } - - contentItem: ColumnLayout { - spacing: Theme.spacing.small - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacing.small - - LogosText { - text: model.name ?? "" - font.pixelSize: Theme.typography.secondaryText - font.bold: true - } - - Rectangle { - Layout.preferredWidth: tagLabel.implicitWidth + Theme.spacing.small * 2 - Layout.preferredHeight: tagLabel.implicitHeight + 4 - radius: 4 - color: Theme.palette.backgroundSecondary - - LogosText { - id: tagLabel - anchors.centerIn: parent - text: model.isPublic ? qsTr("Public") : qsTr("Private") - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textSecondary - } - } - - Item { Layout.fillWidth: true } - - LogosText { - text: model.balance && model.balance.length > 0 ? model.balance : "—" - font.bold: true - } - } - - RowLayout { - Layout.fillWidth: true - spacing: 0 - LogosText { - id: addressLabel - Layout.fillWidth: true - verticalAlignment: Text.AlignVCenter - text: model.address ?? "" - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textMuted - elide: Text.ElideMiddle - } - LogosCopyButton { - Layout.preferredHeight: 40 - Layout.preferredWidth: 40 - onCopyText: root.copyRequested(model.address) - visible: addressLabel.text - icon.color: Theme.palette.textMuted - } - } - } -} diff --git a/apps/amm/qml/components/wallet/CreateAccountDialog.qml b/apps/amm/qml/components/wallet/CreateAccountDialog.qml deleted file mode 100644 index 8070795..0000000 --- a/apps/amm/qml/components/wallet/CreateAccountDialog.qml +++ /dev/null @@ -1,102 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts - -import Logos.Theme -import Logos.Controls - -// Public/private account creation dialog. Ported from the LEZ wallet UI. -Popup { - id: root - - signal createPublicRequested() - signal createPrivateRequested() - - modal: true - dim: true - padding: Theme.spacing.large - closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside - - // Center on the full-window overlay rather than the small navbar control - // this popup is declared inside. - parent: Overlay.overlay - anchors.centerIn: parent - width: 360 - - background: Rectangle { - color: Theme.palette.backgroundSecondary - radius: Theme.spacing.radiusXlarge - border.color: Theme.palette.backgroundElevated - } - - contentItem: ColumnLayout { - id: contentLayout - // Pin to the popup's padded width so children stay within the modal. - width: root.availableWidth - spacing: Theme.spacing.large - - LogosText { - text: qsTr("Create account") - font.pixelSize: Theme.typography.titleText - font.weight: Theme.typography.weightBold - color: Theme.palette.text - } - - LogosText { - text: qsTr("Choose account type.") - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textSecondary - Layout.topMargin: -Theme.spacing.small - } - - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacing.medium - - ColumnLayout { - Layout.fillWidth: true - spacing: 0 - - LogosText { - text: qsTr("Private") - font.pixelSize: Theme.typography.primaryText - color: Theme.palette.text - } - LogosText { - text: qsTr("Private balance and activity.") - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textSecondary - wrapMode: Text.WordWrap - Layout.fillWidth: true - } - } - - LogosSwitch { - id: privateSwitch - checked: false - } - } - - RowLayout { - Layout.topMargin: Theme.spacing.medium - spacing: Theme.spacing.medium - Layout.fillWidth: true - LogosButton { - text: qsTr("Cancel") - Layout.fillWidth: true - onClicked: root.close() - } - LogosButton { - text: qsTr("Create") - Layout.fillWidth: true - onClicked: { - if (privateSwitch.checked) - root.createPrivateRequested() - else - root.createPublicRequested() - root.close() - } - } - } - } -} diff --git a/apps/amm/qml/components/wallet/CreateWalletDialog.qml b/apps/amm/qml/components/wallet/CreateWalletDialog.qml deleted file mode 100644 index 05c8d6e..0000000 --- a/apps/amm/qml/components/wallet/CreateWalletDialog.qml +++ /dev/null @@ -1,201 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts - -import Logos.Theme -import Logos.Controls - -// Wallet creation modal. Two pages, driven by whether a mnemonic exists yet: -// 1. Password entry — emits createWallet(password); the parent creates the -// wallet and, on success, sets `mnemonic` to the returned seed phrase. -// 2. Seed-phrase backup — shows the mnemonic once and gates dismissal behind -// an explicit acknowledgement. This is the only time the phrase is shown, -// so the popup is not auto-dismissable while it is visible. -// Storage/config live at the per-app default (backend.walletHome) — no path -// picking. Opened from the navbar "Connect" button. -Popup { - id: root - - // Where the wallet will be stored, shown for transparency. - property string walletHome: "" - property string createError: "" - // Set by the parent to the BIP39 seed phrase once creation succeeds. A - // non-empty value flips the dialog to the backup page. - property string mnemonic: "" - - signal createWallet(string password) - signal copyRequested(string text) - - modal: true - dim: true - padding: Theme.spacing.large - // Once the wallet exists we must not let the user dismiss the modal (and - // lose the only view of their seed phrase) by clicking away or pressing Esc. - closePolicy: root.mnemonic.length > 0 - ? Popup.NoAutoClose - : (Popup.CloseOnEscape | Popup.CloseOnPressOutside) - // Center on the full-window overlay rather than the small navbar control - // this popup is declared inside. - parent: Overlay.overlay - anchors.centerIn: parent - width: 380 - - onOpened: { - passwordField.text = "" - confirmField.text = "" - root.createError = "" - root.mnemonic = "" - passwordField.forceActiveFocus() - } - - background: Rectangle { - color: Theme.palette.backgroundSecondary - radius: Theme.spacing.radiusXlarge - border.color: Theme.palette.backgroundElevated - } - - contentItem: ColumnLayout { - // Pin to the popup's padded width so long text wraps and fillWidth - // children don't push the layout wider than the modal. - width: root.availableWidth - spacing: 0 - - // ── Page 1: password entry ──────────────────────────────────────── - ColumnLayout { - id: passwordPage - visible: root.mnemonic.length === 0 - Layout.fillWidth: true - spacing: Theme.spacing.large - - LogosText { - text: qsTr("Create your wallet") - font.pixelSize: Theme.typography.titleText - font.weight: Theme.typography.weightBold - color: Theme.palette.text - } - - LogosText { - text: qsTr("Secure your wallet with a password. It will be stored on this device at %1.") - .arg(root.walletHome || qsTr("the default location")) - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textSecondary - wrapMode: Text.WordWrap - Layout.fillWidth: true - Layout.topMargin: -Theme.spacing.small - } - - LogosTextField { - id: passwordField - Layout.fillWidth: true - placeholderText: qsTr("Password") - echoMode: TextInput.Password - Keys.onReturnPressed: createButton.tryCreate() - } - LogosTextField { - id: confirmField - Layout.fillWidth: true - placeholderText: qsTr("Confirm password") - echoMode: TextInput.Password - Keys.onReturnPressed: createButton.tryCreate() - } - - LogosText { - Layout.fillWidth: true - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.error - wrapMode: Text.WordWrap - visible: text.length > 0 - text: root.createError - } - - RowLayout { - Layout.topMargin: Theme.spacing.small - Layout.fillWidth: true - spacing: Theme.spacing.medium - LogosButton { - text: qsTr("Cancel") - Layout.fillWidth: true - onClicked: root.close() - } - LogosButton { - id: createButton - Layout.fillWidth: true - text: qsTr("Create Wallet") - function tryCreate() { - if (passwordField.text.length === 0) { - root.createError = qsTr("Password cannot be empty.") - } else if (passwordField.text !== confirmField.text) { - root.createError = qsTr("Passwords do not match.") - } else { - root.createError = "" - root.createWallet(passwordField.text) - } - } - onClicked: tryCreate() - } - } - } - - // ── Page 2: seed-phrase backup ──────────────────────────────────── - ColumnLayout { - id: backupPage - visible: root.mnemonic.length > 0 - Layout.fillWidth: true - spacing: Theme.spacing.large - - LogosText { - text: qsTr("Back up your recovery phrase") - font.pixelSize: Theme.typography.titleText - font.weight: Theme.typography.weightBold - color: Theme.palette.text - } - - LogosText { - text: qsTr("Write these words down in order and store them somewhere safe. Anyone with this phrase can control your wallet, and it will not be shown again — it is the only way to recover access.") - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textSecondary - wrapMode: Text.WordWrap - Layout.fillWidth: true - Layout.topMargin: -Theme.spacing.small - } - - Rectangle { - Layout.fillWidth: true - radius: Theme.spacing.radiusLarge - color: Theme.palette.backgroundElevated - implicitHeight: phraseText.implicitHeight + 2 * Theme.spacing.medium - - LogosText { - id: phraseText - anchors.fill: parent - anchors.margins: Theme.spacing.medium - text: root.mnemonic - wrapMode: Text.WordWrap - lineHeight: 1.4 - font.pixelSize: Theme.typography.primaryText - font.weight: Theme.typography.weightBold - color: Theme.palette.text - } - } - - LogosButton { - Layout.fillWidth: true - text: qsTr("Copy to clipboard") - onClicked: root.copyRequested(root.mnemonic) - } - - LogosCheckbox { - id: ackCheck - Layout.fillWidth: true - text: qsTr("I have safely backed up my recovery phrase") - } - - LogosButton { - Layout.fillWidth: true - enabled: ackCheck.checked - text: qsTr("Continue") - onClicked: root.close() - } - } - } -} diff --git a/apps/amm/qml/components/wallet/LogosCopyButton.qml b/apps/amm/qml/components/wallet/LogosCopyButton.qml deleted file mode 100644 index 7c1d7bd..0000000 --- a/apps/amm/qml/components/wallet/LogosCopyButton.qml +++ /dev/null @@ -1,39 +0,0 @@ -import QtQuick -import QtQuick.Controls - -import Logos.Theme - -Button { - id: root - - signal copyText() - - implicitWidth: 24 - implicitHeight: 24 - display: AbstractButton.IconOnly - flat: true - - property string iconSource: Qt.resolvedUrl("icons/copy.svg") - - icon.source: root.iconSource - icon.width: 24 - icon.height: 24 - icon.color: Theme.palette.textSecondary - - function reset() { - iconSource = Qt.resolvedUrl("icons/copy.svg") - } - - Timer { - id: resetTimer - interval: 1500 - repeat: false - onTriggered: root.reset() - } - - onClicked: { - root.copyText() - root.iconSource = Qt.resolvedUrl("icons/checkmark.svg") - resetTimer.restart() - } -} diff --git a/apps/amm/qml/components/wallet/WalletIconButton.qml b/apps/amm/qml/components/wallet/WalletIconButton.qml deleted file mode 100644 index 61545e0..0000000 --- a/apps/amm/qml/components/wallet/WalletIconButton.qml +++ /dev/null @@ -1,25 +0,0 @@ -import QtQuick -import QtQuick.Controls - -import Logos.Theme - -// Small icon-only action button for the wallet menu. Uses the same Button + -// icon.source/icon.color path as LogosCopyButton, which renders reliably here -// (LogosIconButton's Image + MultiEffect shader path does not). -Button { - id: root - - property url iconSource - property color iconColor: Theme.palette.textSecondary - property int iconSize: 18 - - implicitWidth: 32 - implicitHeight: 32 - display: AbstractButton.IconOnly - flat: true - - icon.source: root.iconSource - icon.width: root.iconSize - icon.height: root.iconSize - icon.color: root.iconColor -} diff --git a/apps/amm/qml/components/wallet/icons/settings.svg b/apps/amm/qml/components/wallet/icons/settings.svg deleted file mode 100644 index 77f89ae..0000000 --- a/apps/amm/qml/components/wallet/icons/settings.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/amm/qml/pages/LiquidityPage.qml b/apps/amm/qml/pages/LiquidityPage.qml index f471952..9dde13a 100644 --- a/apps/amm/qml/pages/LiquidityPage.qml +++ b/apps/amm/qml/pages/LiquidityPage.qml @@ -1,5 +1,6 @@ import QtQuick 2.15 import QtQuick.Layouts 1.15 +import Logos.Wallet import "../components/shared" import "../components/liquidity" import "../state" @@ -162,10 +163,18 @@ Item { } } - LiquidityConfirmationDialog { - id: confirmationDialog + Component { + id: liquidityConfirmationSummary - anchors.fill: parent + LiquidityConfirmationSummary { } + } + + TransactionConfirmationDialog { + id: confirmationDialog + title: snapshot.action === "add" + ? qsTr("Confirm add liquidity") + : qsTr("Confirm remove liquidity") + summary: liquidityConfirmationSummary onConfirmed: function (snapshot) { root.confirmLiquidityAction(snapshot); diff --git a/apps/amm/qml/pages/SwapPage.qml b/apps/amm/qml/pages/SwapPage.qml index 344d47a..d524e52 100644 --- a/apps/amm/qml/pages/SwapPage.qml +++ b/apps/amm/qml/pages/SwapPage.qml @@ -1,9 +1,10 @@ +pragma ComponentBehavior: Bound + import QtQuick 2.15 -import QtQuick.Controls 2.15 import QtQuick.Layouts 1.15 +import Logos.Wallet import "../components/shared" import "../components/swap" -import "../state" Item { id: root @@ -25,7 +26,7 @@ Item { } QtObject { - id: theme + id: pageTheme property bool isDark: true property var colors: isDark ? dark : light @@ -68,7 +69,7 @@ Item { Rectangle { anchors.fill: parent - color: theme.colors.background + color: pageTheme.colors.background Behavior on color { ColorAnimation { duration: 300 } } // Theme toggle @@ -77,19 +78,19 @@ Item { anchors.right: parent.right anchors.margins: 16 width: 44; height: 24; radius: 12 - color: theme.colors.panelBg - border.color: theme.colors.border + color: pageTheme.colors.panelBg + border.color: pageTheme.colors.border border.width: 1 Text { anchors.centerIn: parent - text: theme.isDark ? "☀" : "☾" + text: pageTheme.isDark ? "☀" : "☾" font.pixelSize: 13 - color: theme.colors.textSecondary + color: pageTheme.colors.textSecondary } MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor - onClicked: theme.isDark = !theme.isDark + onClicked: pageTheme.isDark = !pageTheme.isDark } } @@ -100,7 +101,7 @@ Item { SwapCard { id: swapCard Layout.alignment: Qt.AlignHCenter - theme: theme + theme: pageTheme tokens: root.tokens backend: root.backend width: Math.min(480, root.width - 32) @@ -126,9 +127,9 @@ Item { Text { Layout.alignment: Qt.AlignHCenter - text: "Buy and sell crypto on LEZ." + text: "Buy and sell crypto on LEZ." textFormat: Text.RichText - color: theme.colors.textSecondary + color: pageTheme.colors.textSecondary font.pixelSize: 15 horizontalAlignment: Text.AlignHCenter } @@ -138,7 +139,7 @@ Item { id: tokenModal anchors.fill: parent z: 10 - theme: theme + theme: pageTheme tokens: root.tokens property string targetSide: "sell" @@ -161,10 +162,19 @@ Item { } } - SwapConfirmationDialog { + Component { + id: swapConfirmationSummary + + SwapConfirmationSummary { + theme: pageTheme + } + } + + TransactionConfirmationDialog { id: swapConfirmationDialog - anchors.fill: parent - theme: theme + title: qsTr("Confirm swap") + confirmText: qsTr("Confirm swap") + summary: swapConfirmationSummary onConfirmed: function(snapshot) { // The dialog only shows a preview snapshot; the actual diff --git a/apps/amm/src/AccountModel.cpp b/apps/amm/src/AccountModel.cpp deleted file mode 100644 index 63992f8..0000000 --- a/apps/amm/src/AccountModel.cpp +++ /dev/null @@ -1,79 +0,0 @@ -#include "AccountModel.h" - -#include - -AccountModel::AccountModel(QObject* parent) - : QAbstractListModel(parent) -{ -} - -int AccountModel::rowCount(const QModelIndex& parent) const -{ - if (parent.isValid()) - return 0; - return m_entries.size(); -} - -QVariant AccountModel::data(const QModelIndex& index, int role) const -{ - if (!index.isValid() || index.row() < 0 || index.row() >= m_entries.size()) - return QVariant(); - - const AccountEntry& e = m_entries.at(index.row()); - switch (role) { - case NameRole: return e.name; - case AddressRole: return e.address; - case BalanceRole: return e.balance; - case IsPublicRole: return e.isPublic; - default: return QVariant(); - } -} - -QHash AccountModel::roleNames() const -{ - return { - { NameRole, "name" }, - { AddressRole, "address" }, - { BalanceRole, "balance" }, - { IsPublicRole, "isPublic" } - }; -} - -void AccountModel::replaceFromJsonArray(const QJsonArray& arr) -{ - beginResetModel(); - const int oldCount = m_entries.size(); - m_entries.clear(); - int idx = 0; - for (const QJsonValue& v : arr) { - AccountEntry e; - e.name = QStringLiteral("Account %1").arg(++idx); - e.balance = QString(); - if (v.isObject()) { - const QJsonObject obj = v.toObject(); - e.address = obj.value(QStringLiteral("account_id")).toString(); - e.isPublic = obj.value(QStringLiteral("is_public")).toBool(true); - } else { - e.address = v.toString(); - e.isPublic = true; - } - m_entries.append(e); - } - endResetModel(); - if (oldCount != m_entries.size()) - emit countChanged(); -} - -void AccountModel::setBalanceByAddress(const QString& address, const QString& balance) -{ - for (int i = 0; i < m_entries.size(); ++i) { - if (m_entries.at(i).address == address) { - if (m_entries.at(i).balance != balance) { - m_entries[i].balance = balance; - const QModelIndex idx = index(i, 0); - emit dataChanged(idx, idx, { BalanceRole }); - } - return; - } - } -} diff --git a/apps/amm/src/AccountModel.h b/apps/amm/src/AccountModel.h deleted file mode 100644 index 918839c..0000000 --- a/apps/amm/src/AccountModel.h +++ /dev/null @@ -1,47 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -// One wallet account row. Mirrors the shape returned by the core wallet -// module's list_accounts() (account_id + is_public), with a display name and -// a lazily-fetched balance. -struct AccountEntry { - QString name; - QString address; - QString balance; - bool isPublic = true; -}; - -// QAbstractListModel of wallet accounts, exposed to QML via -// logos.model("amm_ui", "accountModel"). Ported from the LEZ wallet UI. -class AccountModel : public QAbstractListModel { - Q_OBJECT - Q_PROPERTY(int count READ count NOTIFY countChanged) -public: - enum Role { - NameRole = Qt::UserRole + 1, - AddressRole, - BalanceRole, - IsPublicRole - }; - Q_ENUM(Role) - - explicit AccountModel(QObject* parent = nullptr); - - int rowCount(const QModelIndex& parent = QModelIndex()) const override; - QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; - QHash roleNames() const override; - - void replaceFromJsonArray(const QJsonArray& arr); - void setBalanceByAddress(const QString& address, const QString& balance); - int count() const { return m_entries.size(); } - -signals: - void countChanged(); - -private: - QVector m_entries; -}; diff --git a/apps/amm/src/AmmUiBackend.cpp b/apps/amm/src/AmmUiBackend.cpp index f9eb89d..6b30cd1 100644 --- a/apps/amm/src/AmmUiBackend.cpp +++ b/apps/amm/src/AmmUiBackend.cpp @@ -20,6 +20,8 @@ #include #include +#include "LogosWalletProvider.h" +#include "WalletController.h" #include "logos_api.h" #include "logos_sdk.h" @@ -51,17 +53,6 @@ static bool ammDebugEnabled() if (ammDebugEnabled()) qWarning().noquote() namespace { - const char SETTINGS_ORG[] = "Logos"; - const char SETTINGS_APP[] = "AmmUI"; - // Sticky "user pressed Disconnect" flag so the wallet stays locked across - // relaunches until the user reconnects. - const char DISCONNECTED_KEY[] = "disconnected"; - const int WALLET_FFI_SUCCESS = 0; - - // Wallet home env override. Mirrors LEZ's own var so the app shares the - // canonical wallet (~/.lee/wallet) used by the wallet UI and other apps. - const char WALLET_HOME_ENV[] = "LEE_WALLET_HOME_DIR"; - // Absolute path to the deployed AMM program's RISC Zero program binary // (amm.bin — the `ProgramBinary` `.bin` from the docker guest build, decoded // on the Rust side via `ProgramBinary::decode`; NOT a raw ELF — pointing at @@ -77,13 +68,6 @@ namespace { // doesn't need a hardcoded/dummy token list. const char TOKENS_CONFIG_ENV[] = "TOKENS_CONFIG"; - // Normalise file:// URLs and OS paths to a plain local path. - QString toLocalPath(const QString& path) { - if (path.startsWith("file://") || path.contains("/")) - return QUrl::fromUserInput(path).toLocalFile(); - return path; - } - QString bytes32ToHex(const uint8_t (&b)[32]) { return QString::fromLatin1(QByteArray(reinterpret_cast(b), 32).toHex()); } @@ -157,367 +141,86 @@ namespace { } } -QString AmmUiBackend::defaultWalletHome() -{ - const QByteArray override = qgetenv(WALLET_HOME_ENV); - if (!override.isEmpty()) - return QString::fromLocal8Bit(override); - // LEZ's canonical wallet home, shared with the wallet UI and other LEZ apps - // (matches lez/wallet get_home_default_path()). - return QDir::homePath() + QStringLiteral("/.lee/wallet"); -} - -QString AmmUiBackend::defaultConfigPath() const -{ - return defaultWalletHome() + QStringLiteral("/wallet_config.json"); -} - -QString AmmUiBackend::defaultStoragePath() const -{ - return defaultWalletHome() + QStringLiteral("/storage.json"); -} - AmmUiBackend::AmmUiBackend(LogosAPI* logosAPI, QObject* parent) : AmmUiBackendSimpleSource(parent), - m_accountModel(new AccountModel(this)), m_logosAPI(logosAPI ? logosAPI : new LogosAPI("amm_ui", this)), - m_logos(new LogosModules(m_logosAPI)), - m_net(new QNetworkAccessManager(this)), - m_reachabilityTimer(new QTimer(this)) + m_logos(std::make_unique(m_logosAPI)), + m_wallet(std::make_unique(m_logosAPI)), + m_walletController(std::make_unique( + *m_wallet, QStringLiteral("AmmUI"))) { - // PROP defaults via the generated setters. - setIsWalletOpen(false); - setLastSyncedBlock(0); - setCurrentBlockHeight(0); - setWalletHome(defaultWalletHome()); - // Assume reachable until a probe proves otherwise (avoids a startup flash). - setSequencerReachable(true); - - // Periodically re-probe the sequencer so the banner reacts to a node going - // up/down while the app is running. Probes are no-ops until a wallet (and - // thus a sequencer address) is open. - m_reachabilityTimer->setInterval(10000); - connect(m_reachabilityTimer, &QTimer::timeout, this, [this]() { checkReachability(); }); - m_reachabilityTimer->start(); - - // Always resolve against the canonical wallet home (LEE_WALLET_HOME_DIR or - // ~/.lee/wallet). We intentionally don't seed config/storage paths from - // QSettings anymore: a previously-persisted per-app path (~/.lee/amm-wallet) - // would otherwise override the default and pin the app to the old keystore. - - // A wallet exists on disk if its storage file is present (drives whether - // the navbar "Connect" reconnects or offers to create a wallet). - const QString effectiveStorage = storagePath().isEmpty() ? defaultStoragePath() : storagePath(); - setWalletExists(QFileInfo::exists(effectiveStorage)); - - // ui-host runs our constructor inside initLogos(), synchronously, BEFORE - // it enables remoting and emits READY. Any blocking RPC here would stall - // ui-host startup past its ready watchdog. Defer the open+refresh chain to - // the first event-loop tick so ui-host finishes wiring itself up first. - QTimer::singleShot(0, this, [this]() { openOrAdoptWallet(); }); - - // Save wallet on quit; host may not call destructors so this is best-effort. - connect(qApp, &QCoreApplication::aboutToQuit, this, - [this]() { saveWallet(); }, Qt::DirectConnection); + connect(m_walletController.get(), &WalletController::stateChanged, + this, &AmmUiBackend::syncWalletState); + syncWalletState(); + m_walletController->start(); } -AmmUiBackend::~AmmUiBackend() +AmmUiBackend::~AmmUiBackend() = default; + +WalletAccountModel* AmmUiBackend::accountModel() const { - saveWallet(); - delete m_logos; -} - -void AmmUiBackend::openOrAdoptWallet() -{ - // Respect an explicit user disconnect: stay locked, show "Connect". - if (QSettings(SETTINGS_ORG, SETTINGS_APP).value(DISCONNECTED_KEY, false).toBool()) - return; - - // In Basecamp the logos_execution_zone module is a single shared instance, - // so the wallet may already be open (e.g. opened by the dedicated wallet - // app). Adopt that wallet instead of fighting over it: mirror its state - // rather than re-opening from disk, which could clobber unsaved in-memory - // accounts the other app holds. A freshly-created shared wallet can be open - // with zero accounts, so we can't key off list_accounts() alone (see - // sharedWalletIsOpen). - if (sharedWalletIsOpen()) { - const QJsonArray existing = QJsonArray::fromVariantList(m_logos->logos_execution_zone.list_accounts()); - qDebug() << "AmmUiBackend: adopting already-open shared wallet" - << existing.size() << "accounts"; - setIsWalletOpen(true); - m_accountModel->replaceFromJsonArray(existing); - refreshBalances(); - refreshSequencerAddr(); - return; - } - - // Standalone (own core instance): auto-open a previously-created wallet. - // Use persisted paths if the user picked custom ones, else the per-app - // default. Only open if the storage actually exists, otherwise stay closed - // so QML shows the "Connect" entry point (no noisy FFI errors on first run). - const QString cfg = configPath().isEmpty() ? defaultConfigPath() : configPath(); - const QString stg = storagePath().isEmpty() ? defaultStoragePath() : storagePath(); - if (!QFileInfo::exists(stg)) - return; // No wallet yet — QML shows "Connect". - - qDebug() << "AmmUiBackend: opening wallet with config" << cfg << "storage" << stg; - const int err = m_logos->logos_execution_zone.open(cfg, stg); - if (err == WALLET_FFI_SUCCESS) { - persistConfigPath(cfg); - persistStoragePath(stg); - setIsWalletOpen(true); - refreshAccounts(); - refreshBlockHeights(); - refreshSequencerAddr(); - } else { - qWarning() << "AmmUiBackend: wallet open failed, code" << err; - } -} - -bool AmmUiBackend::sharedWalletIsOpen() -{ - // Treat the shared core as "already open" ONLY when it actually holds - // accounts. We used to also treat a non-empty sequencer address as proof of - // an open wallet ("a closed core returns an empty string"), but the wallet - // module now reports a DEFAULT sequencer (e.g. http://localhost:…) even on a - // CLOSED core. That made standalone launches wrongly take the adopt path — - // mirroring an empty account list and returning early — instead of opening - // the real on-disk wallet, so the user saw no accounts. Keying off accounts - // alone means a genuinely-open shared wallet (Basecamp) is still adopted, - // while a closed standalone core correctly falls through to open-from-disk. - // Tradeoff: a shared wallet that is open but holds ZERO accounts is treated - // as closed here, so it won't be adopted — an accepted edge, preferable to - // the default-sequencer false-positive that keying off the address caused. - return !QJsonArray::fromVariantList(m_logos->logos_execution_zone.list_accounts()).isEmpty(); -} - -QString AmmUiBackend::createNewDefault(QString password) -{ - QDir().mkpath(defaultWalletHome()); - return createNew(defaultConfigPath(), defaultStoragePath(), password); -} - -QString AmmUiBackend::createNew(QString configPath, QString storagePath, QString password) -{ - const QString localConfig = toLocalPath(configPath); - const QString localStorage = toLocalPath(storagePath); - // create_new returns the new wallet's BIP39 mnemonic (empty on failure). We - // hand it back to the caller instead of discarding it: wallet creation is - // the only moment the seed phrase is recoverable, so the UI must force a - // backup step before the user can proceed. - const QString mnemonic = m_logos->logos_execution_zone.create_new(localConfig, localStorage, password); - if (mnemonic.isEmpty()) { - qWarning() << "AmmUiBackend: create_new failed (empty mnemonic)"; - return QString(); - } - - persistConfigPath(localConfig); - persistStoragePath(localStorage); - setWalletExists(true); - QSettings(SETTINGS_ORG, SETTINGS_APP).setValue(DISCONNECTED_KEY, false); - setIsWalletOpen(true); - refreshAccounts(); - refreshBlockHeights(); - refreshSequencerAddr(); - return mnemonic; -} - -bool AmmUiBackend::openExisting() -{ - // Adopt a shared open wallet (Basecamp), else open our own from disk. A - // freshly-created shared wallet can be open with zero accounts, so probe - // open-ness rather than keying off list_accounts() alone. - if (sharedWalletIsOpen()) { - const QJsonArray existing = QJsonArray::fromVariantList(m_logos->logos_execution_zone.list_accounts()); - setIsWalletOpen(true); - m_accountModel->replaceFromJsonArray(existing); - refreshBalances(); - refreshSequencerAddr(); - QSettings(SETTINGS_ORG, SETTINGS_APP).setValue(DISCONNECTED_KEY, false); - return true; - } - - const QString cfg = configPath().isEmpty() ? defaultConfigPath() : configPath(); - const QString stg = storagePath().isEmpty() ? defaultStoragePath() : storagePath(); - if (!QFileInfo::exists(stg)) - return false; - - const int err = m_logos->logos_execution_zone.open(cfg, stg); - if (err != WALLET_FFI_SUCCESS) { - qWarning() << "AmmUiBackend: openExisting failed, code" << err; - return false; - } - persistConfigPath(cfg); - persistStoragePath(stg); - setIsWalletOpen(true); - QSettings(SETTINGS_ORG, SETTINGS_APP).setValue(DISCONNECTED_KEY, false); - refreshAccounts(); - refreshBlockHeights(); - refreshSequencerAddr(); - return true; -} - -void AmmUiBackend::disconnectWallet() -{ - // UI-local lock: persist wallet state, drop our view of it, and remember - // the choice. We do NOT close the core module's wallet handle — in Basecamp - // that instance is shared with other apps. - saveWallet(); - setIsWalletOpen(false); - m_accountModel->replaceFromJsonArray(QJsonArray()); - QSettings(SETTINGS_ORG, SETTINGS_APP).setValue(DISCONNECTED_KEY, true); + return m_walletController->accountModel(); } QString AmmUiBackend::createAccountPublic() { - const QString result = m_logos->logos_execution_zone.create_account_public(); - if (!result.isEmpty()) - refreshAccounts(); - return result; + return m_walletController->createAccount(true); } QString AmmUiBackend::createAccountPrivate() { - const QString result = m_logos->logos_execution_zone.create_account_private(); - if (!result.isEmpty()) - refreshAccounts(); - return result; + return m_walletController->createAccount(false); } void AmmUiBackend::refreshAccounts() { - const QJsonArray arr = QJsonArray::fromVariantList(m_logos->logos_execution_zone.list_accounts()); - m_accountModel->replaceFromJsonArray(arr); - refreshBalances(); + m_walletController->refresh(); } void AmmUiBackend::refreshBalances() { - refreshBlockHeights(); - if (currentBlockHeight() > 0) - m_logos->logos_execution_zone.sync_to_block(static_cast(currentBlockHeight())); - - for (int i = 0; i < m_accountModel->count(); ++i) { - const QModelIndex idx = m_accountModel->index(i, 0); - const QString addr = m_accountModel->data(idx, AccountModel::AddressRole).toString(); - const bool isPub = m_accountModel->data(idx, AccountModel::IsPublicRole).toBool(); - m_accountModel->setBalanceByAddress(addr, getBalance(addr, isPub)); - } - saveWallet(); + m_walletController->refresh(); } QString AmmUiBackend::getBalance(QString accountIdHex, bool isPublic) { - return m_logos->logos_execution_zone.get_balance(accountIdHex, isPublic); + return m_walletController->balance(accountIdHex, isPublic); } -void AmmUiBackend::refreshBlockHeights() +QString AmmUiBackend::createNewDefault(QString password) { - const int lastVal = m_logos->logos_execution_zone.get_last_synced_block(); - const int currentVal = m_logos->logos_execution_zone.get_current_block_height(); - if (lastSyncedBlock() != lastVal) - setLastSyncedBlock(lastVal); - if (currentBlockHeight() != currentVal) - setCurrentBlockHeight(currentVal); + return m_walletController->createDefaultWallet(password); } -void AmmUiBackend::refreshSequencerAddr() +QString AmmUiBackend::createNew(QString configPath, + QString storagePath, + QString password) { - const QString addr = m_logos->logos_execution_zone.get_sequencer_addr(); - if (sequencerAddr() != addr) - setSequencerAddr(addr); - // Probe right away so the banner reflects the (possibly new) endpoint - // without waiting for the next periodic tick. - checkReachability(); + return m_walletController->createWallet(configPath, storagePath, password); } -void AmmUiBackend::checkReachability() +bool AmmUiBackend::openExisting() { - const QString addr = sequencerAddr(); - if (addr.isEmpty()) - return; - - QNetworkRequest req{QUrl(addr)}; - req.setTransferTimeout(4000); - QNetworkReply* reply = m_net->get(req); - connect(reply, &QNetworkReply::finished, this, [this, reply]() { - // Any HTTP response (even a 404) means the node is up; only a transport - // failure (connection refused, host not found, timeout) counts as down. - const bool gotHttpStatus = - reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).isValid(); - const bool reachable = gotHttpStatus || reply->error() == QNetworkReply::NoError; - if (sequencerReachable() != reachable) - setSequencerReachable(reachable); - reply->deleteLater(); - }); + return m_walletController->open(); } -void AmmUiBackend::saveWallet() +void AmmUiBackend::disconnectWallet() { - if (isWalletOpen()) - m_logos->logos_execution_zone.save(); + m_walletController->disconnect(); } -// These only update the in-session PROPs (so subsequent open/refresh calls -// reuse the same path). They are no longer written to QSettings: the app -// always resolves against the canonical wallet home, so there's nothing to -// remember across launches. -void AmmUiBackend::persistConfigPath(const QString& path) +void AmmUiBackend::syncWalletState() { - setConfigPath(toLocalPath(path)); -} - -void AmmUiBackend::persistStoragePath(const QString& path) -{ - setStoragePath(toLocalPath(path)); -} - -bool AmmUiBackend::changeSequencerAddr(QString url) -{ - const QString trimmed = url.trimmed(); - if (trimmed.isEmpty()) { - qWarning() << "AmmUiBackend: refusing to set empty sequencer_addr"; - return false; - } - - const QString cfg = configPath().isEmpty() ? defaultConfigPath() : configPath(); - - // Preserve the other config fields (poll timeouts, retries) — only swap the - // endpoint. The wallet reads this file on open via from_path_or_initialize_default. - QJsonObject obj; - QFile in(cfg); - if (in.open(QIODevice::ReadOnly)) { - obj = QJsonDocument::fromJson(in.readAll()).object(); - in.close(); - } - obj.insert(QStringLiteral("sequencer_addr"), trimmed); - - QFile out(cfg); - if (!out.open(QIODevice::WriteOnly | QIODevice::Truncate)) { - qWarning() << "AmmUiBackend: cannot write wallet config" << cfg; - return false; - } - out.write(QJsonDocument(obj).toJson(QJsonDocument::Indented)); - out.close(); - - // Re-open so the live wallet uses the new endpoint right away. - if (isWalletOpen()) { - const QString stg = storagePath().isEmpty() ? defaultStoragePath() : storagePath(); - const int err = m_logos->logos_execution_zone.open(cfg, stg); - if (err != WALLET_FFI_SUCCESS) { - qWarning() << "AmmUiBackend: reopen after sequencer change failed, code" << err; - return false; - } - refreshSequencerAddr(); - refreshAccounts(); - } - return true; -} - -void AmmUiBackend::copyToClipboard(QString text) -{ - if (QGuiApplication::clipboard()) - QGuiApplication::clipboard()->setText(text); + const WalletUiState& state = m_walletController->state(); + setIsWalletOpen(state.isWalletOpen); + setWalletExists(state.walletExists); + setConfigPath(state.configPath); + setStoragePath(state.storagePath); + setWalletHome(state.walletHome); + setLastSyncedBlock(state.lastSyncedBlock); + setCurrentBlockHeight(state.currentBlockHeight); + setSequencerAddr(state.sequencerAddress); + setSequencerReachable(state.sequencerReachable); } QString AmmUiBackend::normalizeAccountId(const QString& id) diff --git a/apps/amm/src/AmmUiBackend.h b/apps/amm/src/AmmUiBackend.h index 0fd09f0..244ccc6 100644 --- a/apps/amm/src/AmmUiBackend.h +++ b/apps/amm/src/AmmUiBackend.h @@ -1,14 +1,15 @@ #ifndef AMM_UI_BACKEND_H #define AMM_UI_BACKEND_H -#include +#include + #include #include #include #include "rep_AmmUiBackend_source.h" -#include "AccountModel.h" +#include "WalletAccountModel.h" extern "C" { #include "amm_client_ffi.h" @@ -16,38 +17,29 @@ extern "C" { class LogosAPI; struct LogosModules; -class QNetworkAccessManager; -class QTimer; +class LogosWalletProvider; +class WalletController; -// Source-side implementation of the AmmUiBackend .rep interface. -// Inheriting from AmmUiBackendSimpleSource gives us the generated PROPs and -// SLOTs from AmmUiBackend.rep — all the simple ones flow over QtRO. Talks to -// the core logos_execution_zone wallet module via LogosModules. class AmmUiBackend : public AmmUiBackendSimpleSource { Q_OBJECT - Q_PROPERTY(AccountModel* accountModel READ accountModel CONSTANT) + Q_PROPERTY(WalletAccountModel* accountModel READ accountModel CONSTANT) public: explicit AmmUiBackend(LogosAPI* logosAPI = nullptr, QObject* parent = nullptr); ~AmmUiBackend() override; - AccountModel* accountModel() const { return m_accountModel; } + WalletAccountModel* accountModel() const; public slots: - // Overrides of the pure-virtual slots generated from the .rep. QString createAccountPublic() override; QString createAccountPrivate() override; void refreshAccounts() override; void refreshBalances() override; QString getBalance(QString accountIdHex, bool isPublic) override; - // Return the new wallet's BIP39 mnemonic (empty string on failure) so the - // UI can force a one-time seed-phrase backup step. QString createNewDefault(QString password) override; QString createNew(QString configPath, QString storagePath, QString password) override; bool openExisting() override; void disconnectWallet() override; - bool changeSequencerAddr(QString url) override; - void copyToClipboard(QString text) override; // AMM QVariantMap resolvePool(QString defAHex, QString defBHex) override; @@ -59,43 +51,27 @@ public slots: QVariantList tokenList() override; private: - // Per-app wallet home (kept distinct from the wallet's canonical - // ~/.lee/wallet so standalone instances stay isolated; Basecamp sharing - // is handled by adopting an already-open shared wallet on startup). - static QString defaultWalletHome(); - QString defaultConfigPath() const; - QString defaultStoragePath() const; + void syncWalletState(); - void persistConfigPath(const QString& path); - void persistStoragePath(const QString& path); // Normalizes an account id given as either 64-char lowercase/uppercase hex // or base58 to lowercase hex. Returns an empty QString if `id` is neither // (or the base58 decode fails), so callers can detect and skip it. QString normalizeAccountId(const QString& id); - void openOrAdoptWallet(); - // True when the shared core already has a wallet open — including a freshly - // created one with zero accounts. See the definition for why list_accounts() - // alone is insufficient. - bool sharedWalletIsOpen(); - void refreshBlockHeights(); - void refreshSequencerAddr(); - void saveWallet(); // Returns the deployed AMM program-binary bytes (a RISC Zero ProgramBinary // .bin, not a raw ELF) from $AMM_PROGRAM_BIN, or an empty QByteArray (with a // qWarning) if the env var is unset/unreadable/empty. QByteArray loadAmmElf(); - // Probe the configured sequencer over HTTP and update sequencerReachable. - void checkReachability(); - - AccountModel* m_accountModel; - LogosAPI* m_logosAPI; - LogosModules* m_logos; - - QNetworkAccessManager* m_net; - QTimer* m_reachabilityTimer; + // Direct module handle for the AMM/swap path (resolvePool/swapExactInput/ + // tokenList). The shared wallet provider exposes only wallet-level ops, not + // the raw account-id / get_account_public / send_generic_public_transaction + // calls the AMM path needs, so keep a thin LogosModules over the same + // LogosAPI as the wallet provider. + std::unique_ptr m_logos; + std::unique_ptr m_wallet; + std::unique_ptr m_walletController; }; #endif // AMM_UI_BACKEND_H diff --git a/apps/amm/src/AmmUiBackend.rep b/apps/amm/src/AmmUiBackend.rep index 4827e07..f389b9d 100644 --- a/apps/amm/src/AmmUiBackend.rep +++ b/apps/amm/src/AmmUiBackend.rep @@ -37,13 +37,6 @@ class AmmUiBackend // Basecamp, does not close the wallet other apps share. SLOT(void disconnectWallet()) - // Settings. Rewrites the wallet config's sequencer_addr and re-opens the - // wallet so the new network takes effect immediately. - SLOT(bool changeSequencerAddr(QString url)) - - // Misc - SLOT(void copyToClipboard(QString text)) - // AMM // Derives the AMM pool's PDAs (config/pool/vaults/current-tick) from the // deployed AMM program binary (see AMM_PROGRAM_BIN — a RISC Zero diff --git a/apps/shared/wallet/CMakeLists.txt b/apps/shared/wallet/CMakeLists.txt new file mode 100644 index 0000000..37f1663 --- /dev/null +++ b/apps/shared/wallet/CMakeLists.txt @@ -0,0 +1,168 @@ +cmake_minimum_required(VERSION 3.21) + +if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) + project(LogosWallet LANGUAGES CXX) + include(CTest) +endif() + +option(LOGOS_WALLET_BUILD_QML "Build the Logos.Wallet QML module" ON) +option(LOGOS_WALLET_BUILD_ACCESS "Build the generated-SDK wallet adapter" ON) +set(LOGOS_WALLET_GENERATED_DIR "" CACHE PATH "Path to generated Logos SDK sources") + +if(LOGOS_WALLET_BUILD_ACCESS + AND NOT EXISTS "${LOGOS_WALLET_GENERATED_DIR}/logos_sdk.h" + AND NOT EXISTS "${LOGOS_WALLET_GENERATED_DIR}/include/logos_sdk.h") + message(FATAL_ERROR + "logos_wallet_access requires logos_sdk.h; set LOGOS_WALLET_GENERATED_DIR" + ) +endif() + +find_package(Qt6 6.8 REQUIRED COMPONENTS Core) +if(LOGOS_WALLET_BUILD_ACCESS OR BUILD_TESTING) + find_package(Qt6 6.8 REQUIRED COMPONENTS Network) +endif() +set(CMAKE_AUTOMOC ON) + +if(LOGOS_WALLET_BUILD_ACCESS) + add_library(logos_wallet_access STATIC + src/WalletProvider.h + src/WalletProvider.cpp + src/LogosWalletProvider.h + src/LogosWalletProvider.cpp + src/WalletAccountModel.h + src/WalletAccountModel.cpp + src/WalletController.h + src/WalletController.cpp + ) + set_target_properties(logos_wallet_access PROPERTIES + AUTOMOC ON + POSITION_INDEPENDENT_CODE ON + ) + target_compile_features(logos_wallet_access PUBLIC cxx_std_17) + target_include_directories(logos_wallet_access + PUBLIC + "$" + PRIVATE + "${LOGOS_WALLET_GENERATED_DIR}" + "${LOGOS_WALLET_GENERATED_DIR}/include" + ) + target_link_libraries(logos_wallet_access + PUBLIC Qt6::Core + PRIVATE Qt6::Network + ) +endif() + +if(LOGOS_WALLET_BUILD_QML) + find_package(Qt6 6.8 REQUIRED COMPONENTS Qml Quick QuickControls2) + qt_policy(SET QTP0004 NEW) + + set(wallet_qml_output_dir "${CMAKE_CURRENT_BINARY_DIR}/qml/Logos/Wallet") + set(wallet_internal_qml + qml/internal/WalletIconButton.qml + qml/internal/CopyButton.qml + qml/internal/AccountDelegate.qml + qml/internal/CreateAccountDialog.qml + qml/internal/CreateWalletDialog.qml + qml/internal/WalletMessageDialog.qml + ) + set(wallet_public_qml + qml/WalletControl.qml + qml/TransactionConfirmationDialog.qml + qml/SubmittedTransaction.qml + ) + set(wallet_icons + qml/internal/icons/account.svg + qml/internal/icons/back.svg + qml/internal/icons/checkmark.svg + qml/internal/icons/copy.svg + qml/internal/icons/power.svg + ) + foreach(qml_file IN LISTS wallet_public_qml wallet_internal_qml) + get_filename_component(qml_name "${qml_file}" NAME) + set_source_files_properties("${qml_file}" PROPERTIES QT_RESOURCE_ALIAS "${qml_name}") + endforeach() + foreach(icon IN LISTS wallet_icons) + get_filename_component(icon_name "${icon}" NAME) + set_source_files_properties("${icon}" PROPERTIES QT_RESOURCE_ALIAS "icons/${icon_name}") + endforeach() + set_source_files_properties(${wallet_internal_qml} PROPERTIES QT_QML_INTERNAL_TYPE TRUE) + + qt_add_library(logos_wallet_qml SHARED) + qt_add_qml_module(logos_wallet_qml + URI Logos.Wallet + VERSION 1.0 + RESOURCE_PREFIX /qt/qml + OUTPUT_DIRECTORY "${wallet_qml_output_dir}" + TYPEINFO plugins.qmltypes + QML_FILES + ${wallet_public_qml} + ${wallet_internal_qml} + RESOURCES + ${wallet_icons} + ) + target_link_libraries(logos_wallet_qml PRIVATE + Qt6::Core + Qt6::Qml + Qt6::Quick + Qt6::QuickControls2 + ) + set_target_properties(logos_wallet_qml logos_wallet_qmlplugin PROPERTIES + LIBRARY_OUTPUT_DIRECTORY "${wallet_qml_output_dir}" + RUNTIME_OUTPUT_DIRECTORY "${wallet_qml_output_dir}" + BUILD_WITH_INSTALL_RPATH ON + INSTALL_RPATH "$ORIGIN" + ) + + set(wallet_qml_install_dir "lib/qml/Logos/Wallet") + install(TARGETS logos_wallet_qml logos_wallet_qmlplugin + LIBRARY DESTINATION "${wallet_qml_install_dir}" + RUNTIME DESTINATION "${wallet_qml_install_dir}" + ) + install(FILES + "${wallet_qml_output_dir}/qmldir" + "${wallet_qml_output_dir}/plugins.qmltypes" + DESTINATION "${wallet_qml_install_dir}" + OPTIONAL + ) +endif() + +if(BUILD_TESTING) + find_package(Qt6 6.8 REQUIRED COMPONENTS Test) + + add_executable(logos_wallet_access_test + tests/cpp/LogosWalletProviderTest.cpp + src/WalletProvider.cpp + src/LogosWalletProvider.cpp + src/WalletAccountModel.cpp + src/WalletAccountModel.h + src/WalletController.cpp + src/WalletController.h + ) + set_target_properties(logos_wallet_access_test PROPERTIES AUTOMOC ON) + target_compile_features(logos_wallet_access_test PRIVATE cxx_std_17) + target_include_directories(logos_wallet_access_test PRIVATE + tests/cpp/fixtures + tests/support + src + ) + target_link_libraries(logos_wallet_access_test PRIVATE + Qt6::Core + Qt6::Network + Qt6::Test + ) + add_test(NAME logos_wallet_access COMMAND logos_wallet_access_test) + + if(LOGOS_WALLET_BUILD_QML) + find_package(Qt6 6.8 REQUIRED COMPONENTS QuickTest) + add_executable(logos_wallet_qml_test tests/qml/main.cpp) + target_compile_definitions(logos_wallet_qml_test PRIVATE + QUICK_TEST_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}/tests/qml" + ) + target_link_libraries(logos_wallet_qml_test PRIVATE Qt6::QuickTest) + add_dependencies(logos_wallet_qml_test logos_wallet_qmlplugin) + add_test(NAME logos_wallet_qml COMMAND logos_wallet_qml_test) + set_tests_properties(logos_wallet_qml PROPERTIES ENVIRONMENT + "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software;QML2_IMPORT_PATH=${CMAKE_CURRENT_BINARY_DIR}/qml;QML_IMPORT_PATH=${CMAKE_CURRENT_BINARY_DIR}/qml" + ) + endif() +endif() diff --git a/apps/shared/wallet/qml/SubmittedTransaction.qml b/apps/shared/wallet/qml/SubmittedTransaction.qml new file mode 100644 index 0000000..00cc3ba --- /dev/null +++ b/apps/shared/wallet/qml/SubmittedTransaction.qml @@ -0,0 +1,86 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +Item { + id: root + + property string title: qsTr("Transaction submitted") + property string transactionId: "" + readonly property bool base58Shape: root.transactionId.length > 0 + && /^[1-9A-HJ-NP-Za-km-z]+$/.test(root.transactionId) + + implicitWidth: 420 + implicitHeight: resultCard.implicitHeight + + TextEdit { + id: clipboardProxy + visible: false + } + + function copyTransactionId() { + if (!root.transactionId) + return + clipboardProxy.text = root.transactionId + clipboardProxy.selectAll() + clipboardProxy.copy() + clipboardProxy.deselect() + clipboardProxy.text = "" + } + + Rectangle { + id: resultCard + anchors.left: parent.left + anchors.right: parent.right + implicitHeight: resultContent.implicitHeight + 32 + color: "#18181b" + border.color: "#3f3f46" + border.width: 1 + radius: 8 + + ColumnLayout { + id: resultContent + anchors.fill: parent + anchors.margins: 16 + spacing: 12 + + Label { + Layout.fillWidth: true + text: root.title + color: "#f4f4f5" + font.bold: true + font.pixelSize: 17 + wrapMode: Text.WordWrap + } + + Label { + Layout.fillWidth: true + text: qsTr("Transaction ID") + color: "#a1a1aa" + font.pixelSize: 12 + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Label { + id: transactionLabel + objectName: "submittedTransactionId" + Layout.fillWidth: true + text: root.transactionId + color: "#f4f4f5" + font.family: "monospace" + wrapMode: Text.WrapAnywhere + Accessible.name: qsTr("Transaction ID %1").arg(root.transactionId) + } + + CopyButton { + objectName: "copySubmittedTransactionButton" + enabled: root.transactionId.length > 0 + onCopyRequested: root.copyTransactionId() + } + } + } + } +} diff --git a/apps/shared/wallet/qml/TransactionConfirmationDialog.qml b/apps/shared/wallet/qml/TransactionConfirmationDialog.qml new file mode 100644 index 0000000..141a8db --- /dev/null +++ b/apps/shared/wallet/qml/TransactionConfirmationDialog.qml @@ -0,0 +1,169 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +Popup { + id: root + + property string title: qsTr("Confirm transaction") + property string cancelText: qsTr("Cancel") + property string confirmText: qsTr("Confirm") + property bool busy: false + property var snapshot: ({}) + property Component summary: null + property bool confirmationPending: false + + signal canceled + signal confirmed(var snapshot) + + modal: true + dim: true + padding: 20 + width: Math.max(0, Math.min(420, parent ? parent.width - 32 : 420)) + height: Math.max(0, Math.min(implicitHeight, parent ? parent.height - 32 : implicitHeight)) + x: parent ? Math.max(0, Math.round((parent.width - width) / 2)) : 0 + y: parent ? Math.max(0, Math.round((parent.height - height) / 2)) : 0 + closePolicy: root.busy ? Popup.NoAutoClose : Popup.CloseOnEscape | Popup.CloseOnPressOutside + focus: true + + function cloneSnapshot(value) { + if (value === undefined || value === null) + return ({}) + try { + return JSON.parse(JSON.stringify(value)) + } catch (_error) { + return value + } + } + + function openWithSnapshot(nextSnapshot) { + root.snapshot = root.cloneSnapshot(nextSnapshot) + root.confirmationPending = false + root.open() + cancelButton.forceActiveFocus() + } + + function cancel() { + if (root.busy) + return + root.confirmationPending = false + root.close() + root.canceled() + } + + function confirm() { + if (root.busy) + return + root.confirmationPending = true + root.confirmed(root.snapshot) + if (!root.busy) { + root.confirmationPending = false + root.close() + } + } + + onBusyChanged: { + if (!root.busy && root.confirmationPending) { + root.confirmationPending = false + root.close() + } + } + + onSnapshotChanged: { + if (summaryLoader.item && summaryLoader.item.hasOwnProperty("snapshot")) + summaryLoader.item.snapshot = root.snapshot + } + + Overlay.modal: Rectangle { color: "#99000000" } + + background: Rectangle { + color: "#18181b" + border.color: "#3f3f46" + border.width: 1 + radius: 8 + } + + contentItem: ColumnLayout { + spacing: 16 + + Label { + Layout.fillWidth: true + text: root.title + color: "#f4f4f5" + font.bold: true + font.pixelSize: 17 + wrapMode: Text.WordWrap + } + + ScrollView { + id: summaryScroll + Layout.fillWidth: true + Layout.fillHeight: true + Layout.minimumHeight: 0 + Layout.preferredHeight: summaryLoader.implicitHeight + clip: true + contentWidth: availableWidth + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + + Loader { + id: summaryLoader + objectName: "transactionSummaryLoader" + width: summaryScroll.availableWidth + sourceComponent: root.summary + onLoaded: { + if (item && item.hasOwnProperty("snapshot")) + item.snapshot = root.snapshot + } + } + } + + BusyIndicator { + Layout.alignment: Qt.AlignHCenter + visible: root.busy + running: root.busy + Accessible.name: qsTr("Submitting transaction") + } + + RowLayout { + Layout.fillWidth: true + spacing: 10 + + Button { + id: cancelButton + objectName: "transactionCancelButton" + Layout.fillWidth: true + implicitHeight: 44 + text: root.cancelText + enabled: !root.busy + Accessible.name: text + onClicked: root.cancel() + } + + Button { + id: confirmButton + objectName: "transactionConfirmButton" + Layout.fillWidth: true + implicitHeight: 44 + text: root.busy ? qsTr("Submitting...") : root.confirmText + enabled: !root.busy + Accessible.name: text + onClicked: root.confirm() + + background: Rectangle { + color: confirmButton.enabled + ? confirmButton.pressed ? "#d95c1e" : "#f26a21" + : "#52525b" + radius: 6 + } + + contentItem: Label { + text: confirmButton.text + color: "#ffffff" + font.bold: true + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + } + } + } +} diff --git a/apps/shared/wallet/qml/WalletControl.qml b/apps/shared/wallet/qml/WalletControl.qml new file mode 100644 index 0000000..28d32a1 --- /dev/null +++ b/apps/shared/wallet/qml/WalletControl.qml @@ -0,0 +1,503 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +Item { + id: root + + property var wallet: null + property var accountModel: null + property var watchCall: null + property bool compact: false + property real viewportWidth: width + property int selectedIndex: 0 + property bool busy: false + + readonly property bool connected: root.wallet !== null && root.wallet.isWalletOpen + readonly property bool compactLayout: root.compact || root.viewportWidth < 680 + readonly property string selectedAddress: root.accountAt(root.selectedIndex, "address") + readonly property string selectedName: root.accountAt(root.selectedIndex, "name") + readonly property string selectedBalance: root.accountAt(root.selectedIndex, "balance") + readonly property bool selectedIsPublic: root.accountAt(root.selectedIndex, "isPublic") === true + + implicitWidth: root.connected ? connectedButton.implicitWidth : connectButton.implicitWidth + implicitHeight: 40 + + Instantiator { + id: accounts + model: root.accountModel + delegate: QtObject { + required property string address + required property string name + required property string balance + required property bool isPublic + } + onCountChanged: root.clampSelection() + } + + function accountAt(index, field) { + const entry = index >= 0 && index < accounts.count ? accounts.objectAt(index) : null + return entry ? entry[field] : (field === "isPublic" ? false : "") + } + + function clampSelection() { + if (accounts.count === 0) { + root.selectedIndex = 0 + } else { + root.selectedIndex = Math.max(0, Math.min(root.selectedIndex, accounts.count - 1)) + } + } + + function shortAddress(address) { + return address && address.length > 13 + ? address.substring(0, 6) + "..." + address.substring(address.length - 4) + : address || "" + } + + function watchResult(result, success, failure) { + if (root.watchCall) { + root.watchCall(result, success, failure) + } else { + success(result) + } + } + + function showError(message) { + messageDialog.message = message + messageDialog.open() + } + + function openWallet() { + if (!root.wallet || root.busy) + return + root.busy = true + try { + root.watchResult(root.wallet.openExisting(), function(ok) { + root.busy = false + if (!ok) + root.showError(qsTr("Wallet could not be opened.")) + }, function(error) { + root.busy = false + root.showError(qsTr("Wallet could not be opened: %1").arg(error)) + }) + } catch (error) { + root.busy = false + root.showError(qsTr("Wallet could not be opened: %1").arg(error)) + } + } + + TextEdit { + id: clipboardProxy + visible: false + } + + function copyToClipboard(text) { + if (!text) + return + clipboardProxy.text = text + clipboardProxy.selectAll() + clipboardProxy.copy() + clipboardProxy.deselect() + clipboardProxy.text = "" + } + + Connections { + target: root.accountModel + ignoreUnknownSignals: true + function onModelReset() { root.clampSelection() } + function onRowsInserted() { root.clampSelection() } + function onRowsRemoved() { root.clampSelection() } + } + + onConnectedChanged: { + if (!root.connected) { + root.selectedIndex = 0 + walletMenu.close() + } + } + + onViewportWidthChanged: { + if (walletMenu.opened) + Qt.callLater(walletMenu.updateAnchor) + } + + Button { + id: connectButton + objectName: "walletConnectButton" + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + visible: !root.connected + enabled: root.wallet !== null && !root.busy + implicitHeight: 40 + implicitWidth: root.compactLayout ? 40 : 108 + text: root.compactLayout ? "" : root.busy ? qsTr("Connecting...") : qsTr("Connect") + display: root.compactLayout ? AbstractButton.IconOnly : AbstractButton.TextBesideIcon + icon.source: Qt.resolvedUrl("icons/account.svg") + icon.color: "#ffffff" + icon.width: 18 + icon.height: 18 + Accessible.name: qsTr("Connect wallet") + ToolTip.text: Accessible.name + ToolTip.visible: hovered && root.compactLayout + + background: Rectangle { + color: connectButton.pressed ? "#d95c1e" : "#f26a21" + radius: 6 + } + + contentItem: RowLayout { + spacing: 6 + Image { + visible: root.compactLayout + source: connectButton.icon.source + sourceSize.width: 18 + sourceSize.height: 18 + } + Label { + Layout.fillWidth: true + visible: !root.compactLayout + text: connectButton.text + color: "#ffffff" + font.bold: true + horizontalAlignment: Text.AlignHCenter + } + } + + onClicked: { + if (root.wallet && root.wallet.walletExists) + root.openWallet() + else + createWalletDialog.open() + } + } + + Button { + id: connectedButton + objectName: "walletAccountButton" + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + visible: root.connected + enabled: !root.busy + implicitHeight: 40 + implicitWidth: root.compactLayout ? 44 : Math.max(140, accountButtonLabel.implicitWidth + 58) + Accessible.name: qsTr("Wallet account %1").arg(root.selectedAddress) + + background: Rectangle { + color: connectedButton.pressed ? "#3f3f46" : "#27272a" + border.width: walletMenu.opened || connectedButton.activeFocus ? 1 : 0 + border.color: "#f26a21" + radius: 6 + } + + contentItem: RowLayout { + spacing: 8 + + Rectangle { + Layout.preferredWidth: 8 + Layout.preferredHeight: 8 + radius: 4 + color: "#22c55e" + } + + Label { + id: accountButtonLabel + Layout.fillWidth: true + visible: !root.compactLayout + text: root.shortAddress(root.selectedAddress) || qsTr("Connected") + color: "#f4f4f5" + horizontalAlignment: Text.AlignHCenter + } + + Label { + visible: !root.compactLayout + text: walletMenu.opened ? "\u25b4" : "\u25be" + color: "#a1a1aa" + } + } + + onClicked: { + if (walletMenu.opened || Date.now() - walletMenu.lastClosedMs < 200) + walletMenu.close() + else + walletMenu.open() + } + } + + Popup { + id: walletMenu + objectName: "walletMenu" + property real lastClosedMs: 0 + property point anchorPosition: Qt.point(0, 0) + readonly property var viewport: Overlay.overlay + readonly property real spaceAbove: Math.max(0, anchorPosition.y - 20) + readonly property real spaceBelow: viewport + ? Math.max(0, viewport.height - anchorPosition.y - connectedButton.height - 20) + : implicitHeight + readonly property bool opensAbove: spaceBelow < implicitHeight && spaceAbove > spaceBelow + readonly property real availableMenuHeight: opensAbove ? spaceAbove : spaceBelow + parent: connectedButton + x: viewport + ? Math.max(12 - anchorPosition.x, + Math.min(connectedButton.width - width, + viewport.width - width - 12 - anchorPosition.x)) + : connectedButton.width - width + y: opensAbove + ? -height - 8 + : connectedButton.height + 8 + width: Math.min(360, Math.max(0, Math.min(root.viewportWidth, + viewport ? viewport.width : root.viewportWidth) - 24)) + height: Math.min(implicitHeight, availableMenuHeight) + margins: 12 + padding: 12 + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + + function updateAnchor() { + if (viewport) + anchorPosition = connectedButton.mapToItem(viewport, 0, 0) + } + + onAboutToShow: updateAnchor() + + onClosed: { + walletMenu.lastClosedMs = Date.now() + if (walletStack.depth > 1) + walletStack.pop(null, StackView.Immediate) + } + + Connections { + target: walletMenu.opened ? walletMenu.viewport : null + + function onWidthChanged() { Qt.callLater(walletMenu.updateAnchor) } + function onHeightChanged() { Qt.callLater(walletMenu.updateAnchor) } + } + + background: Rectangle { + color: "#18181b" + border.color: "#3f3f46" + border.width: 1 + radius: 8 + } + + contentItem: StackView { + id: walletStack + width: walletMenu.availableWidth + height: walletMenu.availableHeight + implicitWidth: walletMenu.availableWidth + implicitHeight: currentItem ? currentItem.implicitHeight : 0 + initialItem: walletOverview + } + + Component { + id: walletOverview + + ColumnLayout { + spacing: 12 + + RowLayout { + Layout.fillWidth: true + + Item { Layout.fillWidth: true } + + WalletIconButton { + objectName: "walletAccountsButton" + iconSource: Qt.resolvedUrl("icons/account.svg") + accessibleName: qsTr("Accounts") + onClicked: walletStack.push(accountList) + } + + WalletIconButton { + objectName: "walletDisconnectButton" + iconSource: Qt.resolvedUrl("icons/power.svg") + accessibleName: qsTr("Disconnect") + onClicked: { + walletMenu.close() + if (root.wallet) + root.wallet.disconnectWallet() + } + } + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: accountCard.implicitHeight + 24 + color: "#27272a" + radius: 6 + + ColumnLayout { + id: accountCard + anchors.fill: parent + anchors.margins: 12 + spacing: 8 + + RowLayout { + Layout.fillWidth: true + + Label { + text: root.selectedName || qsTr("Account") + color: "#f4f4f5" + font.bold: true + } + + Label { + text: root.selectedIsPublic ? qsTr("Public") : qsTr("Private") + color: "#a1a1aa" + font.pixelSize: 11 + } + + Item { Layout.fillWidth: true } + + Label { + text: root.selectedBalance || "-" + color: "#f4f4f5" + font.bold: true + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 4 + + Label { + Layout.fillWidth: true + text: root.selectedAddress + color: "#a1a1aa" + font.family: "monospace" + font.pixelSize: 11 + elide: Text.ElideMiddle + } + + CopyButton { + visible: root.selectedAddress.length > 0 + onCopyRequested: root.copyToClipboard(root.selectedAddress) + } + } + } + } + } + } + + Component { + id: accountList + + ColumnLayout { + spacing: 12 + + RowLayout { + Layout.fillWidth: true + + WalletIconButton { + iconSource: Qt.resolvedUrl("icons/back.svg") + accessibleName: qsTr("Back") + onClicked: walletStack.pop() + } + + Label { + Layout.fillWidth: true + text: qsTr("Accounts") + color: "#f4f4f5" + font.bold: true + } + } + + ListView { + id: accountListView + objectName: "walletAccountList" + Layout.fillWidth: true + Layout.fillHeight: true + Layout.minimumHeight: 0 + Layout.preferredHeight: Math.min(contentHeight, 260) + clip: true + spacing: 6 + model: root.accountModel + ScrollIndicator.vertical: ScrollIndicator { } + + delegate: AccountDelegate { + width: ListView.view.width + highlighted: index === root.selectedIndex + onClicked: { + root.selectedIndex = index + walletStack.pop() + } + onCopyRequested: function(text) { root.copyToClipboard(text) } + } + } + + Button { + objectName: "walletAddAccountButton" + Layout.fillWidth: true + text: qsTr("Add account") + enabled: !root.busy + onClicked: createAccountDialog.open() + } + } + } + } + + CreateWalletDialog { + id: createWalletDialog + objectName: "createWalletDialog" + walletHome: root.wallet ? root.wallet.walletHome || "" : "" + busy: root.busy + + onCreateRequested: function(password) { + if (!root.wallet || root.busy) + return + root.busy = true + try { + root.watchResult(root.wallet.createNewDefault(password), function(mnemonic) { + root.busy = false + if (mnemonic && mnemonic.length > 0) + createWalletDialog.mnemonic = mnemonic + else + createWalletDialog.errorText = qsTr("Wallet could not be created.") + }, function(error) { + root.busy = false + createWalletDialog.errorText = qsTr("Wallet could not be created: %1").arg(error) + }) + } catch (error) { + root.busy = false + createWalletDialog.errorText = qsTr("Wallet could not be created: %1").arg(error) + } + } + + onCopyRequested: function(text) { root.copyToClipboard(text) } + } + + CreateAccountDialog { + id: createAccountDialog + objectName: "createAccountDialog" + busy: root.busy + + onCreateRequested: function(isPublic) { + if (!root.wallet || root.busy) + return + root.busy = true + try { + const request = isPublic + ? root.wallet.createAccountPublic() + : root.wallet.createAccountPrivate() + root.watchResult(request, function(accountId) { + root.busy = false + if (accountId && accountId.length > 0) { + createAccountDialog.close() + } else { + root.showError(qsTr("Account could not be created.")) + } + }, function(error) { + root.busy = false + root.showError(qsTr("Account could not be created: %1").arg(error)) + }) + } catch (error) { + root.busy = false + root.showError(qsTr("Account could not be created: %1").arg(error)) + } + } + } + + WalletMessageDialog { + id: messageDialog + objectName: "walletMessageDialog" + } +} diff --git a/apps/shared/wallet/qml/internal/AccountDelegate.qml b/apps/shared/wallet/qml/internal/AccountDelegate.qml new file mode 100644 index 0000000..b0ff529 --- /dev/null +++ b/apps/shared/wallet/qml/internal/AccountDelegate.qml @@ -0,0 +1,77 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +ItemDelegate { + id: root + + required property int index + required property string name + required property string address + required property string balance + required property bool isPublic + + signal copyRequested(string text) + + leftPadding: 12 + rightPadding: 8 + topPadding: 10 + bottomPadding: 10 + + Accessible.name: qsTr("%1, balance %2").arg(root.name).arg(root.balance || "0") + + background: Rectangle { + color: root.highlighted || root.hovered ? "#27272a" : "#18181b" + radius: 6 + border.width: root.activeFocus ? 1 : 0 + border.color: "#f26a21" + } + + contentItem: ColumnLayout { + spacing: 6 + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Label { + text: root.name + color: "#f4f4f5" + font.bold: true + } + + Label { + text: root.isPublic ? qsTr("Public") : qsTr("Private") + color: "#a1a1aa" + font.pixelSize: 11 + } + + Item { Layout.fillWidth: true } + + Label { + text: root.balance.length > 0 ? root.balance : "-" + color: "#f4f4f5" + font.bold: true + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 4 + + Label { + Layout.fillWidth: true + text: root.address + color: "#a1a1aa" + font.family: "monospace" + font.pixelSize: 11 + elide: Text.ElideMiddle + } + + CopyButton { + visible: root.address.length > 0 + onCopyRequested: root.copyRequested(root.address) + } + } + } +} diff --git a/apps/shared/wallet/qml/internal/CopyButton.qml b/apps/shared/wallet/qml/internal/CopyButton.qml new file mode 100644 index 0000000..1964da3 --- /dev/null +++ b/apps/shared/wallet/qml/internal/CopyButton.qml @@ -0,0 +1,26 @@ +import QtQuick + +WalletIconButton { + id: root + + signal copyRequested + + property bool copied: false + + accessibleName: root.copied ? qsTr("Copied") : qsTr("Copy") + iconSource: root.copied + ? Qt.resolvedUrl("icons/checkmark.svg") + : Qt.resolvedUrl("icons/copy.svg") + + Timer { + id: resetTimer + interval: 1500 + onTriggered: root.copied = false + } + + onClicked: { + root.copyRequested() + root.copied = true + resetTimer.restart() + } +} diff --git a/apps/shared/wallet/qml/internal/CreateAccountDialog.qml b/apps/shared/wallet/qml/internal/CreateAccountDialog.qml new file mode 100644 index 0000000..51d5f31 --- /dev/null +++ b/apps/shared/wallet/qml/internal/CreateAccountDialog.qml @@ -0,0 +1,94 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +Popup { + id: root + + property bool busy: false + + signal createRequested(bool isPublic) + + modal: true + dim: true + parent: Overlay.overlay + width: Math.max(0, Math.min(380, parent ? parent.width - 32 : 380)) + x: parent ? Math.max(0, Math.round((parent.width - width) / 2)) : 0 + y: parent ? Math.max(0, Math.round((parent.height - height) / 2)) : 0 + padding: 20 + closePolicy: root.busy ? Popup.NoAutoClose : Popup.CloseOnEscape | Popup.CloseOnPressOutside + + onOpened: privateSwitch.checked = false + + background: Rectangle { + color: "#18181b" + border.color: "#3f3f46" + border.width: 1 + radius: 8 + } + + contentItem: ColumnLayout { + spacing: 16 + + Label { + Layout.fillWidth: true + text: qsTr("Create account") + color: "#f4f4f5" + font.bold: true + font.pixelSize: 17 + } + + RowLayout { + Layout.fillWidth: true + spacing: 12 + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + Label { + text: privateSwitch.checked ? qsTr("Private") : qsTr("Public") + color: "#f4f4f5" + font.bold: true + } + + Label { + Layout.fillWidth: true + text: privateSwitch.checked + ? qsTr("Private balance and activity") + : qsTr("Public balance and activity") + color: "#a1a1aa" + wrapMode: Text.WordWrap + } + } + + Switch { + id: privateSwitch + objectName: "privateAccountSwitch" + Accessible.name: qsTr("Create private account") + enabled: !root.busy + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 10 + + Button { + Layout.fillWidth: true + text: qsTr("Cancel") + enabled: !root.busy + onClicked: root.close() + } + + Button { + id: createButton + objectName: "createAccountButton" + Layout.fillWidth: true + text: root.busy ? qsTr("Creating...") : qsTr("Create") + enabled: !root.busy + onClicked: root.createRequested(!privateSwitch.checked) + } + } + } +} diff --git a/apps/shared/wallet/qml/internal/CreateWalletDialog.qml b/apps/shared/wallet/qml/internal/CreateWalletDialog.qml new file mode 100644 index 0000000..ac422c1 --- /dev/null +++ b/apps/shared/wallet/qml/internal/CreateWalletDialog.qml @@ -0,0 +1,190 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +Popup { + id: root + + property string walletHome: "" + property string mnemonic: "" + property string errorText: "" + property bool busy: false + + signal createRequested(string password) + signal copyRequested(string text) + + modal: true + dim: true + parent: Overlay.overlay + width: Math.max(0, Math.min(420, parent ? parent.width - 32 : 420)) + x: parent ? Math.max(0, Math.round((parent.width - width) / 2)) : 0 + y: parent ? Math.max(0, Math.round((parent.height - height) / 2)) : 0 + padding: 20 + closePolicy: root.busy || root.mnemonic.length > 0 + ? Popup.NoAutoClose + : Popup.CloseOnEscape | Popup.CloseOnPressOutside + + onOpened: { + passwordField.text = "" + confirmField.text = "" + acknowledgement.checked = false + root.errorText = "" + root.mnemonic = "" + passwordField.forceActiveFocus() + } + + background: Rectangle { + color: "#18181b" + border.color: "#3f3f46" + border.width: 1 + radius: 8 + } + + contentItem: ColumnLayout { + spacing: 16 + + ColumnLayout { + Layout.fillWidth: true + spacing: 14 + visible: root.mnemonic.length === 0 + + Label { + Layout.fillWidth: true + text: qsTr("Create wallet") + color: "#f4f4f5" + font.bold: true + font.pixelSize: 17 + } + + Label { + Layout.fillWidth: true + text: root.walletHome.length > 0 + ? qsTr("Wallet will be stored at %1").arg(root.walletHome) + : qsTr("Wallet will use default storage") + color: "#a1a1aa" + wrapMode: Text.WordWrap + } + + TextField { + id: passwordField + objectName: "walletPasswordField" + Layout.fillWidth: true + placeholderText: qsTr("Password") + echoMode: TextInput.Password + enabled: !root.busy + Keys.onReturnPressed: createButton.tryCreate() + } + + TextField { + id: confirmField + objectName: "walletConfirmPasswordField" + Layout.fillWidth: true + placeholderText: qsTr("Confirm password") + echoMode: TextInput.Password + enabled: !root.busy + Keys.onReturnPressed: createButton.tryCreate() + } + + Label { + Layout.fillWidth: true + visible: root.errorText.length > 0 + text: root.errorText + color: "#f87171" + wrapMode: Text.WordWrap + } + + RowLayout { + Layout.fillWidth: true + spacing: 10 + + Button { + Layout.fillWidth: true + text: qsTr("Cancel") + enabled: !root.busy + onClicked: root.close() + } + + Button { + id: createButton + objectName: "createWalletButton" + Layout.fillWidth: true + text: root.busy ? qsTr("Creating...") : qsTr("Create wallet") + enabled: !root.busy + + function tryCreate() { + if (passwordField.text.length === 0) { + root.errorText = qsTr("Password cannot be empty.") + } else if (passwordField.text !== confirmField.text) { + root.errorText = qsTr("Passwords do not match.") + } else { + root.errorText = "" + root.createRequested(passwordField.text) + } + } + + onClicked: tryCreate() + } + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 14 + visible: root.mnemonic.length > 0 + + Label { + Layout.fillWidth: true + text: qsTr("Back up recovery phrase") + color: "#f4f4f5" + font.bold: true + font.pixelSize: 17 + } + + Label { + Layout.fillWidth: true + text: qsTr("Write these words down in order. Anyone with this phrase can control this wallet.") + color: "#d4d4d8" + wrapMode: Text.WordWrap + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: phraseLabel.implicitHeight + 24 + color: "#27272a" + radius: 6 + + Label { + id: phraseLabel + objectName: "walletMnemonic" + anchors.fill: parent + anchors.margins: 12 + text: root.mnemonic + color: "#f4f4f5" + font.bold: true + wrapMode: Text.WordWrap + } + } + + Button { + Layout.fillWidth: true + text: qsTr("Copy recovery phrase") + onClicked: root.copyRequested(root.mnemonic) + } + + CheckBox { + id: acknowledgement + objectName: "walletBackupAcknowledgement" + Layout.fillWidth: true + text: qsTr("I have safely backed up my recovery phrase") + } + + Button { + objectName: "walletContinueButton" + Layout.fillWidth: true + text: qsTr("Continue") + enabled: acknowledgement.checked + onClicked: root.close() + } + } + } +} diff --git a/apps/shared/wallet/qml/internal/WalletIconButton.qml b/apps/shared/wallet/qml/internal/WalletIconButton.qml new file mode 100644 index 0000000..159d5e1 --- /dev/null +++ b/apps/shared/wallet/qml/internal/WalletIconButton.qml @@ -0,0 +1,31 @@ +import QtQuick +import QtQuick.Controls.Basic + +Button { + id: root + + property url iconSource + property string accessibleName: "" + property color iconColor: "#d4d4d8" + + implicitWidth: 36 + implicitHeight: 36 + display: AbstractButton.IconOnly + hoverEnabled: true + + Accessible.name: root.accessibleName + ToolTip.text: root.accessibleName + ToolTip.visible: root.hovered && root.accessibleName.length > 0 + + icon.source: root.iconSource + icon.width: 18 + icon.height: 18 + icon.color: root.iconColor + + background: Rectangle { + color: root.pressed ? "#3f3f46" : root.hovered || root.activeFocus ? "#27272a" : "transparent" + radius: 6 + border.width: root.activeFocus ? 1 : 0 + border.color: "#f26a21" + } +} diff --git a/apps/shared/wallet/qml/internal/WalletMessageDialog.qml b/apps/shared/wallet/qml/internal/WalletMessageDialog.qml new file mode 100644 index 0000000..7f7a346 --- /dev/null +++ b/apps/shared/wallet/qml/internal/WalletMessageDialog.qml @@ -0,0 +1,52 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +Popup { + id: root + + property string title: qsTr("Wallet error") + property string message: "" + + modal: true + dim: true + parent: Overlay.overlay + width: Math.max(0, Math.min(380, parent ? parent.width - 32 : 380)) + x: parent ? Math.max(0, Math.round((parent.width - width) / 2)) : 0 + y: parent ? Math.max(0, Math.round((parent.height - height) / 2)) : 0 + padding: 20 + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + + background: Rectangle { + color: "#18181b" + border.color: "#3f3f46" + border.width: 1 + radius: 8 + } + + contentItem: ColumnLayout { + spacing: 16 + + Label { + Layout.fillWidth: true + text: root.title + color: "#f4f4f5" + font.bold: true + font.pixelSize: 17 + wrapMode: Text.WordWrap + } + + Label { + Layout.fillWidth: true + text: root.message + color: "#d4d4d8" + wrapMode: Text.WordWrap + } + + Button { + Layout.alignment: Qt.AlignRight + text: qsTr("Close") + onClicked: root.close() + } + } +} diff --git a/apps/amm/qml/components/wallet/icons/account.svg b/apps/shared/wallet/qml/internal/icons/account.svg similarity index 100% rename from apps/amm/qml/components/wallet/icons/account.svg rename to apps/shared/wallet/qml/internal/icons/account.svg diff --git a/apps/amm/qml/components/wallet/icons/back.svg b/apps/shared/wallet/qml/internal/icons/back.svg similarity index 100% rename from apps/amm/qml/components/wallet/icons/back.svg rename to apps/shared/wallet/qml/internal/icons/back.svg diff --git a/apps/amm/qml/components/wallet/icons/checkmark.svg b/apps/shared/wallet/qml/internal/icons/checkmark.svg similarity index 100% rename from apps/amm/qml/components/wallet/icons/checkmark.svg rename to apps/shared/wallet/qml/internal/icons/checkmark.svg diff --git a/apps/amm/qml/components/wallet/icons/copy.svg b/apps/shared/wallet/qml/internal/icons/copy.svg similarity index 100% rename from apps/amm/qml/components/wallet/icons/copy.svg rename to apps/shared/wallet/qml/internal/icons/copy.svg diff --git a/apps/amm/qml/components/wallet/icons/power.svg b/apps/shared/wallet/qml/internal/icons/power.svg similarity index 100% rename from apps/amm/qml/components/wallet/icons/power.svg rename to apps/shared/wallet/qml/internal/icons/power.svg diff --git a/apps/shared/wallet/src/LogosWalletProvider.cpp b/apps/shared/wallet/src/LogosWalletProvider.cpp new file mode 100644 index 0000000..3e56952 --- /dev/null +++ b/apps/shared/wallet/src/LogosWalletProvider.cpp @@ -0,0 +1,382 @@ +#include "LogosWalletProvider.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "logos_sdk.h" + +namespace { +constexpr int WALLET_FFI_SUCCESS = 0; + +bool isHex(const QString& value, qsizetype size, bool lowercaseOnly = true) +{ + if (value.size() != size) + return false; + + for (const QChar character : value) { + const bool digit = character >= QLatin1Char('0') + && character <= QLatin1Char('9'); + const bool lowercase = character >= QLatin1Char('a') + && character <= QLatin1Char('f'); + const bool uppercase = character >= QLatin1Char('A') + && character <= QLatin1Char('F'); + if (!digit && !lowercase && (!uppercase || lowercaseOnly)) + return false; + } + return true; +} + +QString littleEndianU128ToDecimal(const QString& value) +{ + if (!isHex(value, 32)) + return {}; + + QString decimal = QStringLiteral("0"); + for (int byteIndex = 15; byteIndex >= 0; --byteIndex) { + bool parsed = false; + int carry = value.mid(byteIndex * 2, 2).toInt(&parsed, 16); + if (!parsed) + return {}; + + for (qsizetype digit = decimal.size(); digit-- > 0;) { + const int next = (decimal.at(digit).unicode() - QLatin1Char('0').unicode()) + * 256 + carry; + decimal[digit] = QLatin1Char(static_cast('0' + next % 10)); + carry = next / 10; + } + while (carry > 0) { + decimal.prepend(QLatin1Char(static_cast('0' + carry % 10))); + carry /= 10; + } + } + + return decimal; +} + +WalletSession failedSession(WalletFailure failure) +{ + WalletSession session; + session.failure = failure; + session.snapshot.failure = failure; + return session; +} + +WalletCreation failedCreation(WalletFailure failure) +{ + WalletCreation creation; + creation.failure = failure; + creation.snapshot.failure = failure; + return creation; +} +} + +struct LogosWalletProvider::Impl { + explicit Impl(LogosAPI* api) + : ownedLogos(std::make_unique(api)), logos(ownedLogos.get()) + { + } + + explicit Impl(LogosModules* value) + : logos(value) + { + } + + std::unique_ptr ownedLogos; + LogosModules* logos = nullptr; +}; + +LogosWalletProvider::LogosWalletProvider(LogosAPI* api) + : m_impl(std::make_unique(api)) +{ +} + +LogosWalletProvider::LogosWalletProvider(LogosModules* logos) + : m_impl(std::make_unique(logos)) +{ +} + +LogosWalletProvider::~LogosWalletProvider() +{ + if (m_connected) + save(); +} + +WalletSession LogosWalletProvider::connect(const WalletPaths& paths) +{ + clearSnapshot(); + if (!m_impl->logos) + return failedSession(WalletFailure::WalletUnavailable); + + WalletSession session; + if (sharedWalletIsOpen()) { + session.adopted = true; + } else { + if (!QFileInfo::exists(paths.storage)) + return failedSession(WalletFailure::WalletMissing); + if (m_impl->logos->logos_execution_zone.open(paths.config, paths.storage) + != WALLET_FFI_SUCCESS) { + return failedSession(WalletFailure::OpenFailed); + } + } + + m_connected = true; + session.snapshot = snapshot(true); + session.failure = session.snapshot.failure; + return session; +} + +WalletCreation LogosWalletProvider::createWallet(const WalletPaths& paths, + const QString& password) +{ + clearSnapshot(); + if (!m_impl->logos) + return failedCreation(WalletFailure::WalletUnavailable); + + const QFileInfo configInfo(paths.config); + const QFileInfo storageInfo(paths.storage); + if (!QDir().mkpath(configInfo.absolutePath()) + || !QDir().mkpath(storageInfo.absolutePath())) { + return failedCreation(WalletFailure::CreateFailed); + } + + WalletCreation creation; + creation.mnemonic = m_impl->logos->logos_execution_zone.create_new( + paths.config, paths.storage, password); + if (creation.mnemonic.isEmpty()) + return failedCreation(WalletFailure::CreateFailed); + + m_connected = true; + if (!save()) { + creation.failure = WalletFailure::SaveFailed; + creation.snapshot.failure = creation.failure; + return creation; + } + + creation.snapshot = snapshot(true); + creation.failure = creation.snapshot.failure; + return creation; +} + +WalletSnapshot LogosWalletProvider::snapshot(bool forceRefresh) +{ + if (m_snapshotReady && !forceRefresh) + return m_snapshot; + if (!m_connected) { + WalletSnapshot result; + result.failure = WalletFailure::WalletUnavailable; + return result; + } + + WalletSnapshot result = loadSnapshot(); + if (result.ok()) { + m_snapshot = result; + m_snapshotReady = true; + } + return result; +} + +void LogosWalletProvider::clearSnapshot() +{ + m_snapshot = {}; + m_snapshotReady = false; +} + +WalletAccountCreation LogosWalletProvider::createAccount(bool isPublic) +{ + WalletAccountCreation creation; + if (!m_connected || !m_impl->logos) { + creation.failure = WalletFailure::WalletUnavailable; + return creation; + } + + creation.accountId = isPublic + ? m_impl->logos->logos_execution_zone.create_account_public() + : m_impl->logos->logos_execution_zone.create_account_private(); + if (!isHex(creation.accountId, 64)) { + creation.failure = WalletFailure::CreateFailed; + return creation; + } + if (!save()) { + creation.failure = WalletFailure::SaveFailed; + return creation; + } + + if (isPublic) + creation.publicAccount = readPublicAccount(creation.accountId); + + clearSnapshot(); + creation.snapshot = snapshot(true); + return creation; +} + +WalletAccountRead LogosWalletProvider::readPublicAccount(const QString& accountId) const +{ + WalletAccountRead read; + read.accountId = accountId; + if (!m_impl->logos || !isHex(accountId, 64)) + return read; + + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson( + m_impl->logos->logos_execution_zone.get_account_public(accountId).toUtf8(), + &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) + return read; + + const QJsonObject account = document.object(); + const QString owner = account.value(QStringLiteral("program_owner")).toString(); + const QString balance = account.value(QStringLiteral("balance")).toString(); + const QString nonce = account.value(QStringLiteral("nonce")).toString(); + const QString data = account.value(QStringLiteral("data")).toString(); + if (!isHex(owner, 64) + || !isHex(balance, 32) + || !isHex(nonce, 32) + || data.size() % 2 != 0 + || !isHex(data, data.size())) { + return read; + } + + read.status = QStringLiteral("ok"); + read.programOwner = owner; + read.balanceHex = balance; + read.nonceHex = nonce; + read.dataHex = data; + return read; +} + +WalletSubmission LogosWalletProvider::submitPublicTransaction( + const WalletTransaction& transaction) +{ + WalletSubmission submission; + if (!m_connected || !m_impl->logos) { + submission.failure = WalletFailure::WalletUnavailable; + return submission; + } + if (!isHex(transaction.programId, 64) + || transaction.accountIds.size() != transaction.signingRequirements.size()) { + submission.failure = WalletFailure::InvalidRequest; + return submission; + } + for (const QString& accountId : transaction.accountIds) { + if (!isHex(accountId, 64)) { + submission.failure = WalletFailure::InvalidRequest; + return submission; + } + } + + QVariantList signingRequirements; + signingRequirements.reserve(transaction.signingRequirements.size()); + for (bool required : transaction.signingRequirements) + signingRequirements.append(required); + + QVariantList instruction; + instruction.reserve(transaction.instruction.size()); + for (quint32 word : transaction.instruction) + instruction.append(word); + + const QString response = + m_impl->logos->logos_execution_zone.send_generic_public_transaction( + transaction.accountIds, + signingRequirements, + QVariant::fromValue(instruction), + transaction.programId); + + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(response.toUtf8(), &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) { + submission.failure = WalletFailure::SubmissionFailed; + return submission; + } + + const QJsonObject result = document.object(); + const QJsonValue success = result.value(QStringLiteral("success")); + const QJsonValue error = result.value(QStringLiteral("error")); + const QString hash = result.value(QStringLiteral("tx_hash")).toString(); + const bool emptyError = error.isUndefined() + || error.isNull() + || (error.isString() && error.toString().isEmpty()); + if (!success.isBool() + || !success.toBool() + || !emptyError + || !isHex(hash, 64, false)) { + submission.failure = WalletFailure::SubmissionFailed; + return submission; + } + + submission.nativeHash = hash.toLower(); + return submission; +} + +void LogosWalletProvider::disconnect() +{ + if (m_connected) + save(); + clearSnapshot(); + m_connected = false; +} + +bool LogosWalletProvider::sharedWalletIsOpen() const +{ + if (!m_impl->logos) + return false; + if (!m_impl->logos->logos_execution_zone.get_sequencer_addr().isEmpty()) + return true; + return !m_impl->logos->logos_execution_zone.list_accounts().isEmpty(); +} + +WalletSnapshot LogosWalletProvider::loadSnapshot() +{ + WalletSnapshot result; + result.currentBlockHeight = static_cast( + qMax(0, m_impl->logos->logos_execution_zone.get_current_block_height())); + if (result.currentBlockHeight > 0 + && m_impl->logos->logos_execution_zone.sync_to_block(result.currentBlockHeight) + != WALLET_FFI_SUCCESS) { + result.failure = WalletFailure::ReadFailed; + return result; + } + result.lastSyncedBlock = static_cast( + qMax(0, m_impl->logos->logos_execution_zone.get_last_synced_block())); + result.sequencerAddress = m_impl->logos->logos_execution_zone.get_sequencer_addr(); + + const QVariantList entries = m_impl->logos->logos_execution_zone.list_accounts(); + result.accounts.reserve(entries.size()); + result.publicAccountReads.reserve(entries.size()); + for (const QVariant& value : entries) { + const QVariantMap entry = value.toMap(); + const QString address = entry.value(QStringLiteral("account_id")).toString(); + if (entry.isEmpty() || !isHex(address, 64)) { + result.failure = WalletFailure::ReadFailed; + return result; + } + + WalletAccount account; + account.address = address; + account.isPublic = entry.value(QStringLiteral("is_public"), true).toBool(); + if (account.isPublic) { + const WalletAccountRead read = readPublicAccount(address); + result.publicAccountReads.append(read); + account.balance = read.ok() + ? littleEndianU128ToDecimal(read.balanceHex) + : m_impl->logos->logos_execution_zone.get_balance(address, true); + } else { + account.balance = m_impl->logos->logos_execution_zone.get_balance(address, false); + } + result.accounts.append(account); + } + + if (!save()) + result.failure = WalletFailure::SaveFailed; + return result; +} + +bool LogosWalletProvider::save() const +{ + return m_impl->logos + && m_impl->logos->logos_execution_zone.save() == WALLET_FFI_SUCCESS; +} diff --git a/apps/shared/wallet/src/LogosWalletProvider.h b/apps/shared/wallet/src/LogosWalletProvider.h new file mode 100644 index 0000000..d046290 --- /dev/null +++ b/apps/shared/wallet/src/LogosWalletProvider.h @@ -0,0 +1,37 @@ +#pragma once + +#include + +#include "WalletProvider.h" + +class LogosAPI; +struct LogosModules; + +class LogosWalletProvider final : public WalletProvider { +public: + explicit LogosWalletProvider(LogosAPI* api); + explicit LogosWalletProvider(LogosModules* logos); + ~LogosWalletProvider() override; + + WalletSession connect(const WalletPaths& paths) override; + WalletCreation createWallet(const WalletPaths& paths, + const QString& password) override; + WalletSnapshot snapshot(bool forceRefresh = false) override; + void clearSnapshot() override; + WalletAccountCreation createAccount(bool isPublic) override; + WalletAccountRead readPublicAccount(const QString& accountId) const override; + WalletSubmission submitPublicTransaction( + const WalletTransaction& transaction) override; + void disconnect() override; + +private: + bool sharedWalletIsOpen() const; + WalletSnapshot loadSnapshot(); + bool save() const; + + struct Impl; + std::unique_ptr m_impl; + WalletSnapshot m_snapshot; + bool m_snapshotReady = false; + bool m_connected = false; +}; diff --git a/apps/shared/wallet/src/WalletAccountModel.cpp b/apps/shared/wallet/src/WalletAccountModel.cpp new file mode 100644 index 0000000..06e94bf --- /dev/null +++ b/apps/shared/wallet/src/WalletAccountModel.cpp @@ -0,0 +1,61 @@ +#include "WalletAccountModel.h" + +WalletAccountModel::WalletAccountModel(QObject* parent) + : QAbstractListModel(parent) +{ +} + +int WalletAccountModel::rowCount(const QModelIndex& parent) const +{ + return parent.isValid() ? 0 : m_accounts.size(); +} + +QVariant WalletAccountModel::data(const QModelIndex& index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= m_accounts.size()) + return {}; + + const Entry& account = m_accounts.at(index.row()); + switch (role) { + case NameRole: + return account.name; + case AddressRole: + return account.address; + case BalanceRole: + return account.balance; + case IsPublicRole: + return account.isPublic; + default: + return {}; + } +} + +QHash WalletAccountModel::roleNames() const +{ + return { + { NameRole, "name" }, + { AddressRole, "address" }, + { BalanceRole, "balance" }, + { IsPublicRole, "isPublic" }, + }; +} + +void WalletAccountModel::replaceAccounts(const QVector& accounts) +{ + beginResetModel(); + const qsizetype oldCount = m_accounts.size(); + m_accounts.clear(); + m_accounts.reserve(accounts.size()); + for (qsizetype index = 0; index < accounts.size(); ++index) { + const WalletAccount& account = accounts.at(index); + m_accounts.append({ + QStringLiteral("Account %1").arg(index + 1), + account.address, + account.balance, + account.isPublic, + }); + } + endResetModel(); + if (oldCount != m_accounts.size()) + emit countChanged(); +} diff --git a/apps/shared/wallet/src/WalletAccountModel.h b/apps/shared/wallet/src/WalletAccountModel.h new file mode 100644 index 0000000..c00b0d6 --- /dev/null +++ b/apps/shared/wallet/src/WalletAccountModel.h @@ -0,0 +1,42 @@ +#pragma once + +#include +#include + +#include "WalletProvider.h" + +class WalletAccountModel final : public QAbstractListModel { + Q_OBJECT + Q_PROPERTY(int count READ count NOTIFY countChanged) + +public: + enum Role { + NameRole = Qt::UserRole + 1, + AddressRole, + BalanceRole, + IsPublicRole, + }; + Q_ENUM(Role) + + explicit WalletAccountModel(QObject* parent = nullptr); + + int rowCount(const QModelIndex& parent = QModelIndex()) const override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + void replaceAccounts(const QVector& accounts); + int count() const { return m_accounts.size(); } + +signals: + void countChanged(); + +private: + struct Entry { + QString name; + QString address; + QString balance; + bool isPublic = true; + }; + + QVector m_accounts; +}; diff --git a/apps/shared/wallet/src/WalletController.cpp b/apps/shared/wallet/src/WalletController.cpp new file mode 100644 index 0000000..5cffdfd --- /dev/null +++ b/apps/shared/wallet/src/WalletController.cpp @@ -0,0 +1,238 @@ +#include "WalletController.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "WalletAccountModel.h" + +namespace { +const char SETTINGS_ORG[] = "Logos"; +const char DISCONNECTED_KEY[] = "disconnected"; +const char WALLET_HOME_ENV[] = "LEE_WALLET_HOME_DIR"; + +QString toLocalPath(const QString& path) +{ + if (path.startsWith(QStringLiteral("file://")) || path.contains(QLatin1Char('/'))) + return QUrl::fromUserInput(path).toLocalFile(); + return path; +} +} + +WalletController::WalletController(WalletProvider& wallet, + QString settingsApplication, + QObject* parent) + : QObject(parent), + m_wallet(wallet), + m_settingsApplication(std::move(settingsApplication)), + m_accountModel(new WalletAccountModel(this)), + m_network(new QNetworkAccessManager(this)), + m_reachabilityTimer(new QTimer(this)) +{ + m_state.walletHome = defaultWalletHome(); + m_state.walletExists = QFileInfo::exists(defaultStoragePath()); + + m_reachabilityTimer->setInterval(10000); + connect(m_reachabilityTimer, &QTimer::timeout, + this, &WalletController::checkReachability); +} + +WalletController::~WalletController() = default; + +QString WalletController::defaultWalletHome() +{ + const QByteArray override = qgetenv(WALLET_HOME_ENV); + if (!override.isEmpty()) + return QString::fromLocal8Bit(override); + return QDir::homePath() + QStringLiteral("/.lee/wallet"); +} + +QString WalletController::defaultConfigPath() const +{ + return m_state.walletHome + QStringLiteral("/wallet_config.json"); +} + +QString WalletController::defaultStoragePath() const +{ + return m_state.walletHome + QStringLiteral("/storage.json"); +} + +void WalletController::start() +{ + if (m_started) + return; + m_started = true; + m_reachabilityTimer->start(); + QTimer::singleShot(0, this, &WalletController::openOnStartup); +} + +void WalletController::openOnStartup() +{ + if (QSettings(SETTINGS_ORG, m_settingsApplication) + .value(DISCONNECTED_KEY, false).toBool()) { + return; + } + + const QString config = defaultConfigPath(); + const QString storage = defaultStoragePath(); + const WalletSession session = m_wallet.connect({ config, storage }); + if (session.failure == WalletFailure::WalletMissing) + return; + if (!session.ok()) { + qWarning() << "WalletController: wallet connection failed" + << walletFailureCode(session.failure); + return; + } + + m_state.configPath = config; + m_state.storagePath = storage; + m_state.walletExists = QFileInfo::exists(storage) || session.adopted; + m_state.isWalletOpen = true; + applySnapshot(session.snapshot); +} + +QString WalletController::createDefaultWallet(const QString& password) +{ + return createWallet(defaultConfigPath(), defaultStoragePath(), password); +} + +QString WalletController::createWallet(const QString& configPath, + const QString& storagePath, + const QString& password) +{ + const QString config = toLocalPath(configPath); + const QString storage = toLocalPath(storagePath); + const WalletCreation creation = m_wallet.createWallet( + { config, storage }, password); + if (creation.mnemonic.isEmpty()) { + qWarning() << "WalletController: wallet creation failed" + << walletFailureCode(creation.failure); + return {}; + } + + m_state.configPath = config; + m_state.storagePath = storage; + m_state.walletExists = true; + QSettings(SETTINGS_ORG, m_settingsApplication).setValue(DISCONNECTED_KEY, false); + if (!creation.ok()) { + qWarning() << "WalletController: wallet creation failed" + << walletFailureCode(creation.failure); + emit stateChanged(); + return creation.mnemonic; + } + + m_state.isWalletOpen = true; + applySnapshot(creation.snapshot); + return creation.mnemonic; +} + +bool WalletController::open() +{ + const QString config = m_state.configPath.isEmpty() + ? defaultConfigPath() : m_state.configPath; + const QString storage = m_state.storagePath.isEmpty() + ? defaultStoragePath() : m_state.storagePath; + const WalletSession session = m_wallet.connect({ config, storage }); + if (!session.ok()) { + qWarning() << "WalletController: wallet open failed" + << walletFailureCode(session.failure); + return false; + } + + m_state.configPath = config; + m_state.storagePath = storage; + m_state.walletExists = true; + m_state.isWalletOpen = true; + QSettings(SETTINGS_ORG, m_settingsApplication).setValue(DISCONNECTED_KEY, false); + applySnapshot(session.snapshot); + return true; +} + +void WalletController::disconnect() +{ + m_wallet.disconnect(); + m_state.isWalletOpen = false; + m_accountModel->replaceAccounts({}); + QSettings(SETTINGS_ORG, m_settingsApplication).setValue(DISCONNECTED_KEY, true); + emit stateChanged(); +} + +QString WalletController::createAccount(bool isPublic) +{ + const WalletAccountCreation creation = m_wallet.createAccount(isPublic); + if (!creation.ok()) { + qWarning() << "WalletController: account creation failed" + << walletFailureCode(creation.failure); + return {}; + } + if (creation.snapshot.ok()) { + applySnapshot(creation.snapshot); + } else { + qWarning() << "WalletController: account refresh failed" + << walletFailureCode(creation.snapshot.failure); + } + return creation.accountId; +} + +void WalletController::refresh() +{ + const WalletSnapshot next = m_wallet.snapshot(true); + if (next.ok()) { + applySnapshot(next); + } else { + qWarning() << "WalletController: wallet refresh failed" + << walletFailureCode(next.failure); + } +} + +QString WalletController::balance(const QString& accountId, bool isPublic) +{ + const WalletSnapshot current = m_wallet.snapshot(); + for (const WalletAccount& account : current.accounts) { + if (account.address == accountId && account.isPublic == isPublic) + return account.balance; + } + return {}; +} + +void WalletController::applySnapshot(const WalletSnapshot& snapshot) +{ + m_accountModel->replaceAccounts(snapshot.accounts); + m_state.lastSyncedBlock = static_cast(snapshot.lastSyncedBlock); + m_state.currentBlockHeight = static_cast(snapshot.currentBlockHeight); + m_state.sequencerAddress = snapshot.sequencerAddress; + emit stateChanged(); + checkReachability(); +} + +void WalletController::checkReachability() +{ + if (!m_state.isWalletOpen || m_state.sequencerAddress.isEmpty()) + return; + + QNetworkRequest request{QUrl(m_state.sequencerAddress)}; + request.setTransferTimeout(4000); + QNetworkReply* reply = m_network->get(request); + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + if (!m_state.isWalletOpen) { + reply->deleteLater(); + return; + } + const bool receivedHttp = + reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).isValid(); + const bool reachable = receivedHttp || reply->error() == QNetworkReply::NoError; + if (m_state.sequencerReachable != reachable) { + m_state.sequencerReachable = reachable; + emit stateChanged(); + } + reply->deleteLater(); + }); +} diff --git a/apps/shared/wallet/src/WalletController.h b/apps/shared/wallet/src/WalletController.h new file mode 100644 index 0000000..c27f9ce --- /dev/null +++ b/apps/shared/wallet/src/WalletController.h @@ -0,0 +1,67 @@ +#pragma once + +#include +#include + +#include "WalletProvider.h" + +class QNetworkAccessManager; +class QTimer; +class WalletAccountModel; + +struct WalletUiState { + bool isWalletOpen = false; + bool walletExists = false; + QString configPath; + QString storagePath; + QString walletHome; + int lastSyncedBlock = 0; + int currentBlockHeight = 0; + QString sequencerAddress; + bool sequencerReachable = true; +}; + +class WalletController final : public QObject { + Q_OBJECT + +public: + // The provider must outlive the controller. + explicit WalletController(WalletProvider& wallet, + QString settingsApplication, + QObject* parent = nullptr); + ~WalletController() override; + + WalletAccountModel* accountModel() const { return m_accountModel; } + const WalletUiState& state() const { return m_state; } + + void start(); + QString createAccount(bool isPublic); + void refresh(); + QString balance(const QString& accountId, bool isPublic); + QString createDefaultWallet(const QString& password); + QString createWallet(const QString& configPath, + const QString& storagePath, + const QString& password); + bool open(); + void disconnect(); + +signals: + void stateChanged(); + +private: + static QString defaultWalletHome(); + QString defaultConfigPath() const; + QString defaultStoragePath() const; + + void openOnStartup(); + void applySnapshot(const WalletSnapshot& snapshot); + void checkReachability(); + + WalletProvider& m_wallet; + QString m_settingsApplication; + WalletUiState m_state; + WalletAccountModel* m_accountModel; + QNetworkAccessManager* m_network; + QTimer* m_reachabilityTimer; + bool m_started = false; +}; diff --git a/apps/shared/wallet/src/WalletProvider.cpp b/apps/shared/wallet/src/WalletProvider.cpp new file mode 100644 index 0000000..9cb259d --- /dev/null +++ b/apps/shared/wallet/src/WalletProvider.cpp @@ -0,0 +1,26 @@ +#include "WalletProvider.h" + +QString walletFailureCode(WalletFailure failure) +{ + switch (failure) { + case WalletFailure::None: + return {}; + case WalletFailure::WalletMissing: + return QStringLiteral("wallet_missing"); + case WalletFailure::WalletUnavailable: + return QStringLiteral("wallet_unavailable"); + case WalletFailure::OpenFailed: + return QStringLiteral("open_failed"); + case WalletFailure::CreateFailed: + return QStringLiteral("create_failed"); + case WalletFailure::SaveFailed: + return QStringLiteral("save_failed"); + case WalletFailure::ReadFailed: + return QStringLiteral("read_failed"); + case WalletFailure::InvalidRequest: + return QStringLiteral("invalid_request"); + case WalletFailure::SubmissionFailed: + return QStringLiteral("submission_failed"); + } + return QStringLiteral("wallet_unavailable"); +} diff --git a/apps/shared/wallet/src/WalletProvider.h b/apps/shared/wallet/src/WalletProvider.h new file mode 100644 index 0000000..a7814ca --- /dev/null +++ b/apps/shared/wallet/src/WalletProvider.h @@ -0,0 +1,107 @@ +#pragma once + +#include +#include +#include + +enum class WalletFailure { + None, + WalletMissing, + WalletUnavailable, + OpenFailed, + CreateFailed, + SaveFailed, + ReadFailed, + InvalidRequest, + SubmissionFailed, +}; + +QString walletFailureCode(WalletFailure failure); + +struct WalletPaths { + QString config; + QString storage; +}; + +struct WalletAccountRead { + QString accountId; + QString status = QStringLiteral("read_failed"); + QString programOwner; + QString balanceHex; + QString nonceHex; + QString dataHex; + + bool ok() const { return status == QStringLiteral("ok"); } +}; + +struct WalletAccount { + QString address; + QString balance; + bool isPublic = true; +}; + +struct WalletSnapshot { + WalletFailure failure = WalletFailure::None; + QVector accounts; + QVector publicAccountReads; + quint64 lastSyncedBlock = 0; + quint64 currentBlockHeight = 0; + QString sequencerAddress; + + bool ok() const { return failure == WalletFailure::None; } +}; + +struct WalletSession { + WalletFailure failure = WalletFailure::None; + WalletSnapshot snapshot; + bool adopted = false; + + bool ok() const { return failure == WalletFailure::None; } +}; + +struct WalletCreation { + WalletFailure failure = WalletFailure::None; + QString mnemonic; + WalletSnapshot snapshot; + + bool ok() const { return failure == WalletFailure::None; } +}; + +struct WalletAccountCreation { + WalletFailure failure = WalletFailure::None; + QString accountId; + WalletAccountRead publicAccount; + WalletSnapshot snapshot; + + bool ok() const { return failure == WalletFailure::None; } +}; + +struct WalletTransaction { + QString programId; + QStringList accountIds; + QVector signingRequirements; + QVector instruction; +}; + +struct WalletSubmission { + WalletFailure failure = WalletFailure::None; + QString nativeHash; + + bool accepted() const { return failure == WalletFailure::None && !nativeHash.isEmpty(); } +}; + +class WalletProvider { +public: + virtual ~WalletProvider() = default; + + virtual WalletSession connect(const WalletPaths& paths) = 0; + virtual WalletCreation createWallet(const WalletPaths& paths, + const QString& password) = 0; + virtual WalletSnapshot snapshot(bool forceRefresh = false) = 0; + virtual void clearSnapshot() = 0; + virtual WalletAccountCreation createAccount(bool isPublic) = 0; + virtual WalletAccountRead readPublicAccount(const QString& accountId) const = 0; + virtual WalletSubmission submitPublicTransaction( + const WalletTransaction& transaction) = 0; + virtual void disconnect() = 0; +}; diff --git a/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp b/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp new file mode 100644 index 0000000..9701d6b --- /dev/null +++ b/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp @@ -0,0 +1,452 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "FakeWalletProvider.h" +#include "LogosWalletProvider.h" +#include "WalletAccountModel.h" +#include "WalletController.h" +#include "logos_sdk.h" + +namespace { +const QString ACCOUNT_A(64, QLatin1Char('a')); +const QString ACCOUNT_B(64, QLatin1Char('b')); +const QString PROGRAM_ID(64, QLatin1Char('c')); + +QString publicAccountJson(const QString& owner = PROGRAM_ID, + const QString& balance = QStringLiteral("01000000000000000000000000000000"), + const QString& nonce = QString(32, QLatin1Char('0')), + const QString& data = QStringLiteral("00ff")) +{ + return QString::fromUtf8(QJsonDocument(QJsonObject { + { QStringLiteral("program_owner"), owner }, + { QStringLiteral("balance"), balance }, + { QStringLiteral("nonce"), nonce }, + { QStringLiteral("data"), data }, + }).toJson(QJsonDocument::Compact)); +} + +QVariantMap accountEntry(const QString& id, bool isPublic) +{ + return { + { QStringLiteral("account_id"), id }, + { QStringLiteral("is_public"), isPublic }, + }; +} +} + +class LogosWalletProviderTest : public QObject { + Q_OBJECT + +private slots: + void adoptsOpenWalletAndCachesSnapshots(); + void opensConfiguredWalletWhenNoSharedSessionExists(); + void createsAndPersistsWallet(); + void validatesCompletePublicAccountPayloads(); + void fallsBackToBalanceWhenPublicReadFails(); + void createsAndPersistsAccounts(); + void preservesCreatedAccountWhenPublicReadFails(); + void preservesCreatedAccountWhenSnapshotRefreshFails(); + void dispatchesExactGenericTransaction(); + void rejectsInvalidSubmissionResponses(); + void exposesStableAccountModelRoles(); + void fakeProviderImplementsConsumerContract(); + void controllerOwnsUiWalletFlow(); + void controllerStopsReachabilityChecksAfterDisconnect(); +}; + +void LogosWalletProviderTest::adoptsOpenWalletAndCachesSnapshots() +{ + LogosModules modules; + modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer"); + modules.logos_execution_zone.currentBlockHeight = 12; + modules.logos_execution_zone.lastSyncedBlock = 11; + modules.logos_execution_zone.accounts = { + accountEntry(ACCOUNT_A, true), + accountEntry(ACCOUNT_B, false), + }; + modules.logos_execution_zone.publicAccounts.insert( + ACCOUNT_A, publicAccountJson()); + modules.logos_execution_zone.balances.insert(ACCOUNT_B, QStringLiteral("42")); + + LogosWalletProvider provider(&modules); + const WalletSession session = provider.connect({ QStringLiteral("unused"), QStringLiteral("unused") }); + + QVERIFY(session.ok()); + QVERIFY(session.adopted); + QCOMPARE(session.snapshot.accounts.size(), 2); + QCOMPARE(session.snapshot.accounts.at(0).balance, QStringLiteral("1")); + QCOMPARE(session.snapshot.accounts.at(1).balance, QStringLiteral("42")); + QCOMPARE(session.snapshot.publicAccountReads.size(), 1); + QCOMPARE(session.snapshot.currentBlockHeight, quint64(12)); + QCOMPARE(session.snapshot.lastSyncedBlock, quint64(11)); + + const int listCalls = modules.logos_execution_zone.listCalls; + const int readCalls = modules.logos_execution_zone.publicReadCalls; + QVERIFY(provider.snapshot().ok()); + QCOMPARE(modules.logos_execution_zone.listCalls, listCalls); + QCOMPARE(modules.logos_execution_zone.publicReadCalls, readCalls); + + QVERIFY(provider.snapshot(true).ok()); + QVERIFY(modules.logos_execution_zone.listCalls > listCalls); + QVERIFY(modules.logos_execution_zone.publicReadCalls > readCalls); + + modules.logos_execution_zone.publicAccounts[ACCOUNT_A] = publicAccountJson( + PROGRAM_ID, QString(32, QLatin1Char('f'))); + QCOMPARE(provider.snapshot(true).accounts.at(0).balance, + QStringLiteral("340282366920938463463374607431768211455")); + + provider.clearSnapshot(); + const int afterRefresh = modules.logos_execution_zone.listCalls; + QVERIFY(provider.snapshot().ok()); + QVERIFY(modules.logos_execution_zone.listCalls > afterRefresh); + + provider.disconnect(); + QCOMPARE(provider.snapshot().failure, WalletFailure::WalletUnavailable); +} + +void LogosWalletProviderTest::opensConfiguredWalletWhenNoSharedSessionExists() +{ + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QString storage = directory.filePath(QStringLiteral("storage.json")); + QFile file(storage); + QVERIFY(file.open(QIODevice::WriteOnly)); + file.close(); + + LogosModules modules; + LogosWalletProvider provider(&modules); + const WalletSession session = provider.connect({ + directory.filePath(QStringLiteral("wallet.json")), + storage, + }); + + QVERIFY(session.ok()); + QVERIFY(!session.adopted); + QCOMPARE(modules.logos_execution_zone.openCalls, 1); + QCOMPARE(modules.logos_execution_zone.openedStorage, storage); + + LogosModules missingModules; + LogosWalletProvider missingProvider(&missingModules); + QCOMPARE(missingProvider.connect({ QStringLiteral("config"), QStringLiteral("missing") }).failure, + WalletFailure::WalletMissing); +} + +void LogosWalletProviderTest::createsAndPersistsWallet() +{ + QTemporaryDir directory; + QVERIFY(directory.isValid()); + + LogosModules modules; + LogosWalletProvider provider(&modules); + const WalletPaths paths { + directory.filePath(QStringLiteral("config/wallet.json")), + directory.filePath(QStringLiteral("state/storage.json")), + }; + const WalletCreation creation = provider.createWallet(paths, QStringLiteral("secret")); + + QVERIFY(creation.ok()); + QCOMPARE(creation.mnemonic, modules.logos_execution_zone.mnemonic); + QCOMPARE(modules.logos_execution_zone.createdConfig, paths.config); + QCOMPARE(modules.logos_execution_zone.createdStorage, paths.storage); + QCOMPARE(modules.logos_execution_zone.createdPassword, QStringLiteral("secret")); + QVERIFY(modules.logos_execution_zone.saveCalls >= 1); + + LogosModules rejectedModules; + rejectedModules.logos_execution_zone.mnemonic.clear(); + LogosWalletProvider rejectedProvider(&rejectedModules); + QCOMPARE(rejectedProvider.createWallet(paths, QStringLiteral("secret")).failure, + WalletFailure::CreateFailed); + + LogosModules unsavedModules; + unsavedModules.logos_execution_zone.saveResult = 1; + LogosWalletProvider unsavedProvider(&unsavedModules); + const WalletCreation unsaved = unsavedProvider.createWallet(paths, QStringLiteral("secret")); + QCOMPARE(unsaved.failure, WalletFailure::SaveFailed); + QCOMPARE(unsaved.mnemonic, unsavedModules.logos_execution_zone.mnemonic); +} + +void LogosWalletProviderTest::validatesCompletePublicAccountPayloads() +{ + LogosModules modules; + modules.logos_execution_zone.publicAccounts.insert(ACCOUNT_A, publicAccountJson()); + LogosWalletProvider provider(&modules); + + const WalletAccountRead valid = provider.readPublicAccount(ACCOUNT_A); + QVERIFY(valid.ok()); + QCOMPARE(valid.accountId, ACCOUNT_A); + QCOMPARE(valid.programOwner, PROGRAM_ID); + QCOMPARE(valid.balanceHex, QStringLiteral("01000000000000000000000000000000")); + QCOMPARE(valid.dataHex, QStringLiteral("00ff")); + + modules.logos_execution_zone.publicAccounts[ACCOUNT_A] = publicAccountJson(PROGRAM_ID.toUpper()); + QVERIFY(!provider.readPublicAccount(ACCOUNT_A).ok()); + modules.logos_execution_zone.publicAccounts[ACCOUNT_A] = publicAccountJson( + PROGRAM_ID, QStringLiteral("01")); + QVERIFY(!provider.readPublicAccount(ACCOUNT_A).ok()); + modules.logos_execution_zone.publicAccounts[ACCOUNT_A] = publicAccountJson( + PROGRAM_ID, QStringLiteral("01000000000000000000000000000000"), + QString(32, QLatin1Char('0')), QStringLiteral("abc")); + QVERIFY(!provider.readPublicAccount(ACCOUNT_A).ok()); + modules.logos_execution_zone.publicAccounts[ACCOUNT_A] = QStringLiteral("[]"); + QVERIFY(!provider.readPublicAccount(ACCOUNT_A).ok()); + QVERIFY(!provider.readPublicAccount(QStringLiteral("invalid")).ok()); +} + +void LogosWalletProviderTest::fallsBackToBalanceWhenPublicReadFails() +{ + LogosModules modules; + modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer"); + modules.logos_execution_zone.accounts = { accountEntry(ACCOUNT_A, true) }; + modules.logos_execution_zone.balances.insert(ACCOUNT_A, QStringLiteral("42")); + + LogosWalletProvider provider(&modules); + const WalletSession session = provider.connect({}); + + QVERIFY(session.ok()); + QCOMPARE(session.snapshot.accounts.size(), 1); + QCOMPARE(session.snapshot.accounts.at(0).balance, QStringLiteral("42")); + QCOMPARE(session.snapshot.publicAccountReads.size(), 1); + QVERIFY(!session.snapshot.publicAccountReads.at(0).ok()); +} + +void LogosWalletProviderTest::createsAndPersistsAccounts() +{ + LogosModules modules; + modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer"); + modules.logos_execution_zone.publicAccountId = ACCOUNT_A; + modules.logos_execution_zone.accounts = { accountEntry(ACCOUNT_A, true) }; + modules.logos_execution_zone.publicAccounts.insert(ACCOUNT_A, publicAccountJson()); + LogosWalletProvider provider(&modules); + QVERIFY(provider.connect({}).ok()); + + const int savesBeforeCreate = modules.logos_execution_zone.saveCalls; + const WalletAccountCreation creation = provider.createAccount(true); + QVERIFY(creation.ok()); + QCOMPARE(creation.accountId, ACCOUNT_A); + QVERIFY(creation.publicAccount.ok()); + QCOMPARE(creation.snapshot.accounts.size(), 1); + QVERIFY(modules.logos_execution_zone.saveCalls > savesBeforeCreate); + + modules.logos_execution_zone.saveResult = 1; + QCOMPARE(provider.createAccount(true).failure, WalletFailure::SaveFailed); +} + +void LogosWalletProviderTest::preservesCreatedAccountWhenPublicReadFails() +{ + LogosModules modules; + modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer"); + modules.logos_execution_zone.publicAccountId = ACCOUNT_A; + modules.logos_execution_zone.accounts = { accountEntry(ACCOUNT_A, true) }; + modules.logos_execution_zone.balances.insert(ACCOUNT_A, QStringLiteral("7")); + LogosWalletProvider provider(&modules); + QVERIFY(provider.connect({}).ok()); + + const WalletAccountCreation creation = provider.createAccount(true); + + QVERIFY(creation.ok()); + QCOMPARE(creation.accountId, ACCOUNT_A); + QVERIFY(!creation.publicAccount.ok()); + QCOMPARE(creation.snapshot.accounts.size(), 1); + QCOMPARE(creation.snapshot.accounts.at(0).balance, QStringLiteral("7")); +} + +void LogosWalletProviderTest::preservesCreatedAccountWhenSnapshotRefreshFails() +{ + LogosModules modules; + modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer"); + modules.logos_execution_zone.publicAccountId = ACCOUNT_A; + modules.logos_execution_zone.publicAccounts.insert(ACCOUNT_A, publicAccountJson()); + LogosWalletProvider provider(&modules); + QVERIFY(provider.connect({}).ok()); + + modules.logos_execution_zone.currentBlockHeight = 1; + modules.logos_execution_zone.syncResult = 1; + const WalletAccountCreation creation = provider.createAccount(true); + + QVERIFY(creation.ok()); + QCOMPARE(creation.accountId, ACCOUNT_A); + QCOMPARE(creation.snapshot.failure, WalletFailure::ReadFailed); +} + +void LogosWalletProviderTest::dispatchesExactGenericTransaction() +{ + LogosModules modules; + modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer"); + modules.logos_execution_zone.transactionResponse = QStringLiteral( + R"({"success":true,"tx_hash":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"})"); + LogosWalletProvider provider(&modules); + QVERIFY(provider.connect({}).ok()); + + WalletTransaction transaction; + transaction.programId = PROGRAM_ID; + transaction.accountIds = { ACCOUNT_A, ACCOUNT_B }; + transaction.signingRequirements = { true, false }; + transaction.instruction = { 7, 0, 4294967295U }; + + const WalletSubmission submission = provider.submitPublicTransaction(transaction); + QVERIFY(submission.accepted()); + QCOMPARE(submission.nativeHash, QString(64, QLatin1Char('a'))); + QCOMPARE(modules.logos_execution_zone.submitCalls, 1); + QCOMPARE(modules.logos_execution_zone.submittedProgramId, PROGRAM_ID); + QCOMPARE(modules.logos_execution_zone.submittedAccountIds, transaction.accountIds); + QCOMPARE(modules.logos_execution_zone.submittedSigningRequirements, + QVariantList({ true, false })); + QCOMPARE(modules.logos_execution_zone.submittedInstruction.toList(), + QVariantList({ 7U, 0U, 4294967295U })); +} + +void LogosWalletProviderTest::rejectsInvalidSubmissionResponses() +{ + LogosModules modules; + modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer"); + LogosWalletProvider provider(&modules); + QVERIFY(provider.connect({}).ok()); + + WalletTransaction transaction { + PROGRAM_ID, + { ACCOUNT_A }, + { true }, + { 1 }, + }; + + const QStringList invalidResponses { + QStringLiteral("not-json"), + QStringLiteral(R"({"success":false,"tx_hash":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"})"), + QStringLiteral(R"({"success":true,"error":"rejected","tx_hash":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"})"), + QStringLiteral(R"({"success":true,"tx_hash":"short"})"), + }; + for (const QString& response : invalidResponses) { + modules.logos_execution_zone.transactionResponse = response; + QCOMPARE(provider.submitPublicTransaction(transaction).failure, + WalletFailure::SubmissionFailed); + } + + transaction.signingRequirements.clear(); + QCOMPARE(provider.submitPublicTransaction(transaction).failure, + WalletFailure::InvalidRequest); +} + +void LogosWalletProviderTest::exposesStableAccountModelRoles() +{ + WalletAccountModel model; + QSignalSpy countChanged(&model, &WalletAccountModel::countChanged); + model.replaceAccounts({ + { ACCOUNT_A, QStringLiteral("10"), true }, + { ACCOUNT_B, QStringLiteral("20"), false }, + }); + + QCOMPARE(model.count(), 2); + QCOMPARE(countChanged.count(), 1); + QCOMPARE(model.roleNames().value(WalletAccountModel::NameRole), QByteArray("name")); + QCOMPARE(model.data(model.index(0), WalletAccountModel::NameRole).toString(), + QStringLiteral("Account 1")); + QCOMPARE(model.data(model.index(1), WalletAccountModel::AddressRole).toString(), ACCOUNT_B); + QCOMPARE(model.data(model.index(1), WalletAccountModel::BalanceRole).toString(), + QStringLiteral("20")); + QVERIFY(!model.data(model.index(1), WalletAccountModel::IsPublicRole).toBool()); +} + +void LogosWalletProviderTest::fakeProviderImplementsConsumerContract() +{ + FakeWalletProvider provider; + provider.snapshotResult.accounts = { { ACCOUNT_A, QStringLiteral("5"), true } }; + provider.submissionResult.nativeHash = QString(64, QLatin1Char('d')); + + QCOMPARE(provider.snapshot(true).accounts.size(), 1); + QVERIFY(provider.lastForceRefresh); + QCOMPARE(provider.readPublicAccount(ACCOUNT_B).accountId, ACCOUNT_B); + QCOMPARE(provider.readCalls, 1); + WalletTransaction transaction { PROGRAM_ID, { ACCOUNT_A }, { true }, { 9 } }; + QVERIFY(provider.submitPublicTransaction(transaction).accepted()); + QCOMPARE(provider.lastTransaction.instruction, transaction.instruction); + provider.disconnect(); + QCOMPARE(provider.disconnectCalls, 1); +} + +void LogosWalletProviderTest::controllerOwnsUiWalletFlow() +{ + const QString settingsApplication = QStringLiteral("WalletControllerTest"); + QSettings settings(QStringLiteral("Logos"), settingsApplication); + settings.clear(); + + FakeWalletProvider provider; + provider.connectResult.snapshot.accounts = { + { ACCOUNT_A, QStringLiteral("5"), true }, + }; + provider.connectResult.snapshot.lastSyncedBlock = 7; + provider.connectResult.snapshot.currentBlockHeight = 8; + + WalletController controller(provider, settingsApplication); + QSignalSpy stateChanged(&controller, &WalletController::stateChanged); + + QVERIFY(controller.open()); + QCOMPARE(provider.connectCalls, 1); + QVERIFY(controller.state().isWalletOpen); + QVERIFY(controller.state().walletExists); + QCOMPARE(controller.state().lastSyncedBlock, 7); + QCOMPARE(controller.state().currentBlockHeight, 8); + QCOMPARE(controller.accountModel()->count(), 1); + + provider.snapshotResult.accounts = { + { ACCOUNT_A, QStringLiteral("9"), true }, + }; + controller.refresh(); + QVERIFY(provider.lastForceRefresh); + QCOMPARE(controller.balance(ACCOUNT_A, true), QStringLiteral("9")); + + provider.createAccountResult.accountId = ACCOUNT_B; + provider.createAccountResult.snapshot.accounts = { + { ACCOUNT_A, QStringLiteral("9"), true }, + { ACCOUNT_B, QStringLiteral("3"), false }, + }; + QCOMPARE(controller.createAccount(false), ACCOUNT_B); + QVERIFY(!provider.lastAccountWasPublic); + QCOMPARE(controller.accountModel()->count(), 2); + + controller.disconnect(); + QCOMPARE(provider.disconnectCalls, 1); + QVERIFY(!controller.state().isWalletOpen); + QCOMPARE(controller.accountModel()->count(), 0); + QVERIFY(stateChanged.count() >= 4); + + settings.clear(); +} + +void LogosWalletProviderTest::controllerStopsReachabilityChecksAfterDisconnect() +{ + const QString settingsApplication = QStringLiteral("WalletReachabilityTest"); + QSettings settings(QStringLiteral("Logos"), settingsApplication); + settings.clear(); + + FakeWalletProvider provider; + provider.connectResult.snapshot.sequencerAddress = QStringLiteral("http://127.0.0.1:1"); + WalletController controller(provider, settingsApplication); + auto* network = controller.findChild(); + QVERIFY(network); + QSignalSpy finished(network, &QNetworkAccessManager::finished); + + QVERIFY(controller.open()); + QTRY_VERIFY_WITH_TIMEOUT(!finished.isEmpty(), 1000); + controller.disconnect(); + finished.clear(); + + auto* timer = controller.findChild(); + QVERIFY(timer); + timer->setInterval(1); + controller.start(); + QTest::qWait(50); + QCOMPARE(finished.count(), 0); + + settings.clear(); +} + +QTEST_GUILESS_MAIN(LogosWalletProviderTest) + +#include "LogosWalletProviderTest.moc" diff --git a/apps/shared/wallet/tests/cpp/fixtures/logos_sdk.h b/apps/shared/wallet/tests/cpp/fixtures/logos_sdk.h new file mode 100644 index 0000000..c1927f4 --- /dev/null +++ b/apps/shared/wallet/tests/cpp/fixtures/logos_sdk.h @@ -0,0 +1,118 @@ +#pragma once + +#include +#include +#include +#include +#include + +class LogosAPI; + +class FakeExecutionZone { +public: + int openResult = 0; + int saveResult = 0; + int syncResult = 0; + int lastSyncedBlock = 0; + int currentBlockHeight = 0; + QString sequencerAddress; + QString mnemonic = QStringLiteral("one two three"); + QString publicAccountId; + QString privateAccountId; + QString transactionResponse; + QVariantList accounts; + QHash publicAccounts; + QHash balances; + + int openCalls = 0; + int saveCalls = 0; + int syncCalls = 0; + int listCalls = 0; + int publicReadCalls = 0; + int submitCalls = 0; + QString openedConfig; + QString openedStorage; + QString createdConfig; + QString createdStorage; + QString createdPassword; + QStringList submittedAccountIds; + QVariantList submittedSigningRequirements; + QVariant submittedInstruction; + QString submittedProgramId; + + int open(const QString& config, const QString& storage) + { + ++openCalls; + openedConfig = config; + openedStorage = storage; + return openResult; + } + + QString create_new(const QString& config, + const QString& storage, + const QString& password) + { + createdConfig = config; + createdStorage = storage; + createdPassword = password; + return mnemonic; + } + + int save() + { + ++saveCalls; + return saveResult; + } + + QString create_account_public() { return publicAccountId; } + QString create_account_private() { return privateAccountId; } + + int get_last_synced_block() const { return lastSyncedBlock; } + int get_current_block_height() const { return currentBlockHeight; } + + int sync_to_block(quint64) + { + ++syncCalls; + return syncResult; + } + + QString get_sequencer_addr() const { return sequencerAddress; } + + QVariantList list_accounts() + { + ++listCalls; + return accounts; + } + + QString get_account_public(const QString& accountId) + { + ++publicReadCalls; + return publicAccounts.value(accountId); + } + + QString get_balance(const QString& accountId, bool) const + { + return balances.value(accountId); + } + + QString send_generic_public_transaction( + const QStringList& accountIds, + const QVariantList& signingRequirements, + const QVariant& instruction, + const QString& programId) + { + ++submitCalls; + submittedAccountIds = accountIds; + submittedSigningRequirements = signingRequirements; + submittedInstruction = instruction; + submittedProgramId = programId; + return transactionResponse; + } +}; + +struct LogosModules { + LogosModules() = default; + explicit LogosModules(LogosAPI*) { } + + FakeExecutionZone logos_execution_zone; +}; diff --git a/apps/shared/wallet/tests/qml/main.cpp b/apps/shared/wallet/tests/qml/main.cpp new file mode 100644 index 0000000..42c698d --- /dev/null +++ b/apps/shared/wallet/tests/qml/main.cpp @@ -0,0 +1,3 @@ +#include + +QUICK_TEST_MAIN(logos_wallet_qml) diff --git a/apps/shared/wallet/tests/qml/tst_SubmittedTransaction.qml b/apps/shared/wallet/tests/qml/tst_SubmittedTransaction.qml new file mode 100644 index 0000000..44e57c4 --- /dev/null +++ b/apps/shared/wallet/tests/qml/tst_SubmittedTransaction.qml @@ -0,0 +1,43 @@ +import QtQuick +import QtTest +import Logos.Wallet as Wallet + +Item { + id: root + width: 360 + height: 400 + + Component { + id: resultComponent + + Wallet.SubmittedTransaction { + width: 280 + } + } + + TestCase { + name: "SubmittedTransaction" + when: windowShown + + function test_presentsBase58AndCopyFeedback() { + const result = createTemporaryObject(resultComponent, root, { + transactionId: "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + }) + verify(result, "Submitted result exists") + verify(result.base58Shape) + + const label = findChild(result, "submittedTransactionId") + verify(label, "Transaction label exists") + compare(label.text, result.transactionId) + verify(label.implicitHeight > 0) + + const copyButton = findChild(result, "copySubmittedTransactionButton") + verify(copyButton, "Copy button exists") + mouseClick(copyButton) + verify(copyButton.copied) + + result.transactionId = "0OIl" + verify(!result.base58Shape) + } + } +} diff --git a/apps/shared/wallet/tests/qml/tst_TransactionConfirmationDialog.qml b/apps/shared/wallet/tests/qml/tst_TransactionConfirmationDialog.qml new file mode 100644 index 0000000..898a245 --- /dev/null +++ b/apps/shared/wallet/tests/qml/tst_TransactionConfirmationDialog.qml @@ -0,0 +1,125 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtTest +import Logos.Wallet as Wallet + +Item { + id: root + width: 800 + height: 600 + + Component { + id: summaryComponent + + Item { + property var snapshot: ({}) + implicitHeight: summaryText.implicitHeight + + Label { + id: summaryText + objectName: "customSummaryText" + text: parent.snapshot.amount || "" + } + } + } + + Component { + id: dialogComponent + + Wallet.TransactionConfirmationDialog { + summary: summaryComponent + } + } + + Component { + id: viewportComponent + + Item { + width: 360 + height: 240 + } + } + + Component { + id: tallSummaryComponent + + Item { + property var snapshot: ({}) + implicitHeight: 360 + } + } + + Component { + id: constrainedDialogComponent + + Wallet.TransactionConfirmationDialog { + summary: tallSummaryComponent + } + } + + TestCase { + name: "TransactionConfirmationDialog" + when: windowShown + + function test_capturesSnapshotAndLoadsProgramSummary() { + const dialog = createTemporaryObject(dialogComponent, root) + verify(dialog, "Dialog exists") + const source = { amount: "10" } + dialog.openWithSnapshot(source) + tryCompare(dialog, "opened", true) + source.amount = "20" + compare(dialog.snapshot.amount, "10") + + const summary = findChild(dialog, "customSummaryText") + verify(summary, "Custom summary exists") + compare(summary.text, "10") + + confirmedSpy.target = dialog + confirmedSpy.signalName = "confirmed" + confirmedSpy.clear() + mouseClick(findChild(dialog, "transactionConfirmButton")) + compare(confirmedSpy.count, 1) + compare(confirmedSpy.signalArguments[0][0].amount, "10") + tryCompare(dialog, "opened", false) + } + + function test_busyStateCannotBeDismissed() { + const dialog = createTemporaryObject(dialogComponent, root) + verify(dialog, "Dialog exists") + dialog.openWithSnapshot({ amount: "5" }) + dialog.busy = true + const cancelButton = findChild(dialog, "transactionCancelButton") + const confirmButton = findChild(dialog, "transactionConfirmButton") + verify(!cancelButton.enabled) + verify(!confirmButton.enabled) + keyClick(Qt.Key_Escape) + verify(dialog.opened) + dialog.cancel() + verify(dialog.opened) + dialog.busy = false + dialog.cancel() + tryCompare(dialog, "opened", false) + } + + function test_keepsActionsInsideShortViewport() { + const viewport = createTemporaryObject(viewportComponent, root) + verify(viewport, "Short viewport exists") + const dialog = createTemporaryObject(constrainedDialogComponent, viewport) + verify(dialog, "Dialog exists") + + dialog.openWithSnapshot({ amount: "5" }) + tryCompare(dialog, "opened", true) + verify(dialog.height <= viewport.height - 32) + verify(dialog.y >= 0) + verify(dialog.y + dialog.height <= viewport.height) + + const confirmButton = findChild(dialog, "transactionConfirmButton") + verify(confirmButton, "Confirm button exists") + verify(confirmButton.visible) + } + } + + SignalSpy { id: confirmedSpy } +} diff --git a/apps/shared/wallet/tests/qml/tst_WalletControl.qml b/apps/shared/wallet/tests/qml/tst_WalletControl.qml new file mode 100644 index 0000000..157a0bc --- /dev/null +++ b/apps/shared/wallet/tests/qml/tst_WalletControl.qml @@ -0,0 +1,339 @@ +import QtQuick +import QtTest +import Logos.Wallet as Wallet + +Item { + id: root + width: 800 + height: 600 + + Component { + id: backendComponent + + QtObject { + property bool isWalletOpen: false + property bool walletExists: true + property string walletHome: "/wallet" + property int openCalls: 0 + property int createCalls: 0 + property int publicAccountCalls: 0 + property int privateAccountCalls: 0 + property int disconnectCalls: 0 + + function openExisting() { + openCalls++ + isWalletOpen = true + return true + } + + function createNewDefault(_password) { + createCalls++ + isWalletOpen = true + return "alpha beta gamma" + } + + function createAccountPublic() { + publicAccountCalls++ + return "a".repeat(64) + } + + function createAccountPrivate() { + privateAccountCalls++ + return "b".repeat(64) + } + + function disconnectWallet() { + disconnectCalls++ + isWalletOpen = false + } + } + } + + Component { + id: modelComponent + ListModel { } + } + + Component { + id: controlComponent + Wallet.WalletControl { + width: 320 + height: implicitHeight + viewportWidth: 800 + } + } + + Component { + id: compactWindowComponent + + Window { + width: 360 + height: 240 + visible: true + + property alias control: walletControl + + Wallet.WalletControl { + id: walletControl + x: 20 + y: 8 + width: 320 + height: implicitHeight + viewportWidth: parent.width + } + } + } + + Component { + id: compactDialogWindowComponent + + Window { + width: 360 + height: 600 + visible: true + + property alias control: walletControl + + Wallet.WalletControl { + id: walletControl + x: parent.width - width - 12 + y: 8 + width: 40 + height: implicitHeight + viewportWidth: parent.width + } + } + } + + TestCase { + name: "WalletControl" + when: windowShown + + function createControl(walletProperties, accounts) { + const backend = createTemporaryObject(backendComponent, root, walletProperties || {}) + verify(backend, "Backend exists") + const model = createTemporaryObject(modelComponent, root) + verify(model, "Account model exists") + for (const account of accounts || []) + model.append(account) + const control = createTemporaryObject(controlComponent, root, { + wallet: backend, + accountModel: model + }) + verify(control, "Wallet control exists") + return { backend, model, control } + } + + function test_opensExistingWallet() { + const fixture = createControl({ walletExists: true }, []) + const connectButton = findChild(fixture.control, "walletConnectButton") + verify(connectButton, "Connect button exists") + mouseClick(connectButton) + compare(fixture.backend.openCalls, 1) + tryCompare(fixture.control, "connected", true) + } + + function test_requiresSeedBackupAcknowledgement() { + const fixture = createControl({ walletExists: false }, []) + mouseClick(findChild(fixture.control, "walletConnectButton")) + + const dialog = findChild(fixture.control, "createWalletDialog") + tryCompare(dialog, "opened", true) + const password = findChild(dialog, "walletPasswordField") + const confirmation = findChild(dialog, "walletConfirmPasswordField") + const createButton = findChild(dialog, "createWalletButton") + verify(password && confirmation && createButton, "Wallet fields exist") + + password.text = "secret" + confirmation.text = "different" + mouseClick(createButton) + compare(fixture.backend.createCalls, 0) + verify(dialog.errorText.length > 0) + + confirmation.text = "secret" + mouseClick(createButton) + compare(fixture.backend.createCalls, 1) + compare(dialog.mnemonic, "alpha beta gamma") + + const acknowledgement = findChild(dialog, "walletBackupAcknowledgement") + const continueButton = findChild(dialog, "walletContinueButton") + verify(acknowledgement && continueButton, "Backup controls exist") + verify(!continueButton.enabled) + mouseClick(acknowledgement) + verify(continueButton.enabled) + mouseClick(continueButton) + tryCompare(dialog, "opened", false) + } + + function test_clampsSelectionAndDisconnectsLocally() { + const fixture = createControl({ isWalletOpen: true }, [ + { name: "One", address: "a".repeat(64), balance: "10", isPublic: true }, + { name: "Two", address: "b".repeat(64), balance: "20", isPublic: false } + ]) + fixture.control.selectedIndex = 1 + compare(fixture.control.selectedAddress, "b".repeat(64)) + fixture.model.clear() + tryCompare(fixture.control, "selectedIndex", 0) + compare(fixture.control.selectedAddress, "") + + fixture.model.append({ + name: "One", address: "a".repeat(64), balance: "10", isPublic: true + }) + mouseClick(findChild(fixture.control, "walletAccountButton")) + const disconnectButton = findChild(fixture.control, "walletDisconnectButton") + tryVerify(function() { return disconnectButton.visible }) + mouseClick(disconnectButton) + compare(fixture.backend.disconnectCalls, 1) + tryCompare(fixture.control, "connected", false) + } + + function test_connectedButtonClosesOpenMenu() { + const fixture = createControl({ isWalletOpen: true }, [ + { name: "One", address: "a".repeat(64), balance: "10", isPublic: true } + ]) + const accountButton = findChild(fixture.control, "walletAccountButton") + const menu = findChild(fixture.control, "walletMenu") + + mouseClick(accountButton) + tryCompare(menu, "opened", true) + mouseClick(accountButton) + tryCompare(menu, "opened", false) + } + + function test_openMenuTracksControlMovement() { + const fixture = createControl({ isWalletOpen: true }, [ + { name: "One", address: "a".repeat(64), balance: "10", isPublic: true } + ]) + fixture.control.x = 20 + fixture.control.y = 20 + const accountButton = findChild(fixture.control, "walletAccountButton") + const menu = findChild(fixture.control, "walletMenu") + + mouseClick(accountButton) + tryCompare(menu, "opened", true) + const initialMenu = menu.contentItem.mapToItem(root, 0, 0) + const initialButton = accountButton.mapToItem(root, 0, 0) + + fixture.control.x += 300 + fixture.control.y += 30 + wait(0) + + const movedMenu = menu.contentItem.mapToItem(root, 0, 0) + const movedButton = accountButton.mapToItem(root, 0, 0) + compare(movedMenu.x - movedButton.x, initialMenu.x - initialButton.x) + compare(movedMenu.y - movedButton.y, initialMenu.y - initialButton.y) + } + + function test_selectsAccount() { + const fixture = createControl({ isWalletOpen: true }, [ + { name: "One", address: "a".repeat(64), balance: "10", isPublic: true }, + { name: "Two", address: "b".repeat(64), balance: "20", isPublic: false } + ]) + mouseClick(findChild(fixture.control, "walletAccountButton")) + const accountsButton = findChild(fixture.control, "walletAccountsButton") + tryVerify(function() { return accountsButton.visible }) + mouseClick(accountsButton) + + const accountList = findChild(fixture.control, "walletAccountList") + tryCompare(accountList, "count", 2) + tryVerify(function() { return accountList.itemAtIndex(1) !== null }) + const secondAccount = accountList.itemAtIndex(1) + secondAccount.clicked() + tryCompare(fixture.control, "selectedIndex", 1) + compare(fixture.control.selectedAddress, "b".repeat(64)) + } + + function test_createsAccount() { + const fixture = createControl({ isWalletOpen: true }, [ + { name: "One", address: "a".repeat(64), balance: "10", isPublic: true } + ]) + mouseClick(findChild(fixture.control, "walletAccountButton")) + const accountsButton = findChild(fixture.control, "walletAccountsButton") + tryVerify(function() { return accountsButton.visible }) + mouseClick(accountsButton) + + const addButton = findChild(fixture.control, "walletAddAccountButton") + tryVerify(function() { return addButton.visible }) + addButton.clicked() + const dialog = findChild(fixture.control, "createAccountDialog") + tryCompare(dialog, "opened", true) + findChild(dialog, "createAccountButton").clicked() + compare(fixture.backend.publicAccountCalls, 1) + tryCompare(dialog, "opened", false) + } + + function test_compactLayoutHasStableWidth() { + const fixture = createControl({ isWalletOpen: false }, []) + fixture.control.viewportWidth = 480 + verify(fixture.control.compactLayout) + compare(fixture.control.implicitWidth, 40) + fixture.control.viewportWidth = 900 + verify(!fixture.control.compactLayout) + compare(fixture.control.implicitWidth, 108) + } + + function test_walletDialogsUseWindowViewport() { + const window = createTemporaryObject(compactDialogWindowComponent, root) + verify(window, "Compact window exists") + waitForRendering(window.contentItem) + + const dialogNames = [ + "createWalletDialog", + "createAccountDialog", + "walletMessageDialog" + ] + for (const name of dialogNames) { + const dialog = findChild(window.control, name) + verify(dialog, name + " exists") + dialog.open() + tryCompare(dialog, "opened", true) + compare(dialog.width, window.width - 32) + verify(dialog.x >= 0) + verify(dialog.x + dialog.width <= window.width) + dialog.close() + tryCompare(dialog, "opened", false) + } + } + + function test_accountsRemainReachableInShortWindow() { + const backend = createTemporaryObject(backendComponent, root, { isWalletOpen: true }) + const model = createTemporaryObject(modelComponent, root) + verify(backend && model, "Wallet fixture exists") + for (let index = 0; index < 10; ++index) { + model.append({ + name: "Account " + index, + address: String(index).repeat(64), + balance: String(index), + isPublic: true + }) + } + + const window = createTemporaryObject(compactWindowComponent, root) + verify(window, "Short window exists") + window.control.wallet = backend + window.control.accountModel = model + waitForRendering(window.contentItem) + + mouseClick(findChild(window.control, "walletAccountButton")) + const menu = findChild(window.control, "walletMenu") + tryCompare(menu, "opened", true) + mouseClick(findChild(window.control, "walletAccountsButton")) + + const accountList = findChild(window.control, "walletAccountList") + const addButton = findChild(window.control, "walletAddAccountButton") + tryCompare(accountList, "count", 10) + verify(menu.y >= 12, "Menu top: " + menu.y) + verify(menu.y + menu.height <= window.height - 12, + "Menu bottom: " + (menu.y + menu.height) + + ", window: " + window.height) + verify(accountList.height > 0) + verify(accountList.contentHeight > accountList.height) + verify(addButton && addButton.visible, "Add account button is visible") + const addPosition = addButton.mapToItem(window.contentItem, 0, 0) + verify(addPosition.y >= menu.y) + verify(addPosition.y + addButton.height <= menu.y + menu.height, + "Add account bottom: " + (addPosition.y + addButton.height) + + ", menu bottom: " + (menu.y + menu.height)) + } + } +} diff --git a/apps/shared/wallet/tests/support/FakeWalletProvider.h b/apps/shared/wallet/tests/support/FakeWalletProvider.h new file mode 100644 index 0000000..d5dea0a --- /dev/null +++ b/apps/shared/wallet/tests/support/FakeWalletProvider.h @@ -0,0 +1,75 @@ +#pragma once + +#include "WalletProvider.h" + +class FakeWalletProvider final : public WalletProvider { +public: + WalletSession connectResult; + WalletCreation createWalletResult; + WalletSnapshot snapshotResult; + WalletAccountCreation createAccountResult; + WalletAccountRead readResult; + WalletSubmission submissionResult; + + int connectCalls = 0; + int createWalletCalls = 0; + int snapshotCalls = 0; + int clearCalls = 0; + int createAccountCalls = 0; + mutable int readCalls = 0; + int submitCalls = 0; + int disconnectCalls = 0; + bool lastForceRefresh = false; + bool lastAccountWasPublic = false; + WalletPaths lastPaths; + WalletTransaction lastTransaction; + + WalletSession connect(const WalletPaths& paths) override + { + ++connectCalls; + lastPaths = paths; + return connectResult; + } + + WalletCreation createWallet(const WalletPaths& paths, + const QString&) override + { + ++createWalletCalls; + lastPaths = paths; + return createWalletResult; + } + + WalletSnapshot snapshot(bool forceRefresh) override + { + ++snapshotCalls; + lastForceRefresh = forceRefresh; + return snapshotResult; + } + + void clearSnapshot() override { ++clearCalls; } + + WalletAccountCreation createAccount(bool isPublic) override + { + ++createAccountCalls; + lastAccountWasPublic = isPublic; + return createAccountResult; + } + + WalletAccountRead readPublicAccount(const QString& accountId) const override + { + ++readCalls; + WalletAccountRead result = readResult; + result.accountId = accountId; + return result; + } + + WalletSubmission submitPublicTransaction( + const WalletTransaction& transaction) override + { + ++submitCalls; + lastTransaction = transaction; + return submissionResult; + } + + void disconnect() override { ++disconnectCalls; } +}; diff --git a/flake.nix b/flake.nix index a0a443c..15fc281 100644 --- a/flake.nix +++ b/flake.nix @@ -118,6 +118,27 @@ externalLibInputs = { amm_client_ffi = { input = self; packages.default = "amm_client_ffi"; }; }; + # The AMM UI links the shared C++ wallet access lib and bundles the + # Logos.Wallet QML module (apps/shared/wallet). apps/amm/flake.nix wires + # these via its `shared_wallet` input; when built from this root flake + # the source lives in-tree, so point CMake straight at it and stage the + # built QML module the same way. Keep in sync with apps/amm/flake.nix. + preConfigure = '' + cmakeFlagsArray+=("-DLOGOS_WALLET_SOURCE_DIR=${./apps/shared/wallet}") + ''; + postInstall = '' + test -f ${./apps/amm/qml}/Logos/Wallet/qmldir + + walletQmlDir="shared-wallet/qml/Logos/Wallet" + if [ ! -d "$walletQmlDir" ]; then + echo "Built Logos.Wallet QML module not found" + exit 1 + fi + walletQmlInstallDir="$out/lib/Logos/Wallet" + mkdir -p "$walletQmlInstallDir" + cp -r "$walletQmlDir/." "$walletQmlInstallDir/" + test -f "$walletQmlInstallDir/qmldir" + ''; }; # Expose the AMM QML UI as a NAMED app/package (`amm-ui`) rather than