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.
@ -1,12 +1,25 @@
|
|||||||
cmake_minimum_required(VERSION 3.14)
|
cmake_minimum_required(VERSION 3.21)
|
||||||
project(AmmUiPlugin LANGUAGES CXX)
|
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})
|
if(DEFINED ENV{LOGOS_MODULE_BUILDER_ROOT})
|
||||||
include($ENV{LOGOS_MODULE_BUILDER_ROOT}/cmake/LogosModule.cmake)
|
include($ENV{LOGOS_MODULE_BUILDER_ROOT}/cmake/LogosModule.cmake)
|
||||||
else()
|
else()
|
||||||
message(FATAL_ERROR "LogosModule.cmake not found. Set LOGOS_MODULE_BUILDER_ROOT.")
|
message(FATAL_ERROR "LogosModule.cmake not found. Set LOGOS_MODULE_BUILDER_ROOT.")
|
||||||
endif()
|
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 +
|
# ui_qml module with a hand-written C++ backend (QtRO .rep view contract +
|
||||||
# generated *SimpleSource/*ViewPluginBase). Mirrors the LEZ wallet UI module.
|
# generated *SimpleSource/*ViewPluginBase). Mirrors the LEZ wallet UI module.
|
||||||
logos_module(
|
logos_module(
|
||||||
@ -18,8 +31,6 @@ logos_module(
|
|||||||
src/AmmUiPlugin.cpp
|
src/AmmUiPlugin.cpp
|
||||||
src/AmmUiBackend.h
|
src/AmmUiBackend.h
|
||||||
src/AmmUiBackend.cpp
|
src/AmmUiBackend.cpp
|
||||||
src/AccountModel.h
|
|
||||||
src/AccountModel.cpp
|
|
||||||
FIND_PACKAGES
|
FIND_PACKAGES
|
||||||
Qt6Gui
|
Qt6Gui
|
||||||
Qt6Network
|
Qt6Network
|
||||||
@ -28,4 +39,6 @@ logos_module(
|
|||||||
Qt6::Network
|
Qt6::Network
|
||||||
EXTERNAL_LIBS
|
EXTERNAL_LIBS
|
||||||
amm_client_ffi
|
amm_client_ffi
|
||||||
|
LINK_TARGETS
|
||||||
|
logos_wallet_access
|
||||||
)
|
)
|
||||||
|
|||||||
41
apps/amm/flake.lock
generated
@ -2238,17 +2238,17 @@
|
|||||||
"rust-rapidsnark": "rust-rapidsnark"
|
"rust-rapidsnark": "rust-rapidsnark"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1782310268,
|
"lastModified": 1784202021,
|
||||||
"narHash": "sha256-tIIIO+V+w/C/KLtKnLIv8O8/GoJ4PzgJSWrlQCXJ2P4=",
|
"narHash": "sha256-BG94Ob5tbJyw7GclNBdnOWqcBLRld8sc+xeOU8LI5RQ=",
|
||||||
"owner": "logos-blockchain",
|
"owner": "logos-blockchain",
|
||||||
"repo": "lssa",
|
"repo": "logos-execution-zone",
|
||||||
"rev": "fb8cbac40e0bda4f152415ff4f181cdc6bca6d4a",
|
"rev": "a7e06a660940a00093b1760560d37ff84aff5a05",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "logos-blockchain",
|
"owner": "logos-blockchain",
|
||||||
"repo": "lssa",
|
"repo": "logos-execution-zone",
|
||||||
"rev": "fb8cbac40e0bda4f152415ff4f181cdc6bca6d4a",
|
"rev": "a7e06a660940a00093b1760560d37ff84aff5a05",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -14438,11 +14438,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1782082589,
|
"lastModified": 1782920676,
|
||||||
"narHash": "sha256-Qeqxp0HYb3oTpmfD5YlFPJzpAJa7Ilb9o4sMeVvmHRI=",
|
"narHash": "sha256-Vb81kiYbi8yYDZbUSW6v7QhV6uO/pj7F+lCAW1coAsY=",
|
||||||
"owner": "logos-co",
|
"owner": "logos-co",
|
||||||
"repo": "logos-protocol",
|
"repo": "logos-protocol",
|
||||||
"rev": "315a3a2e0af61bc47aad5601ee44cd7689975820",
|
"rev": "d7ad26d369c4e464a99f2a357f10c5947c7174e1",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@ -16410,17 +16410,17 @@
|
|||||||
"nix-bundle-lgx": "nix-bundle-lgx_28"
|
"nix-bundle-lgx": "nix-bundle-lgx_28"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1782313954,
|
"lastModified": 1782741385,
|
||||||
"narHash": "sha256-psh5EtcIZh6kzFD4pQDT8bae9JS9eVtk5nPWxR2aGSA=",
|
"narHash": "sha256-kN6xb1b/Jvu9Ninaqtmi/C4fx8poisGYbThIBIXwvHw=",
|
||||||
"owner": "logos-blockchain",
|
"owner": "logos-blockchain",
|
||||||
"repo": "logos-execution-zone-module",
|
"repo": "logos-execution-zone-module",
|
||||||
"rev": "d2e9400ac06c3cdbfc2405b4f153fff9841a453c",
|
"rev": "d70225ced646934d2294fd9e8f8b03615c104b80",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "logos-blockchain",
|
"owner": "logos-blockchain",
|
||||||
"repo": "logos-execution-zone-module",
|
"repo": "logos-execution-zone-module",
|
||||||
"rev": "d2e9400ac06c3cdbfc2405b4f153fff9841a453c",
|
"rev": "d70225ced646934d2294fd9e8f8b03615c104b80",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -26763,7 +26763,8 @@
|
|||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"logos-module-builder": "logos-module-builder",
|
"logos-module-builder": "logos-module-builder",
|
||||||
"logos_execution_zone": "logos_execution_zone"
|
"logos_execution_zone": "logos_execution_zone",
|
||||||
|
"shared_wallet": "shared_wallet"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"rust-overlay": {
|
"rust-overlay": {
|
||||||
@ -26849,6 +26850,18 @@
|
|||||||
"rev": "e91187f8ccb5bbfc7bb00dac88169112428da78f",
|
"rev": "e91187f8ccb5bbfc7bb00dac88169112428da78f",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"shared_wallet": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"path": "../shared/wallet",
|
||||||
|
"type": "path"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"path": "../shared/wallet",
|
||||||
|
"type": "path"
|
||||||
|
},
|
||||||
|
"parent": []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"root": "root",
|
"root": "root",
|
||||||
|
|||||||
@ -4,11 +4,28 @@
|
|||||||
inputs = {
|
inputs = {
|
||||||
logos-module-builder.url = "github:logos-co/logos-module-builder";
|
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
|
# Core wallet module (the LEZ wallet FFI Qt plugin). The input name must
|
||||||
# match the metadata.json `dependencies` entry so the builder can resolve
|
# match the metadata.json `dependencies` entry so the builder can resolve
|
||||||
# it as a module dependency. This rev pins LEZ (lssa) at fb8cbac4, which
|
# it as a module dependency. This revision exposes generic transaction
|
||||||
# includes the macOS Metal-build fix, so no `--override-input` is needed.
|
# submission by deployed program ID.
|
||||||
logos_execution_zone.url = "github:logos-blockchain/logos-execution-zone-module?rev=d2e9400ac06c3cdbfc2405b4f153fff9841a453c";
|
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
|
# 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
|
# 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
|
# with `nix run .#amm-ui`, and build just the FFI crate with
|
||||||
# `nix build .#amm_client_ffi`.
|
# `nix build .#amm_client_ffi`.
|
||||||
outputs = inputs@{ logos-module-builder, ... }:
|
outputs = inputs@{ logos-module-builder, shared_wallet, ... }:
|
||||||
logos-module-builder.lib.mkLogosQmlModule {
|
logos-module-builder.lib.mkLogosQmlModule {
|
||||||
src = ./.;
|
src = ./.;
|
||||||
configFile = ./metadata.json;
|
configFile = ./metadata.json;
|
||||||
flakeInputs = inputs;
|
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"
|
||||||
|
'';
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
5
apps/amm/qml/Logos/Wallet/qmldir
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
module Logos.Wallet
|
||||||
|
optional plugin logos_wallet_qmlplugin ../../../Logos/Wallet
|
||||||
|
classname Logos_WalletPlugin
|
||||||
|
prefer :/qt/qml/Logos/Wallet/
|
||||||
|
depends QtQuick
|
||||||
@ -2,8 +2,7 @@ import QtQuick 2.15
|
|||||||
import QtQuick.Layouts 1.15
|
import QtQuick.Layouts 1.15
|
||||||
|
|
||||||
import Logos.Theme
|
import Logos.Theme
|
||||||
|
import Logos.Wallet
|
||||||
import "components/wallet"
|
|
||||||
|
|
||||||
// Self-contained navigation bar — styling is independent of any view's theme.
|
// Self-contained navigation bar — styling is independent of any view's theme.
|
||||||
// Use currentIndex to read the active tab; tabChanged(index) fires on selection.
|
// Use currentIndex to read the active tab; tabChanged(index) fires on selection.
|
||||||
@ -94,11 +93,15 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Wallet / account control on the far right.
|
// Wallet / account control on the far right.
|
||||||
AccountControl {
|
WalletControl {
|
||||||
id: accountControl
|
id: accountControl
|
||||||
Layout.leftMargin: 12
|
Layout.leftMargin: 12
|
||||||
backend: root.backend
|
wallet: root.backend
|
||||||
accountModel: root.accountModel
|
accountModel: root.accountModel
|
||||||
|
viewportWidth: root.width
|
||||||
|
watchCall: function(result, success, failure) {
|
||||||
|
logos.watch(result, success, failure)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 || ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
88
apps/amm/qml/components/swap/SwapConfirmationSummary.qml
Normal file
@ -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 || "")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
<svg fill="none" height="24" viewBox="0 0 24 24" width="24" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 845 B |
@ -1,5 +1,6 @@
|
|||||||
import QtQuick 2.15
|
import QtQuick 2.15
|
||||||
import QtQuick.Layouts 1.15
|
import QtQuick.Layouts 1.15
|
||||||
|
import Logos.Wallet
|
||||||
import "../components/shared"
|
import "../components/shared"
|
||||||
import "../components/liquidity"
|
import "../components/liquidity"
|
||||||
import "../state"
|
import "../state"
|
||||||
@ -162,10 +163,18 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LiquidityConfirmationDialog {
|
Component {
|
||||||
id: confirmationDialog
|
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) {
|
onConfirmed: function (snapshot) {
|
||||||
root.confirmLiquidityAction(snapshot);
|
root.confirmLiquidityAction(snapshot);
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
|
pragma ComponentBehavior: Bound
|
||||||
|
|
||||||
import QtQuick 2.15
|
import QtQuick 2.15
|
||||||
import QtQuick.Controls 2.15
|
|
||||||
import QtQuick.Layouts 1.15
|
import QtQuick.Layouts 1.15
|
||||||
|
import Logos.Wallet
|
||||||
import "../components/shared"
|
import "../components/shared"
|
||||||
import "../components/swap"
|
import "../components/swap"
|
||||||
import "../state"
|
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
@ -25,7 +26,7 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
QtObject {
|
QtObject {
|
||||||
id: theme
|
id: pageTheme
|
||||||
property bool isDark: true
|
property bool isDark: true
|
||||||
property var colors: isDark ? dark : light
|
property var colors: isDark ? dark : light
|
||||||
|
|
||||||
@ -68,7 +69,7 @@ Item {
|
|||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
color: theme.colors.background
|
color: pageTheme.colors.background
|
||||||
Behavior on color { ColorAnimation { duration: 300 } }
|
Behavior on color { ColorAnimation { duration: 300 } }
|
||||||
|
|
||||||
// Theme toggle
|
// Theme toggle
|
||||||
@ -77,19 +78,19 @@ Item {
|
|||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.margins: 16
|
anchors.margins: 16
|
||||||
width: 44; height: 24; radius: 12
|
width: 44; height: 24; radius: 12
|
||||||
color: theme.colors.panelBg
|
color: pageTheme.colors.panelBg
|
||||||
border.color: theme.colors.border
|
border.color: pageTheme.colors.border
|
||||||
border.width: 1
|
border.width: 1
|
||||||
Text {
|
Text {
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
text: theme.isDark ? "☀" : "☾"
|
text: pageTheme.isDark ? "☀" : "☾"
|
||||||
font.pixelSize: 13
|
font.pixelSize: 13
|
||||||
color: theme.colors.textSecondary
|
color: pageTheme.colors.textSecondary
|
||||||
}
|
}
|
||||||
MouseArea {
|
MouseArea {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
cursorShape: Qt.PointingHandCursor
|
cursorShape: Qt.PointingHandCursor
|
||||||
onClicked: theme.isDark = !theme.isDark
|
onClicked: pageTheme.isDark = !pageTheme.isDark
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -100,7 +101,7 @@ Item {
|
|||||||
SwapCard {
|
SwapCard {
|
||||||
id: swapCard
|
id: swapCard
|
||||||
Layout.alignment: Qt.AlignHCenter
|
Layout.alignment: Qt.AlignHCenter
|
||||||
theme: theme
|
theme: pageTheme
|
||||||
tokens: root.tokens
|
tokens: root.tokens
|
||||||
backend: root.backend
|
backend: root.backend
|
||||||
width: Math.min(480, root.width - 32)
|
width: Math.min(480, root.width - 32)
|
||||||
@ -126,9 +127,9 @@ Item {
|
|||||||
|
|
||||||
Text {
|
Text {
|
||||||
Layout.alignment: Qt.AlignHCenter
|
Layout.alignment: Qt.AlignHCenter
|
||||||
text: "Buy and sell crypto on <font color='" + theme.colors.textPrimary + "'>LEZ</font>."
|
text: "Buy and sell crypto on <font color='" + pageTheme.colors.textPrimary + "'>LEZ</font>."
|
||||||
textFormat: Text.RichText
|
textFormat: Text.RichText
|
||||||
color: theme.colors.textSecondary
|
color: pageTheme.colors.textSecondary
|
||||||
font.pixelSize: 15
|
font.pixelSize: 15
|
||||||
horizontalAlignment: Text.AlignHCenter
|
horizontalAlignment: Text.AlignHCenter
|
||||||
}
|
}
|
||||||
@ -138,7 +139,7 @@ Item {
|
|||||||
id: tokenModal
|
id: tokenModal
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
z: 10
|
z: 10
|
||||||
theme: theme
|
theme: pageTheme
|
||||||
tokens: root.tokens
|
tokens: root.tokens
|
||||||
|
|
||||||
property string targetSide: "sell"
|
property string targetSide: "sell"
|
||||||
@ -161,10 +162,19 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SwapConfirmationDialog {
|
Component {
|
||||||
|
id: swapConfirmationSummary
|
||||||
|
|
||||||
|
SwapConfirmationSummary {
|
||||||
|
theme: pageTheme
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TransactionConfirmationDialog {
|
||||||
id: swapConfirmationDialog
|
id: swapConfirmationDialog
|
||||||
anchors.fill: parent
|
title: qsTr("Confirm swap")
|
||||||
theme: theme
|
confirmText: qsTr("Confirm swap")
|
||||||
|
summary: swapConfirmationSummary
|
||||||
|
|
||||||
onConfirmed: function(snapshot) {
|
onConfirmed: function(snapshot) {
|
||||||
// The dialog only shows a preview snapshot; the actual
|
// The dialog only shows a preview snapshot; the actual
|
||||||
|
|||||||
@ -1,79 +0,0 @@
|
|||||||
#include "AccountModel.h"
|
|
||||||
|
|
||||||
#include <QJsonObject>
|
|
||||||
|
|
||||||
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<int, QByteArray> 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,47 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <QAbstractListModel>
|
|
||||||
#include <QJsonArray>
|
|
||||||
#include <QString>
|
|
||||||
#include <QVector>
|
|
||||||
|
|
||||||
// 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<int, QByteArray> 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<AccountEntry> m_entries;
|
|
||||||
};
|
|
||||||
@ -20,6 +20,8 @@
|
|||||||
#include <QTimer>
|
#include <QTimer>
|
||||||
#include <QUrl>
|
#include <QUrl>
|
||||||
|
|
||||||
|
#include "LogosWalletProvider.h"
|
||||||
|
#include "WalletController.h"
|
||||||
#include "logos_api.h"
|
#include "logos_api.h"
|
||||||
#include "logos_sdk.h"
|
#include "logos_sdk.h"
|
||||||
|
|
||||||
@ -51,17 +53,6 @@ static bool ammDebugEnabled()
|
|||||||
if (ammDebugEnabled()) qWarning().noquote()
|
if (ammDebugEnabled()) qWarning().noquote()
|
||||||
|
|
||||||
namespace {
|
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
|
// Absolute path to the deployed AMM program's RISC Zero program binary
|
||||||
// (amm.bin — the `ProgramBinary` `.bin` from the docker guest build, decoded
|
// (amm.bin — the `ProgramBinary` `.bin` from the docker guest build, decoded
|
||||||
// on the Rust side via `ProgramBinary::decode`; NOT a raw ELF — pointing at
|
// 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.
|
// doesn't need a hardcoded/dummy token list.
|
||||||
const char TOKENS_CONFIG_ENV[] = "TOKENS_CONFIG";
|
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]) {
|
QString bytes32ToHex(const uint8_t (&b)[32]) {
|
||||||
return QString::fromLatin1(QByteArray(reinterpret_cast<const char*>(b), 32).toHex());
|
return QString::fromLatin1(QByteArray(reinterpret_cast<const char*>(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)
|
AmmUiBackend::AmmUiBackend(LogosAPI* logosAPI, QObject* parent)
|
||||||
: AmmUiBackendSimpleSource(parent),
|
: AmmUiBackendSimpleSource(parent),
|
||||||
m_accountModel(new AccountModel(this)),
|
|
||||||
m_logosAPI(logosAPI ? logosAPI : new LogosAPI("amm_ui", this)),
|
m_logosAPI(logosAPI ? logosAPI : new LogosAPI("amm_ui", this)),
|
||||||
m_logos(new LogosModules(m_logosAPI)),
|
m_logos(std::make_unique<LogosModules>(m_logosAPI)),
|
||||||
m_net(new QNetworkAccessManager(this)),
|
m_wallet(std::make_unique<LogosWalletProvider>(m_logosAPI)),
|
||||||
m_reachabilityTimer(new QTimer(this))
|
m_walletController(std::make_unique<WalletController>(
|
||||||
|
*m_wallet, QStringLiteral("AmmUI")))
|
||||||
{
|
{
|
||||||
// PROP defaults via the generated setters.
|
connect(m_walletController.get(), &WalletController::stateChanged,
|
||||||
setIsWalletOpen(false);
|
this, &AmmUiBackend::syncWalletState);
|
||||||
setLastSyncedBlock(0);
|
syncWalletState();
|
||||||
setCurrentBlockHeight(0);
|
m_walletController->start();
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
AmmUiBackend::~AmmUiBackend()
|
AmmUiBackend::~AmmUiBackend() = default;
|
||||||
|
|
||||||
|
WalletAccountModel* AmmUiBackend::accountModel() const
|
||||||
{
|
{
|
||||||
saveWallet();
|
return m_walletController->accountModel();
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
QString AmmUiBackend::createAccountPublic()
|
QString AmmUiBackend::createAccountPublic()
|
||||||
{
|
{
|
||||||
const QString result = m_logos->logos_execution_zone.create_account_public();
|
return m_walletController->createAccount(true);
|
||||||
if (!result.isEmpty())
|
|
||||||
refreshAccounts();
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
QString AmmUiBackend::createAccountPrivate()
|
QString AmmUiBackend::createAccountPrivate()
|
||||||
{
|
{
|
||||||
const QString result = m_logos->logos_execution_zone.create_account_private();
|
return m_walletController->createAccount(false);
|
||||||
if (!result.isEmpty())
|
|
||||||
refreshAccounts();
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void AmmUiBackend::refreshAccounts()
|
void AmmUiBackend::refreshAccounts()
|
||||||
{
|
{
|
||||||
const QJsonArray arr = QJsonArray::fromVariantList(m_logos->logos_execution_zone.list_accounts());
|
m_walletController->refresh();
|
||||||
m_accountModel->replaceFromJsonArray(arr);
|
|
||||||
refreshBalances();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void AmmUiBackend::refreshBalances()
|
void AmmUiBackend::refreshBalances()
|
||||||
{
|
{
|
||||||
refreshBlockHeights();
|
m_walletController->refresh();
|
||||||
if (currentBlockHeight() > 0)
|
|
||||||
m_logos->logos_execution_zone.sync_to_block(static_cast<quint64>(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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
QString AmmUiBackend::getBalance(QString accountIdHex, bool isPublic)
|
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();
|
return m_walletController->createDefaultWallet(password);
|
||||||
const int currentVal = m_logos->logos_execution_zone.get_current_block_height();
|
|
||||||
if (lastSyncedBlock() != lastVal)
|
|
||||||
setLastSyncedBlock(lastVal);
|
|
||||||
if (currentBlockHeight() != currentVal)
|
|
||||||
setCurrentBlockHeight(currentVal);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void AmmUiBackend::refreshSequencerAddr()
|
QString AmmUiBackend::createNew(QString configPath,
|
||||||
|
QString storagePath,
|
||||||
|
QString password)
|
||||||
{
|
{
|
||||||
const QString addr = m_logos->logos_execution_zone.get_sequencer_addr();
|
return m_walletController->createWallet(configPath, storagePath, password);
|
||||||
if (sequencerAddr() != addr)
|
|
||||||
setSequencerAddr(addr);
|
|
||||||
// Probe right away so the banner reflects the (possibly new) endpoint
|
|
||||||
// without waiting for the next periodic tick.
|
|
||||||
checkReachability();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void AmmUiBackend::checkReachability()
|
bool AmmUiBackend::openExisting()
|
||||||
{
|
{
|
||||||
const QString addr = sequencerAddr();
|
return m_walletController->open();
|
||||||
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();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void AmmUiBackend::saveWallet()
|
void AmmUiBackend::disconnectWallet()
|
||||||
{
|
{
|
||||||
if (isWalletOpen())
|
m_walletController->disconnect();
|
||||||
m_logos->logos_execution_zone.save();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// These only update the in-session PROPs (so subsequent open/refresh calls
|
void AmmUiBackend::syncWalletState()
|
||||||
// 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)
|
|
||||||
{
|
{
|
||||||
setConfigPath(toLocalPath(path));
|
const WalletUiState& state = m_walletController->state();
|
||||||
}
|
setIsWalletOpen(state.isWalletOpen);
|
||||||
|
setWalletExists(state.walletExists);
|
||||||
void AmmUiBackend::persistStoragePath(const QString& path)
|
setConfigPath(state.configPath);
|
||||||
{
|
setStoragePath(state.storagePath);
|
||||||
setStoragePath(toLocalPath(path));
|
setWalletHome(state.walletHome);
|
||||||
}
|
setLastSyncedBlock(state.lastSyncedBlock);
|
||||||
|
setCurrentBlockHeight(state.currentBlockHeight);
|
||||||
bool AmmUiBackend::changeSequencerAddr(QString url)
|
setSequencerAddr(state.sequencerAddress);
|
||||||
{
|
setSequencerReachable(state.sequencerReachable);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
QString AmmUiBackend::normalizeAccountId(const QString& id)
|
QString AmmUiBackend::normalizeAccountId(const QString& id)
|
||||||
|
|||||||
@ -1,14 +1,15 @@
|
|||||||
#ifndef AMM_UI_BACKEND_H
|
#ifndef AMM_UI_BACKEND_H
|
||||||
#define AMM_UI_BACKEND_H
|
#define AMM_UI_BACKEND_H
|
||||||
|
|
||||||
#include <QObject>
|
#include <memory>
|
||||||
|
|
||||||
#include <QString>
|
#include <QString>
|
||||||
#include <QVariantList>
|
#include <QVariantList>
|
||||||
#include <QVariantMap>
|
#include <QVariantMap>
|
||||||
|
|
||||||
#include "rep_AmmUiBackend_source.h"
|
#include "rep_AmmUiBackend_source.h"
|
||||||
|
|
||||||
#include "AccountModel.h"
|
#include "WalletAccountModel.h"
|
||||||
|
|
||||||
extern "C" {
|
extern "C" {
|
||||||
#include "amm_client_ffi.h"
|
#include "amm_client_ffi.h"
|
||||||
@ -16,38 +17,29 @@ extern "C" {
|
|||||||
|
|
||||||
class LogosAPI;
|
class LogosAPI;
|
||||||
struct LogosModules;
|
struct LogosModules;
|
||||||
class QNetworkAccessManager;
|
class LogosWalletProvider;
|
||||||
class QTimer;
|
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 {
|
class AmmUiBackend : public AmmUiBackendSimpleSource {
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
Q_PROPERTY(AccountModel* accountModel READ accountModel CONSTANT)
|
Q_PROPERTY(WalletAccountModel* accountModel READ accountModel CONSTANT)
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit AmmUiBackend(LogosAPI* logosAPI = nullptr, QObject* parent = nullptr);
|
explicit AmmUiBackend(LogosAPI* logosAPI = nullptr, QObject* parent = nullptr);
|
||||||
~AmmUiBackend() override;
|
~AmmUiBackend() override;
|
||||||
|
|
||||||
AccountModel* accountModel() const { return m_accountModel; }
|
WalletAccountModel* accountModel() const;
|
||||||
|
|
||||||
public slots:
|
public slots:
|
||||||
// Overrides of the pure-virtual slots generated from the .rep.
|
|
||||||
QString createAccountPublic() override;
|
QString createAccountPublic() override;
|
||||||
QString createAccountPrivate() override;
|
QString createAccountPrivate() override;
|
||||||
void refreshAccounts() override;
|
void refreshAccounts() override;
|
||||||
void refreshBalances() override;
|
void refreshBalances() override;
|
||||||
QString getBalance(QString accountIdHex, bool isPublic) 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 createNewDefault(QString password) override;
|
||||||
QString createNew(QString configPath, QString storagePath, QString password) override;
|
QString createNew(QString configPath, QString storagePath, QString password) override;
|
||||||
bool openExisting() override;
|
bool openExisting() override;
|
||||||
void disconnectWallet() override;
|
void disconnectWallet() override;
|
||||||
bool changeSequencerAddr(QString url) override;
|
|
||||||
void copyToClipboard(QString text) override;
|
|
||||||
|
|
||||||
// AMM
|
// AMM
|
||||||
QVariantMap resolvePool(QString defAHex, QString defBHex) override;
|
QVariantMap resolvePool(QString defAHex, QString defBHex) override;
|
||||||
@ -59,43 +51,27 @@ public slots:
|
|||||||
QVariantList tokenList() override;
|
QVariantList tokenList() override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
// Per-app wallet home (kept distinct from the wallet's canonical
|
void syncWalletState();
|
||||||
// ~/.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 persistConfigPath(const QString& path);
|
|
||||||
void persistStoragePath(const QString& path);
|
|
||||||
// Normalizes an account id given as either 64-char lowercase/uppercase hex
|
// 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 base58 to lowercase hex. Returns an empty QString if `id` is neither
|
||||||
// (or the base58 decode fails), so callers can detect and skip it.
|
// (or the base58 decode fails), so callers can detect and skip it.
|
||||||
QString normalizeAccountId(const QString& id);
|
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
|
// 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
|
// .bin, not a raw ELF) from $AMM_PROGRAM_BIN, or an empty QByteArray (with a
|
||||||
// qWarning) if the env var is unset/unreadable/empty.
|
// qWarning) if the env var is unset/unreadable/empty.
|
||||||
QByteArray loadAmmElf();
|
QByteArray loadAmmElf();
|
||||||
|
|
||||||
// Probe the configured sequencer over HTTP and update sequencerReachable.
|
|
||||||
void checkReachability();
|
|
||||||
|
|
||||||
AccountModel* m_accountModel;
|
|
||||||
|
|
||||||
LogosAPI* m_logosAPI;
|
LogosAPI* m_logosAPI;
|
||||||
LogosModules* m_logos;
|
// Direct module handle for the AMM/swap path (resolvePool/swapExactInput/
|
||||||
|
// tokenList). The shared wallet provider exposes only wallet-level ops, not
|
||||||
QNetworkAccessManager* m_net;
|
// the raw account-id / get_account_public / send_generic_public_transaction
|
||||||
QTimer* m_reachabilityTimer;
|
// calls the AMM path needs, so keep a thin LogosModules over the same
|
||||||
|
// LogosAPI as the wallet provider.
|
||||||
|
std::unique_ptr<LogosModules> m_logos;
|
||||||
|
std::unique_ptr<LogosWalletProvider> m_wallet;
|
||||||
|
std::unique_ptr<WalletController> m_walletController;
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // AMM_UI_BACKEND_H
|
#endif // AMM_UI_BACKEND_H
|
||||||
|
|||||||
@ -37,13 +37,6 @@ class AmmUiBackend
|
|||||||
// Basecamp, does not close the wallet other apps share.
|
// Basecamp, does not close the wallet other apps share.
|
||||||
SLOT(void disconnectWallet())
|
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
|
// AMM
|
||||||
// Derives the AMM pool's PDAs (config/pool/vaults/current-tick) from the
|
// Derives the AMM pool's PDAs (config/pool/vaults/current-tick) from the
|
||||||
// deployed AMM program binary (see AMM_PROGRAM_BIN — a RISC Zero
|
// deployed AMM program binary (see AMM_PROGRAM_BIN — a RISC Zero
|
||||||
|
|||||||
168
apps/shared/wallet/CMakeLists.txt
Normal file
@ -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
|
||||||
|
"$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/src>"
|
||||||
|
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()
|
||||||
86
apps/shared/wallet/qml/SubmittedTransaction.qml
Normal file
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
169
apps/shared/wallet/qml/TransactionConfirmationDialog.qml
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
503
apps/shared/wallet/qml/WalletControl.qml
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
77
apps/shared/wallet/qml/internal/AccountDelegate.qml
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
apps/shared/wallet/qml/internal/CopyButton.qml
Normal file
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
94
apps/shared/wallet/qml/internal/CreateAccountDialog.qml
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
190
apps/shared/wallet/qml/internal/CreateWalletDialog.qml
Normal file
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
31
apps/shared/wallet/qml/internal/WalletIconButton.qml
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
52
apps/shared/wallet/qml/internal/WalletMessageDialog.qml
Normal file
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 253 B After Width: | Height: | Size: 253 B |
|
Before Width: | Height: | Size: 206 B After Width: | Height: | Size: 206 B |
|
Before Width: | Height: | Size: 463 B After Width: | Height: | Size: 463 B |
|
Before Width: | Height: | Size: 977 B After Width: | Height: | Size: 977 B |
|
Before Width: | Height: | Size: 237 B After Width: | Height: | Size: 237 B |
382
apps/shared/wallet/src/LogosWalletProvider.cpp
Normal file
@ -0,0 +1,382 @@
|
|||||||
|
#include "LogosWalletProvider.h"
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QJsonParseError>
|
||||||
|
#include <QVariantList>
|
||||||
|
#include <QVariantMap>
|
||||||
|
|
||||||
|
#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<char>('0' + next % 10));
|
||||||
|
carry = next / 10;
|
||||||
|
}
|
||||||
|
while (carry > 0) {
|
||||||
|
decimal.prepend(QLatin1Char(static_cast<char>('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<LogosModules>(api)), logos(ownedLogos.get())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
explicit Impl(LogosModules* value)
|
||||||
|
: logos(value)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
std::unique_ptr<LogosModules> ownedLogos;
|
||||||
|
LogosModules* logos = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
LogosWalletProvider::LogosWalletProvider(LogosAPI* api)
|
||||||
|
: m_impl(std::make_unique<Impl>(api))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
LogosWalletProvider::LogosWalletProvider(LogosModules* logos)
|
||||||
|
: m_impl(std::make_unique<Impl>(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<quint64>(
|
||||||
|
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<quint64>(
|
||||||
|
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;
|
||||||
|
}
|
||||||
37
apps/shared/wallet/src/LogosWalletProvider.h
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
#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<Impl> m_impl;
|
||||||
|
WalletSnapshot m_snapshot;
|
||||||
|
bool m_snapshotReady = false;
|
||||||
|
bool m_connected = false;
|
||||||
|
};
|
||||||
61
apps/shared/wallet/src/WalletAccountModel.cpp
Normal file
@ -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<int, QByteArray> WalletAccountModel::roleNames() const
|
||||||
|
{
|
||||||
|
return {
|
||||||
|
{ NameRole, "name" },
|
||||||
|
{ AddressRole, "address" },
|
||||||
|
{ BalanceRole, "balance" },
|
||||||
|
{ IsPublicRole, "isPublic" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
void WalletAccountModel::replaceAccounts(const QVector<WalletAccount>& 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();
|
||||||
|
}
|
||||||
42
apps/shared/wallet/src/WalletAccountModel.h
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QAbstractListModel>
|
||||||
|
#include <QVector>
|
||||||
|
|
||||||
|
#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<int, QByteArray> roleNames() const override;
|
||||||
|
|
||||||
|
void replaceAccounts(const QVector<WalletAccount>& accounts);
|
||||||
|
int count() const { return m_accounts.size(); }
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void countChanged();
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct Entry {
|
||||||
|
QString name;
|
||||||
|
QString address;
|
||||||
|
QString balance;
|
||||||
|
bool isPublic = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
QVector<Entry> m_accounts;
|
||||||
|
};
|
||||||
238
apps/shared/wallet/src/WalletController.cpp
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
#include "WalletController.h"
|
||||||
|
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
#include <QDebug>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QNetworkAccessManager>
|
||||||
|
#include <QNetworkReply>
|
||||||
|
#include <QNetworkRequest>
|
||||||
|
#include <QSettings>
|
||||||
|
#include <QTimer>
|
||||||
|
#include <QUrl>
|
||||||
|
|
||||||
|
#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<int>(snapshot.lastSyncedBlock);
|
||||||
|
m_state.currentBlockHeight = static_cast<int>(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();
|
||||||
|
});
|
||||||
|
}
|
||||||
67
apps/shared/wallet/src/WalletController.h
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
#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;
|
||||||
|
};
|
||||||
26
apps/shared/wallet/src/WalletProvider.cpp
Normal file
@ -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");
|
||||||
|
}
|
||||||
107
apps/shared/wallet/src/WalletProvider.h
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
#include <QStringList>
|
||||||
|
#include <QVector>
|
||||||
|
|
||||||
|
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<WalletAccount> accounts;
|
||||||
|
QVector<WalletAccountRead> 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<bool> signingRequirements;
|
||||||
|
QVector<quint32> 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;
|
||||||
|
};
|
||||||
452
apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp
Normal file
@ -0,0 +1,452 @@
|
|||||||
|
#include <QFile>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QNetworkAccessManager>
|
||||||
|
#include <QSettings>
|
||||||
|
#include <QSignalSpy>
|
||||||
|
#include <QTemporaryDir>
|
||||||
|
#include <QTimer>
|
||||||
|
#include <QtTest>
|
||||||
|
|
||||||
|
#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<QNetworkAccessManager*>();
|
||||||
|
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<QTimer*>();
|
||||||
|
QVERIFY(timer);
|
||||||
|
timer->setInterval(1);
|
||||||
|
controller.start();
|
||||||
|
QTest::qWait(50);
|
||||||
|
QCOMPARE(finished.count(), 0);
|
||||||
|
|
||||||
|
settings.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
QTEST_GUILESS_MAIN(LogosWalletProviderTest)
|
||||||
|
|
||||||
|
#include "LogosWalletProviderTest.moc"
|
||||||
118
apps/shared/wallet/tests/cpp/fixtures/logos_sdk.h
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QHash>
|
||||||
|
#include <QString>
|
||||||
|
#include <QStringList>
|
||||||
|
#include <QVariant>
|
||||||
|
#include <QVariantList>
|
||||||
|
|
||||||
|
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<QString, QString> publicAccounts;
|
||||||
|
QHash<QString, QString> 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;
|
||||||
|
};
|
||||||
3
apps/shared/wallet/tests/qml/main.cpp
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
#include <QtQuickTest/quicktest.h>
|
||||||
|
|
||||||
|
QUICK_TEST_MAIN(logos_wallet_qml)
|
||||||
43
apps/shared/wallet/tests/qml/tst_SubmittedTransaction.qml
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 }
|
||||||
|
}
|
||||||
339
apps/shared/wallet/tests/qml/tst_WalletControl.qml
Normal file
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
75
apps/shared/wallet/tests/support/FakeWalletProvider.h
Normal file
@ -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; }
|
||||||
|
};
|
||||||
21
flake.nix
@ -118,6 +118,27 @@
|
|||||||
externalLibInputs = {
|
externalLibInputs = {
|
||||||
amm_client_ffi = { input = self; packages.default = "amm_client_ffi"; };
|
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
|
# Expose the AMM QML UI as a NAMED app/package (`amm-ui`) rather than
|
||||||
|
|||||||