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.
This commit is contained in:
Ricardo Guilherme Schmidt 2026-07-15 11:55:39 -03:00 committed by r4bbit
parent e03164baf3
commit dfd19a9796
No known key found for this signature in database
GPG Key ID: E95F1E9447DC91A9
54 changed files with 3899 additions and 1959 deletions

View File

@ -1,12 +1,25 @@
cmake_minimum_required(VERSION 3.14)
cmake_minimum_required(VERSION 3.21)
project(AmmUiPlugin LANGUAGES CXX)
find_package(Qt6 6.8 REQUIRED COMPONENTS Core Gui Network Qml Quick QuickControls2)
qt_standard_project_setup(REQUIRES 6.8)
if(DEFINED ENV{LOGOS_MODULE_BUILDER_ROOT})
include($ENV{LOGOS_MODULE_BUILDER_ROOT}/cmake/LogosModule.cmake)
else()
message(FATAL_ERROR "LogosModule.cmake not found. Set LOGOS_MODULE_BUILDER_ROOT.")
endif()
set(LOGOS_WALLET_SOURCE_DIR
"${CMAKE_CURRENT_SOURCE_DIR}/../shared/wallet"
CACHE PATH "Path to the shared Logos wallet module"
)
set(LOGOS_WALLET_GENERATED_DIR
"${CMAKE_CURRENT_SOURCE_DIR}/generated_code"
CACHE PATH "Path to generated Logos SDK sources"
)
add_subdirectory("${LOGOS_WALLET_SOURCE_DIR}" "${CMAKE_CURRENT_BINARY_DIR}/shared-wallet")
# ui_qml module with a hand-written C++ backend (QtRO .rep view contract +
# generated *SimpleSource/*ViewPluginBase). Mirrors the LEZ wallet UI module.
logos_module(
@ -18,8 +31,6 @@ logos_module(
src/AmmUiPlugin.cpp
src/AmmUiBackend.h
src/AmmUiBackend.cpp
src/AccountModel.h
src/AccountModel.cpp
FIND_PACKAGES
Qt6Gui
Qt6Network
@ -28,4 +39,6 @@ logos_module(
Qt6::Network
EXTERNAL_LIBS
amm_client_ffi
LINK_TARGETS
logos_wallet_access
)

41
apps/amm/flake.lock generated
View File

@ -2238,17 +2238,17 @@
"rust-rapidsnark": "rust-rapidsnark"
},
"locked": {
"lastModified": 1782310268,
"narHash": "sha256-tIIIO+V+w/C/KLtKnLIv8O8/GoJ4PzgJSWrlQCXJ2P4=",
"lastModified": 1784202021,
"narHash": "sha256-BG94Ob5tbJyw7GclNBdnOWqcBLRld8sc+xeOU8LI5RQ=",
"owner": "logos-blockchain",
"repo": "lssa",
"rev": "fb8cbac40e0bda4f152415ff4f181cdc6bca6d4a",
"repo": "logos-execution-zone",
"rev": "a7e06a660940a00093b1760560d37ff84aff5a05",
"type": "github"
},
"original": {
"owner": "logos-blockchain",
"repo": "lssa",
"rev": "fb8cbac40e0bda4f152415ff4f181cdc6bca6d4a",
"repo": "logos-execution-zone",
"rev": "a7e06a660940a00093b1760560d37ff84aff5a05",
"type": "github"
}
},
@ -14438,11 +14438,11 @@
]
},
"locked": {
"lastModified": 1782082589,
"narHash": "sha256-Qeqxp0HYb3oTpmfD5YlFPJzpAJa7Ilb9o4sMeVvmHRI=",
"lastModified": 1782920676,
"narHash": "sha256-Vb81kiYbi8yYDZbUSW6v7QhV6uO/pj7F+lCAW1coAsY=",
"owner": "logos-co",
"repo": "logos-protocol",
"rev": "315a3a2e0af61bc47aad5601ee44cd7689975820",
"rev": "d7ad26d369c4e464a99f2a357f10c5947c7174e1",
"type": "github"
},
"original": {
@ -16410,17 +16410,17 @@
"nix-bundle-lgx": "nix-bundle-lgx_28"
},
"locked": {
"lastModified": 1782313954,
"narHash": "sha256-psh5EtcIZh6kzFD4pQDT8bae9JS9eVtk5nPWxR2aGSA=",
"lastModified": 1782741385,
"narHash": "sha256-kN6xb1b/Jvu9Ninaqtmi/C4fx8poisGYbThIBIXwvHw=",
"owner": "logos-blockchain",
"repo": "logos-execution-zone-module",
"rev": "d2e9400ac06c3cdbfc2405b4f153fff9841a453c",
"rev": "d70225ced646934d2294fd9e8f8b03615c104b80",
"type": "github"
},
"original": {
"owner": "logos-blockchain",
"repo": "logos-execution-zone-module",
"rev": "d2e9400ac06c3cdbfc2405b4f153fff9841a453c",
"rev": "d70225ced646934d2294fd9e8f8b03615c104b80",
"type": "github"
}
},
@ -26763,7 +26763,8 @@
"root": {
"inputs": {
"logos-module-builder": "logos-module-builder",
"logos_execution_zone": "logos_execution_zone"
"logos_execution_zone": "logos_execution_zone",
"shared_wallet": "shared_wallet"
}
},
"rust-overlay": {
@ -26849,6 +26850,18 @@
"rev": "e91187f8ccb5bbfc7bb00dac88169112428da78f",
"type": "github"
}
},
"shared_wallet": {
"flake": false,
"locked": {
"path": "../shared/wallet",
"type": "path"
},
"original": {
"path": "../shared/wallet",
"type": "path"
},
"parent": []
}
},
"root": "root",

View File

@ -4,11 +4,28 @@
inputs = {
logos-module-builder.url = "github:logos-co/logos-module-builder";
# Shared C++ wallet access and Logos.Wallet QML sources.
shared_wallet = {
url = "path:../shared/wallet";
flake = false;
};
# Core wallet module (the LEZ wallet FFI Qt plugin). The input name must
# match the metadata.json `dependencies` entry so the builder can resolve
# it as a module dependency. This rev pins LEZ (lssa) at fb8cbac4, which
# includes the macOS Metal-build fix, so no `--override-input` is needed.
logos_execution_zone.url = "github:logos-blockchain/logos-execution-zone-module?rev=d2e9400ac06c3cdbfc2405b4f153fff9841a453c";
# it as a module dependency. This revision exposes generic transaction
# submission by deployed program ID.
logos_execution_zone = {
url = "github:logos-blockchain/logos-execution-zone-module?rev=d70225ced646934d2294fd9e8f8b03615c104b80";
# The module pins the monorepo at v0.2.0-rc6 (e37876a), which owns the
# xcrun wrapper in its flake.nix. Override that transitive input to the
# head of PR #629 (fix(macos): nuke xcrun cache) so the Metal/xcrun build
# works on macOS. Note: #629 branches off `dev`, so this also pulls dev's
# drift from rc6 — if the wallet_ffi ABI mismatches the module build, fall
# back to cherry-picking #629's one line onto e37876a and pin that commit.
inputs.logos-execution-zone.url =
"github:logos-blockchain/logos-execution-zone?rev=a7e06a660940a00093b1760560d37ff84aff5a05";
};
};
# NOTE: this flake is no longer built standalone. The amm_client_ffi crate
@ -22,10 +39,28 @@
# as a named attribute (there is no bare `default`): run it from the repo root
# with `nix run .#amm-ui`, and build just the FFI crate with
# `nix build .#amm_client_ffi`.
outputs = inputs@{ logos-module-builder, ... }:
outputs = inputs@{ logos-module-builder, shared_wallet, ... }:
logos-module-builder.lib.mkLogosQmlModule {
src = ./.;
configFile = ./metadata.json;
flakeInputs = inputs;
preConfigure = ''
cmakeFlagsArray+=("-DLOGOS_WALLET_SOURCE_DIR=${shared_wallet}")
'';
postInstall = ''
# The builder installs the view under lib/qml after this hook. Its
# import descriptor points back to this compiled shared QML module.
test -f ${./qml}/Logos/Wallet/qmldir
walletQmlDir="shared-wallet/qml/Logos/Wallet"
if [ ! -d "$walletQmlDir" ]; then
echo "Built Logos.Wallet QML module not found"
exit 1
fi
walletQmlInstallDir="$out/lib/Logos/Wallet"
mkdir -p "$walletQmlInstallDir"
cp -r "$walletQmlDir/." "$walletQmlInstallDir/"
test -f "$walletQmlInstallDir/qmldir"
'';
};
}

View 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

View File

@ -2,8 +2,7 @@ import QtQuick 2.15
import QtQuick.Layouts 1.15
import Logos.Theme
import "components/wallet"
import Logos.Wallet
// Self-contained navigation bar styling is independent of any view's theme.
// Use currentIndex to read the active tab; tabChanged(index) fires on selection.
@ -94,11 +93,15 @@ Item {
}
// Wallet / account control on the far right.
AccountControl {
WalletControl {
id: accountControl
Layout.leftMargin: 12
backend: root.backend
wallet: root.backend
accountModel: root.accountModel
viewportWidth: root.width
watchCall: function(result, success, failure) {
logos.watch(result, success, failure)
}
}
}
}

View File

@ -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
}
}
}
}
}
}

View File

@ -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 || ""
}
}
}

View File

@ -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
}
}
}
}
}
}

View 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 || "")
}
}

View File

@ -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() }
}
// 0x123456cdef 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) })
}
}
}

View File

@ -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
}
}
}
}

View File

@ -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()
}
}
}
}
}

View File

@ -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()
}
}
}
}

View File

@ -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()
}
}

View File

@ -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
}

View File

@ -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

View File

@ -1,5 +1,6 @@
import QtQuick 2.15
import QtQuick.Layouts 1.15
import Logos.Wallet
import "../components/shared"
import "../components/liquidity"
import "../state"
@ -162,10 +163,18 @@ Item {
}
}
LiquidityConfirmationDialog {
id: confirmationDialog
Component {
id: liquidityConfirmationSummary
anchors.fill: parent
LiquidityConfirmationSummary { }
}
TransactionConfirmationDialog {
id: confirmationDialog
title: snapshot.action === "add"
? qsTr("Confirm add liquidity")
: qsTr("Confirm remove liquidity")
summary: liquidityConfirmationSummary
onConfirmed: function (snapshot) {
root.confirmLiquidityAction(snapshot);

View File

@ -1,9 +1,10 @@
pragma ComponentBehavior: Bound
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import Logos.Wallet
import "../components/shared"
import "../components/swap"
import "../state"
Item {
id: root
@ -25,7 +26,7 @@ Item {
}
QtObject {
id: theme
id: pageTheme
property bool isDark: true
property var colors: isDark ? dark : light
@ -68,7 +69,7 @@ Item {
Rectangle {
anchors.fill: parent
color: theme.colors.background
color: pageTheme.colors.background
Behavior on color { ColorAnimation { duration: 300 } }
// Theme toggle
@ -77,19 +78,19 @@ Item {
anchors.right: parent.right
anchors.margins: 16
width: 44; height: 24; radius: 12
color: theme.colors.panelBg
border.color: theme.colors.border
color: pageTheme.colors.panelBg
border.color: pageTheme.colors.border
border.width: 1
Text {
anchors.centerIn: parent
text: theme.isDark ? "☀" : "☾"
text: pageTheme.isDark ? "☀" : "☾"
font.pixelSize: 13
color: theme.colors.textSecondary
color: pageTheme.colors.textSecondary
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: theme.isDark = !theme.isDark
onClicked: pageTheme.isDark = !pageTheme.isDark
}
}
@ -100,7 +101,7 @@ Item {
SwapCard {
id: swapCard
Layout.alignment: Qt.AlignHCenter
theme: theme
theme: pageTheme
tokens: root.tokens
backend: root.backend
width: Math.min(480, root.width - 32)
@ -126,9 +127,9 @@ Item {
Text {
Layout.alignment: Qt.AlignHCenter
text: "Buy and sell crypto on <font color='" + theme.colors.textPrimary + "'>LEZ</font>."
text: "Buy and sell crypto on <font color='" + pageTheme.colors.textPrimary + "'>LEZ</font>."
textFormat: Text.RichText
color: theme.colors.textSecondary
color: pageTheme.colors.textSecondary
font.pixelSize: 15
horizontalAlignment: Text.AlignHCenter
}
@ -138,7 +139,7 @@ Item {
id: tokenModal
anchors.fill: parent
z: 10
theme: theme
theme: pageTheme
tokens: root.tokens
property string targetSide: "sell"
@ -161,10 +162,19 @@ Item {
}
}
SwapConfirmationDialog {
Component {
id: swapConfirmationSummary
SwapConfirmationSummary {
theme: pageTheme
}
}
TransactionConfirmationDialog {
id: swapConfirmationDialog
anchors.fill: parent
theme: theme
title: qsTr("Confirm swap")
confirmText: qsTr("Confirm swap")
summary: swapConfirmationSummary
onConfirmed: function(snapshot) {
// The dialog only shows a preview snapshot; the actual

View File

@ -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;
}
}
}

View File

@ -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;
};

View File

@ -20,6 +20,8 @@
#include <QTimer>
#include <QUrl>
#include "LogosWalletProvider.h"
#include "WalletController.h"
#include "logos_api.h"
#include "logos_sdk.h"
@ -51,17 +53,6 @@ static bool ammDebugEnabled()
if (ammDebugEnabled()) qWarning().noquote()
namespace {
const char SETTINGS_ORG[] = "Logos";
const char SETTINGS_APP[] = "AmmUI";
// Sticky "user pressed Disconnect" flag so the wallet stays locked across
// relaunches until the user reconnects.
const char DISCONNECTED_KEY[] = "disconnected";
const int WALLET_FFI_SUCCESS = 0;
// Wallet home env override. Mirrors LEZ's own var so the app shares the
// canonical wallet (~/.lee/wallet) used by the wallet UI and other apps.
const char WALLET_HOME_ENV[] = "LEE_WALLET_HOME_DIR";
// Absolute path to the deployed AMM program's RISC Zero program binary
// (amm.bin — the `ProgramBinary` `.bin` from the docker guest build, decoded
// on the Rust side via `ProgramBinary::decode`; NOT a raw ELF — pointing at
@ -77,13 +68,6 @@ namespace {
// doesn't need a hardcoded/dummy token list.
const char TOKENS_CONFIG_ENV[] = "TOKENS_CONFIG";
// Normalise file:// URLs and OS paths to a plain local path.
QString toLocalPath(const QString& path) {
if (path.startsWith("file://") || path.contains("/"))
return QUrl::fromUserInput(path).toLocalFile();
return path;
}
QString bytes32ToHex(const uint8_t (&b)[32]) {
return QString::fromLatin1(QByteArray(reinterpret_cast<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)
: AmmUiBackendSimpleSource(parent),
m_accountModel(new AccountModel(this)),
m_logosAPI(logosAPI ? logosAPI : new LogosAPI("amm_ui", this)),
m_logos(new LogosModules(m_logosAPI)),
m_net(new QNetworkAccessManager(this)),
m_reachabilityTimer(new QTimer(this))
m_logos(std::make_unique<LogosModules>(m_logosAPI)),
m_wallet(std::make_unique<LogosWalletProvider>(m_logosAPI)),
m_walletController(std::make_unique<WalletController>(
*m_wallet, QStringLiteral("AmmUI")))
{
// PROP defaults via the generated setters.
setIsWalletOpen(false);
setLastSyncedBlock(0);
setCurrentBlockHeight(0);
setWalletHome(defaultWalletHome());
// Assume reachable until a probe proves otherwise (avoids a startup flash).
setSequencerReachable(true);
// Periodically re-probe the sequencer so the banner reacts to a node going
// up/down while the app is running. Probes are no-ops until a wallet (and
// thus a sequencer address) is open.
m_reachabilityTimer->setInterval(10000);
connect(m_reachabilityTimer, &QTimer::timeout, this, [this]() { checkReachability(); });
m_reachabilityTimer->start();
// Always resolve against the canonical wallet home (LEE_WALLET_HOME_DIR or
// ~/.lee/wallet). We intentionally don't seed config/storage paths from
// QSettings anymore: a previously-persisted per-app path (~/.lee/amm-wallet)
// would otherwise override the default and pin the app to the old keystore.
// A wallet exists on disk if its storage file is present (drives whether
// the navbar "Connect" reconnects or offers to create a wallet).
const QString effectiveStorage = storagePath().isEmpty() ? defaultStoragePath() : storagePath();
setWalletExists(QFileInfo::exists(effectiveStorage));
// ui-host runs our constructor inside initLogos(), synchronously, BEFORE
// it enables remoting and emits READY. Any blocking RPC here would stall
// ui-host startup past its ready watchdog. Defer the open+refresh chain to
// the first event-loop tick so ui-host finishes wiring itself up first.
QTimer::singleShot(0, this, [this]() { openOrAdoptWallet(); });
// Save wallet on quit; host may not call destructors so this is best-effort.
connect(qApp, &QCoreApplication::aboutToQuit, this,
[this]() { saveWallet(); }, Qt::DirectConnection);
connect(m_walletController.get(), &WalletController::stateChanged,
this, &AmmUiBackend::syncWalletState);
syncWalletState();
m_walletController->start();
}
AmmUiBackend::~AmmUiBackend()
AmmUiBackend::~AmmUiBackend() = default;
WalletAccountModel* AmmUiBackend::accountModel() const
{
saveWallet();
delete m_logos;
}
void AmmUiBackend::openOrAdoptWallet()
{
// Respect an explicit user disconnect: stay locked, show "Connect".
if (QSettings(SETTINGS_ORG, SETTINGS_APP).value(DISCONNECTED_KEY, false).toBool())
return;
// In Basecamp the logos_execution_zone module is a single shared instance,
// so the wallet may already be open (e.g. opened by the dedicated wallet
// app). Adopt that wallet instead of fighting over it: mirror its state
// rather than re-opening from disk, which could clobber unsaved in-memory
// accounts the other app holds. A freshly-created shared wallet can be open
// with zero accounts, so we can't key off list_accounts() alone (see
// sharedWalletIsOpen).
if (sharedWalletIsOpen()) {
const QJsonArray existing = QJsonArray::fromVariantList(m_logos->logos_execution_zone.list_accounts());
qDebug() << "AmmUiBackend: adopting already-open shared wallet"
<< existing.size() << "accounts";
setIsWalletOpen(true);
m_accountModel->replaceFromJsonArray(existing);
refreshBalances();
refreshSequencerAddr();
return;
}
// Standalone (own core instance): auto-open a previously-created wallet.
// Use persisted paths if the user picked custom ones, else the per-app
// default. Only open if the storage actually exists, otherwise stay closed
// so QML shows the "Connect" entry point (no noisy FFI errors on first run).
const QString cfg = configPath().isEmpty() ? defaultConfigPath() : configPath();
const QString stg = storagePath().isEmpty() ? defaultStoragePath() : storagePath();
if (!QFileInfo::exists(stg))
return; // No wallet yet — QML shows "Connect".
qDebug() << "AmmUiBackend: opening wallet with config" << cfg << "storage" << stg;
const int err = m_logos->logos_execution_zone.open(cfg, stg);
if (err == WALLET_FFI_SUCCESS) {
persistConfigPath(cfg);
persistStoragePath(stg);
setIsWalletOpen(true);
refreshAccounts();
refreshBlockHeights();
refreshSequencerAddr();
} else {
qWarning() << "AmmUiBackend: wallet open failed, code" << err;
}
}
bool AmmUiBackend::sharedWalletIsOpen()
{
// Treat the shared core as "already open" ONLY when it actually holds
// accounts. We used to also treat a non-empty sequencer address as proof of
// an open wallet ("a closed core returns an empty string"), but the wallet
// module now reports a DEFAULT sequencer (e.g. http://localhost:…) even on a
// CLOSED core. That made standalone launches wrongly take the adopt path —
// mirroring an empty account list and returning early — instead of opening
// the real on-disk wallet, so the user saw no accounts. Keying off accounts
// alone means a genuinely-open shared wallet (Basecamp) is still adopted,
// while a closed standalone core correctly falls through to open-from-disk.
// Tradeoff: a shared wallet that is open but holds ZERO accounts is treated
// as closed here, so it won't be adopted — an accepted edge, preferable to
// the default-sequencer false-positive that keying off the address caused.
return !QJsonArray::fromVariantList(m_logos->logos_execution_zone.list_accounts()).isEmpty();
}
QString AmmUiBackend::createNewDefault(QString password)
{
QDir().mkpath(defaultWalletHome());
return createNew(defaultConfigPath(), defaultStoragePath(), password);
}
QString AmmUiBackend::createNew(QString configPath, QString storagePath, QString password)
{
const QString localConfig = toLocalPath(configPath);
const QString localStorage = toLocalPath(storagePath);
// create_new returns the new wallet's BIP39 mnemonic (empty on failure). We
// hand it back to the caller instead of discarding it: wallet creation is
// the only moment the seed phrase is recoverable, so the UI must force a
// backup step before the user can proceed.
const QString mnemonic = m_logos->logos_execution_zone.create_new(localConfig, localStorage, password);
if (mnemonic.isEmpty()) {
qWarning() << "AmmUiBackend: create_new failed (empty mnemonic)";
return QString();
}
persistConfigPath(localConfig);
persistStoragePath(localStorage);
setWalletExists(true);
QSettings(SETTINGS_ORG, SETTINGS_APP).setValue(DISCONNECTED_KEY, false);
setIsWalletOpen(true);
refreshAccounts();
refreshBlockHeights();
refreshSequencerAddr();
return mnemonic;
}
bool AmmUiBackend::openExisting()
{
// Adopt a shared open wallet (Basecamp), else open our own from disk. A
// freshly-created shared wallet can be open with zero accounts, so probe
// open-ness rather than keying off list_accounts() alone.
if (sharedWalletIsOpen()) {
const QJsonArray existing = QJsonArray::fromVariantList(m_logos->logos_execution_zone.list_accounts());
setIsWalletOpen(true);
m_accountModel->replaceFromJsonArray(existing);
refreshBalances();
refreshSequencerAddr();
QSettings(SETTINGS_ORG, SETTINGS_APP).setValue(DISCONNECTED_KEY, false);
return true;
}
const QString cfg = configPath().isEmpty() ? defaultConfigPath() : configPath();
const QString stg = storagePath().isEmpty() ? defaultStoragePath() : storagePath();
if (!QFileInfo::exists(stg))
return false;
const int err = m_logos->logos_execution_zone.open(cfg, stg);
if (err != WALLET_FFI_SUCCESS) {
qWarning() << "AmmUiBackend: openExisting failed, code" << err;
return false;
}
persistConfigPath(cfg);
persistStoragePath(stg);
setIsWalletOpen(true);
QSettings(SETTINGS_ORG, SETTINGS_APP).setValue(DISCONNECTED_KEY, false);
refreshAccounts();
refreshBlockHeights();
refreshSequencerAddr();
return true;
}
void AmmUiBackend::disconnectWallet()
{
// UI-local lock: persist wallet state, drop our view of it, and remember
// the choice. We do NOT close the core module's wallet handle — in Basecamp
// that instance is shared with other apps.
saveWallet();
setIsWalletOpen(false);
m_accountModel->replaceFromJsonArray(QJsonArray());
QSettings(SETTINGS_ORG, SETTINGS_APP).setValue(DISCONNECTED_KEY, true);
return m_walletController->accountModel();
}
QString AmmUiBackend::createAccountPublic()
{
const QString result = m_logos->logos_execution_zone.create_account_public();
if (!result.isEmpty())
refreshAccounts();
return result;
return m_walletController->createAccount(true);
}
QString AmmUiBackend::createAccountPrivate()
{
const QString result = m_logos->logos_execution_zone.create_account_private();
if (!result.isEmpty())
refreshAccounts();
return result;
return m_walletController->createAccount(false);
}
void AmmUiBackend::refreshAccounts()
{
const QJsonArray arr = QJsonArray::fromVariantList(m_logos->logos_execution_zone.list_accounts());
m_accountModel->replaceFromJsonArray(arr);
refreshBalances();
m_walletController->refresh();
}
void AmmUiBackend::refreshBalances()
{
refreshBlockHeights();
if (currentBlockHeight() > 0)
m_logos->logos_execution_zone.sync_to_block(static_cast<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();
m_walletController->refresh();
}
QString AmmUiBackend::getBalance(QString accountIdHex, bool isPublic)
{
return m_logos->logos_execution_zone.get_balance(accountIdHex, isPublic);
return m_walletController->balance(accountIdHex, isPublic);
}
void AmmUiBackend::refreshBlockHeights()
QString AmmUiBackend::createNewDefault(QString password)
{
const int lastVal = m_logos->logos_execution_zone.get_last_synced_block();
const int currentVal = m_logos->logos_execution_zone.get_current_block_height();
if (lastSyncedBlock() != lastVal)
setLastSyncedBlock(lastVal);
if (currentBlockHeight() != currentVal)
setCurrentBlockHeight(currentVal);
return m_walletController->createDefaultWallet(password);
}
void AmmUiBackend::refreshSequencerAddr()
QString AmmUiBackend::createNew(QString configPath,
QString storagePath,
QString password)
{
const QString addr = m_logos->logos_execution_zone.get_sequencer_addr();
if (sequencerAddr() != addr)
setSequencerAddr(addr);
// Probe right away so the banner reflects the (possibly new) endpoint
// without waiting for the next periodic tick.
checkReachability();
return m_walletController->createWallet(configPath, storagePath, password);
}
void AmmUiBackend::checkReachability()
bool AmmUiBackend::openExisting()
{
const QString addr = sequencerAddr();
if (addr.isEmpty())
return;
QNetworkRequest req{QUrl(addr)};
req.setTransferTimeout(4000);
QNetworkReply* reply = m_net->get(req);
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
// Any HTTP response (even a 404) means the node is up; only a transport
// failure (connection refused, host not found, timeout) counts as down.
const bool gotHttpStatus =
reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).isValid();
const bool reachable = gotHttpStatus || reply->error() == QNetworkReply::NoError;
if (sequencerReachable() != reachable)
setSequencerReachable(reachable);
reply->deleteLater();
});
return m_walletController->open();
}
void AmmUiBackend::saveWallet()
void AmmUiBackend::disconnectWallet()
{
if (isWalletOpen())
m_logos->logos_execution_zone.save();
m_walletController->disconnect();
}
// These only update the in-session PROPs (so subsequent open/refresh calls
// reuse the same path). They are no longer written to QSettings: the app
// always resolves against the canonical wallet home, so there's nothing to
// remember across launches.
void AmmUiBackend::persistConfigPath(const QString& path)
void AmmUiBackend::syncWalletState()
{
setConfigPath(toLocalPath(path));
}
void AmmUiBackend::persistStoragePath(const QString& path)
{
setStoragePath(toLocalPath(path));
}
bool AmmUiBackend::changeSequencerAddr(QString url)
{
const QString trimmed = url.trimmed();
if (trimmed.isEmpty()) {
qWarning() << "AmmUiBackend: refusing to set empty sequencer_addr";
return false;
}
const QString cfg = configPath().isEmpty() ? defaultConfigPath() : configPath();
// Preserve the other config fields (poll timeouts, retries) — only swap the
// endpoint. The wallet reads this file on open via from_path_or_initialize_default.
QJsonObject obj;
QFile in(cfg);
if (in.open(QIODevice::ReadOnly)) {
obj = QJsonDocument::fromJson(in.readAll()).object();
in.close();
}
obj.insert(QStringLiteral("sequencer_addr"), trimmed);
QFile out(cfg);
if (!out.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
qWarning() << "AmmUiBackend: cannot write wallet config" << cfg;
return false;
}
out.write(QJsonDocument(obj).toJson(QJsonDocument::Indented));
out.close();
// Re-open so the live wallet uses the new endpoint right away.
if (isWalletOpen()) {
const QString stg = storagePath().isEmpty() ? defaultStoragePath() : storagePath();
const int err = m_logos->logos_execution_zone.open(cfg, stg);
if (err != WALLET_FFI_SUCCESS) {
qWarning() << "AmmUiBackend: reopen after sequencer change failed, code" << err;
return false;
}
refreshSequencerAddr();
refreshAccounts();
}
return true;
}
void AmmUiBackend::copyToClipboard(QString text)
{
if (QGuiApplication::clipboard())
QGuiApplication::clipboard()->setText(text);
const WalletUiState& state = m_walletController->state();
setIsWalletOpen(state.isWalletOpen);
setWalletExists(state.walletExists);
setConfigPath(state.configPath);
setStoragePath(state.storagePath);
setWalletHome(state.walletHome);
setLastSyncedBlock(state.lastSyncedBlock);
setCurrentBlockHeight(state.currentBlockHeight);
setSequencerAddr(state.sequencerAddress);
setSequencerReachable(state.sequencerReachable);
}
QString AmmUiBackend::normalizeAccountId(const QString& id)

View File

@ -1,14 +1,15 @@
#ifndef AMM_UI_BACKEND_H
#define AMM_UI_BACKEND_H
#include <QObject>
#include <memory>
#include <QString>
#include <QVariantList>
#include <QVariantMap>
#include "rep_AmmUiBackend_source.h"
#include "AccountModel.h"
#include "WalletAccountModel.h"
extern "C" {
#include "amm_client_ffi.h"
@ -16,38 +17,29 @@ extern "C" {
class LogosAPI;
struct LogosModules;
class QNetworkAccessManager;
class QTimer;
class LogosWalletProvider;
class WalletController;
// Source-side implementation of the AmmUiBackend .rep interface.
// Inheriting from AmmUiBackendSimpleSource gives us the generated PROPs and
// SLOTs from AmmUiBackend.rep — all the simple ones flow over QtRO. Talks to
// the core logos_execution_zone wallet module via LogosModules.
class AmmUiBackend : public AmmUiBackendSimpleSource {
Q_OBJECT
Q_PROPERTY(AccountModel* accountModel READ accountModel CONSTANT)
Q_PROPERTY(WalletAccountModel* accountModel READ accountModel CONSTANT)
public:
explicit AmmUiBackend(LogosAPI* logosAPI = nullptr, QObject* parent = nullptr);
~AmmUiBackend() override;
AccountModel* accountModel() const { return m_accountModel; }
WalletAccountModel* accountModel() const;
public slots:
// Overrides of the pure-virtual slots generated from the .rep.
QString createAccountPublic() override;
QString createAccountPrivate() override;
void refreshAccounts() override;
void refreshBalances() override;
QString getBalance(QString accountIdHex, bool isPublic) override;
// Return the new wallet's BIP39 mnemonic (empty string on failure) so the
// UI can force a one-time seed-phrase backup step.
QString createNewDefault(QString password) override;
QString createNew(QString configPath, QString storagePath, QString password) override;
bool openExisting() override;
void disconnectWallet() override;
bool changeSequencerAddr(QString url) override;
void copyToClipboard(QString text) override;
// AMM
QVariantMap resolvePool(QString defAHex, QString defBHex) override;
@ -59,43 +51,27 @@ public slots:
QVariantList tokenList() override;
private:
// Per-app wallet home (kept distinct from the wallet's canonical
// ~/.lee/wallet so standalone instances stay isolated; Basecamp sharing
// is handled by adopting an already-open shared wallet on startup).
static QString defaultWalletHome();
QString defaultConfigPath() const;
QString defaultStoragePath() const;
void syncWalletState();
void persistConfigPath(const QString& path);
void persistStoragePath(const QString& path);
// Normalizes an account id given as either 64-char lowercase/uppercase hex
// or base58 to lowercase hex. Returns an empty QString if `id` is neither
// (or the base58 decode fails), so callers can detect and skip it.
QString normalizeAccountId(const QString& id);
void openOrAdoptWallet();
// True when the shared core already has a wallet open — including a freshly
// created one with zero accounts. See the definition for why list_accounts()
// alone is insufficient.
bool sharedWalletIsOpen();
void refreshBlockHeights();
void refreshSequencerAddr();
void saveWallet();
// Returns the deployed AMM program-binary bytes (a RISC Zero ProgramBinary
// .bin, not a raw ELF) from $AMM_PROGRAM_BIN, or an empty QByteArray (with a
// qWarning) if the env var is unset/unreadable/empty.
QByteArray loadAmmElf();
// Probe the configured sequencer over HTTP and update sequencerReachable.
void checkReachability();
AccountModel* m_accountModel;
LogosAPI* m_logosAPI;
LogosModules* m_logos;
QNetworkAccessManager* m_net;
QTimer* m_reachabilityTimer;
// Direct module handle for the AMM/swap path (resolvePool/swapExactInput/
// tokenList). The shared wallet provider exposes only wallet-level ops, not
// the raw account-id / get_account_public / send_generic_public_transaction
// calls the AMM path needs, so keep a thin LogosModules over the same
// LogosAPI as the wallet provider.
std::unique_ptr<LogosModules> m_logos;
std::unique_ptr<LogosWalletProvider> m_wallet;
std::unique_ptr<WalletController> m_walletController;
};
#endif // AMM_UI_BACKEND_H

View File

@ -37,13 +37,6 @@ class AmmUiBackend
// Basecamp, does not close the wallet other apps share.
SLOT(void disconnectWallet())
// Settings. Rewrites the wallet config's sequencer_addr and re-opens the
// wallet so the new network takes effect immediately.
SLOT(bool changeSequencerAddr(QString url))
// Misc
SLOT(void copyToClipboard(QString text))
// AMM
// Derives the AMM pool's PDAs (config/pool/vaults/current-tick) from the
// deployed AMM program binary (see AMM_PROGRAM_BIN — a RISC Zero

View 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()

View 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()
}
}
}
}
}

View 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
}
}
}
}
}

View 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"
}
}

View 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)
}
}
}
}

View 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()
}
}

View 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)
}
}
}
}

View 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()
}
}
}
}

View 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"
}
}

View 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()
}
}
}

View File

Before

Width:  |  Height:  |  Size: 253 B

After

Width:  |  Height:  |  Size: 253 B

View File

Before

Width:  |  Height:  |  Size: 206 B

After

Width:  |  Height:  |  Size: 206 B

View File

Before

Width:  |  Height:  |  Size: 463 B

After

Width:  |  Height:  |  Size: 463 B

View File

Before

Width:  |  Height:  |  Size: 977 B

After

Width:  |  Height:  |  Size: 977 B

View File

Before

Width:  |  Height:  |  Size: 237 B

After

Width:  |  Height:  |  Size: 237 B

View 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;
}

View 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;
};

View 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();
}

View 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;
};

View 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();
});
}

View 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;
};

View 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");
}

View 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;
};

View 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"

View 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;
};

View File

@ -0,0 +1,3 @@
#include <QtQuickTest/quicktest.h>
QUICK_TEST_MAIN(logos_wallet_qml)

View 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)
}
}
}

View File

@ -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 }
}

View 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))
}
}
}

View 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; }
};

View File

@ -118,6 +118,27 @@
externalLibInputs = {
amm_client_ffi = { input = self; packages.default = "amm_client_ffi"; };
};
# The AMM UI links the shared C++ wallet access lib and bundles the
# Logos.Wallet QML module (apps/shared/wallet). apps/amm/flake.nix wires
# these via its `shared_wallet` input; when built from this root flake
# the source lives in-tree, so point CMake straight at it and stage the
# built QML module the same way. Keep in sync with apps/amm/flake.nix.
preConfigure = ''
cmakeFlagsArray+=("-DLOGOS_WALLET_SOURCE_DIR=${./apps/shared/wallet}")
'';
postInstall = ''
test -f ${./apps/amm/qml}/Logos/Wallet/qmldir
walletQmlDir="shared-wallet/qml/Logos/Wallet"
if [ ! -d "$walletQmlDir" ]; then
echo "Built Logos.Wallet QML module not found"
exit 1
fi
walletQmlInstallDir="$out/lib/Logos/Wallet"
mkdir -p "$walletQmlInstallDir"
cp -r "$walletQmlDir/." "$walletQmlInstallDir/"
test -f "$walletQmlInstallDir/qmldir"
'';
};
# Expose the AMM QML UI as a NAMED app/package (`amm-ui`) rather than