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