feat(amm): wire the AMM app to the LEZ wallet module

Turns the dummy-data AMM UI into a real client of the on-chain LEZ wallet.
Adds a hand-written ui_qml C++ backend (src/AmmUi*) over the core
logos_execution_zone module: create/open a local wallet, create and list
public/private accounts, and a navbar Connect / Connected + account-selector
+ Disconnect flow. Onboarding is password-only (no path picking) with a
per-app wallet at ~/.lee/amm-wallet (override: AMM_WALLET_HOME_DIR);
standalone gets its own wallet, Basecamp shares accounts via adopt-on-start.

Requires Nix with flakes; macOS also needs `sandbox = false` (the default).
The logos_execution_zone input is pinned to a module rev whose LEZ (lssa)
already includes the macOS Metal-build fix, so no `--override-input` is
needed — plain `nix run .` works:

    cd apps/amm
    nix run .

- create_new now returns the new wallet's BIP39 mnemonic (not an int status);
  the app currently discards it, so the wallet can't yet be recovered. Surfacing
  it in onboarding (+ restore_storage) is a follow-up.
- The wallet password is currently a no-op upstream (storage.rs: "TODO: use
  password for storage encryption"); storage.json is plaintext. So Disconnect
  is a UI-level lock and reconnect does not (cannot yet) re-prompt for it.
- wallet-ffi requires explicit config/storage paths; a *_default() FFI would
  let the app drop its path handling.
- Bundled network config: connects to whatever WalletConfig::default() points
  at; real testnet endpoints still TBD.
This commit is contained in:
r4bbit 2026-06-24 14:50:17 +02:00
parent a26debd592
commit 751d4ac530
28 changed files with 26278 additions and 158 deletions

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
target/
*.bin
**/*/result

29
apps/amm/CMakeLists.txt Normal file
View File

@ -0,0 +1,29 @@
cmake_minimum_required(VERSION 3.14)
project(AmmUiPlugin LANGUAGES CXX)
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()
# ui_qml module with a hand-written C++ backend (QtRO .rep view contract +
# generated *SimpleSource/*ViewPluginBase). Mirrors the LEZ wallet UI module.
logos_module(
NAME amm_ui
REP_FILE src/AmmUiBackend.rep
SOURCES
src/AmmUiPluginInterface.h
src/AmmUiPlugin.h
src/AmmUiPlugin.cpp
src/AmmUiBackend.h
src/AmmUiBackend.cpp
src/AccountModel.h
src/AccountModel.cpp
FIND_PACKAGES
Qt6Gui
Qt6Network
LINK_LIBRARIES
Qt6::Gui
Qt6::Network
)

View File

@ -4,6 +4,34 @@ A QML UI application for the Automated Market Maker (AMM) program.
See the [Logos QML UI App Tutorial](https://github.com/logos-co/logos-tutorial/blob/master/tutorial-qml-ui-app.md) for more information.
## Wallet / chain integration
This app is a `ui_qml` module with a hand-written C++ backend
(`src/AmmUiBackend.*`, plugin in `src/AmmUiPlugin.*`) that depends on the core
**`logos_execution_zone`** wallet module. The backend calls the core module's
wallet FFI through `m_logos->logos_execution_zone.*` and exposes an async QtRO
surface (`src/AmmUiBackend.rep`) plus an account list model to the QML view.
**Onboarding is non-invasive.** The app opens straight to the Trade screen; the
navbar shows **Connect** (opens a password-only modal) or **Connected** + the
account selector. There is no path picking — the wallet uses LEZ's canonical
home, `~/.lee/wallet/` (override with `LEE_WALLET_HOME_DIR`, the same var LEZ
honors), and its config (`wallet_config.json`) self-initializes.
Account/keystore sharing follows the runtime:
- **Standalone** (`nix run .`): own core-module instance, but the canonical
`~/.lee/wallet` keystore is shared with the LEZ wallet UI and any other LEZ
app on the machine. A previously-created wallet auto-opens on launch.
- **Inside Basecamp**: the core wallet module is a single shared instance, so on
startup the backend **adopts** the already-open wallet (see
`openOrAdoptWallet()`), surfacing **shared** accounts across apps.
> Follow-up: the wallet FFI requires explicit `config_path`/`storage_path` even
> though the wallet crate already defines defaults (`~/.lee/wallet`,
> `from_path_or_initialize_default`). A `wallet_ffi_create_new_default()` /
> `_open_default()` upstream would let the app drop its path handling entirely.
## Setup
This project requires Nix with experimental features enabled. If you haven't already, enable them permanently:

24599
apps/amm/flake.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,14 @@
{
description = "Logos QML UI Module replace with your description";
description = "Logos AMM QML UI trade and provide liquidity on the LEZ AMM";
inputs = {
logos-module-builder.url = "github:logos-co/logos-module-builder";
# 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";
};
outputs = inputs@{ logos-module-builder, ... }:

View File

@ -4,14 +4,15 @@
"type": "ui_qml",
"category": "amm",
"description": "UI module for the AMM program",
"main": "amm_ui_plugin",
"view": "qml/Main.qml",
"icon": "icons/amm.png",
"dependencies": [],
"dependencies": ["logos_execution_zone"],
"nix": {
"packages": {
"build": [],
"runtime": []
"runtime": ["qt6.qtdeclarative", "zstd", "krb5", "abseil-cpp"]
},
"external_libraries": [],
"cmake": {

View File

@ -1,17 +1,77 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import Logos.Theme
import "pages"
Item {
id: root
NavBar {
id: navbar
// Backend replica + account model, bridged from the C++ backend.
readonly property var backend: logos.module("amm_ui")
readonly property var accountModel: logos.model("amm_ui", "accountModel")
property bool ready: false
Connections {
target: logos
function onViewModuleReadyChanged(moduleName, isReady) {
if (moduleName === "amm_ui")
root.ready = isReady && root.backend !== null
}
}
Component.onCompleted: {
root.ready = root.backend !== null && logos.isViewModuleReady("amm_ui")
}
// Connectivity banner: shown when a wallet is open but its configured
// sequencer doesn't answer reachability probes (so transactions will fail).
Rectangle {
id: connectionBanner
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
z: 101
readonly property bool show: root.ready
&& root.backend
&& root.backend.isWalletOpen
&& root.backend.sequencerAddr.length > 0
&& !root.backend.sequencerReachable
height: show ? 32 : 0
visible: height > 0
clip: true
color: Theme.palette.warning
Behavior on height { NumberAnimation { duration: 150; easing.type: Easing.OutCubic } }
Text {
anchors.centerIn: parent
width: parent.width - 40
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideMiddle
font.pixelSize: 12
font.weight: Font.Medium
color: Theme.palette.background
text: qsTr("Unable to connect to network")
}
}
// The app is always usable; the wallet is opt-in via the navbar "Connect"
// control. Trade/Liquidity render immediately on launch.
NavBar {
id: navbar
anchors.top: connectionBanner.bottom
anchors.left: parent.left
anchors.right: parent.right
z: 100
backend: root.ready ? root.backend : null
accountModel: root.accountModel
}
Item {

View File

@ -1,6 +1,10 @@
import QtQuick 2.15
import QtQuick.Layouts 1.15
import Logos.Theme
import "components/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.
Item {
@ -9,13 +13,20 @@ Item {
property int currentIndex: 0
readonly property var tabs: ["Trade", "Liquidity"]
// Wallet wiring, passed down from Main.qml.
property var backend: null
property var accountModel: null
// Address of the account currently selected in the header control.
readonly property string selectedAddress: accountControl.selectedAddress
signal tabChanged(int index)
implicitHeight: 56
Rectangle {
anchors.fill: parent
color: "#ffffff"
color: Theme.palette.background
// Bottom separator
Rectangle {
@ -23,7 +34,7 @@ Item {
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 1
color: Qt.rgba(0, 0, 0, 0.08)
color: Theme.palette.borderSecondary
}
RowLayout {
@ -35,7 +46,7 @@ Item {
// App identity
Text {
text: "Logos AMM"
color: "#111111"
color: Theme.palette.text
font.pixelSize: 17
font.weight: Font.Bold
}
@ -55,7 +66,7 @@ Item {
height: 36
width: tabLabel.implicitWidth + 28
radius: 18
color: active ? "#111111" : "transparent"
color: active ? Theme.palette.backgroundSecondary : "transparent"
Behavior on color { ColorAnimation { duration: 150 } }
@ -63,7 +74,7 @@ Item {
id: tabLabel
anchors.centerIn: parent
text: modelData
color: active ? "#ffffff" : "#666666"
color: active ? Theme.palette.text : Theme.palette.textSecondary
font.pixelSize: 14
font.weight: active ? Font.Medium : Font.Normal
@ -81,6 +92,14 @@ Item {
}
}
}
// Wallet / account control on the far right.
AccountControl {
id: accountControl
Layout.leftMargin: 12
backend: root.backend
accountModel: root.accountModel
}
}
}
}

View File

@ -0,0 +1,490 @@
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

@ -0,0 +1,84 @@
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

@ -0,0 +1,102 @@
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

@ -0,0 +1,201 @@
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

@ -0,0 +1,39 @@
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

@ -0,0 +1,25 @@
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

@ -0,0 +1 @@
<svg fill="none" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8z" fill="#000"/><path d="M4 20c0-3.866 3.582-6 8-6s8 2.134 8 6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1z" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 253 B

View File

@ -0,0 +1 @@
<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"><path d="M15 5l-7 7 7 7"/></svg>

After

Width:  |  Height:  |  Size: 206 B

View File

@ -0,0 +1 @@
<svg fill="none" height="17" viewBox="0 0 16 17" width="16" xmlns="http://www.w3.org/2000/svg"><path clip-rule="evenodd" d="m6.76413 10.1901 5.56067-5.41122c.382-.37171 1-.37297 1.3863.00299.3837.37336.3857.97674.0031 1.34906l-6.25845 6.09017c-.18993.1849-.43822.2781-.68737.2789-.25655-.0019-.50554-.0937-.69254-.2756l-2.79161-2.7166c-.38013-.36991-.37994-.96985.00641-1.34581.38367-.37336 1.00799-.37115 1.38299-.00623z" fill="#000" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 463 B

View File

@ -0,0 +1 @@
<svg fill="none" height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg"><g fill="#000"><path d="m4.16634 7c.27614 0 .5-.22386.5-.5s-.22386-.5-.5-.5h-.16667c-1.47275 0-2.66666 1.19391-2.66666 2.66667v3.33333c0 1.4728 1.19391 2.6667 2.66666 2.6667h3.33334c1.47276 0 2.66666-1.1939 2.66666-2.6667v-.1667c0-.2761-.22385-.5-.5-.5-.27614 0-.5.2239-.5.5v.1667c0 .9205-.74619 1.6667-1.66666 1.6667h-3.33334c-.92047 0-1.66666-.7462-1.66666-1.6667v-3.33333c0-.92048.74619-1.66667 1.66666-1.66667z"/><path clip-rule="evenodd" d="m5.99967 4c0-1.47276 1.19391-2.66666 2.66667-2.66666h3.33336c1.4727 0 2.6666 1.1939 2.6666 2.66666v3.33334c0 1.47276-1.1939 2.66666-2.6666 2.66666h-3.33336c-1.47276 0-2.66667-1.1939-2.66667-2.66666zm2.66667-1.66666h3.33336c.9204 0 1.6666.74619 1.6666 1.66666v3.33334c0 .92047-.7462 1.66666-1.6666 1.66666h-3.33336c-.92047 0-1.66667-.74619-1.66667-1.66666v-3.33334c0-.92047.7462-1.66666 1.66667-1.66666z" fill-rule="evenodd"/></g></svg>

After

Width:  |  Height:  |  Size: 977 B

View File

@ -0,0 +1 @@
<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"><path d="M12 3v9"/><path d="M7.05 7.05a7 7 0 1 0 9.9 0"/></svg>

After

Width:  |  Height:  |  Size: 237 B

View File

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 845 B

View File

@ -0,0 +1,79 @@
#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

@ -0,0 +1,47 @@
#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

@ -0,0 +1,401 @@
#include "AmmUiBackend.h"
#include <QClipboard>
#include <QCoreApplication>
#include <QDebug>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QGuiApplication>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QSettings>
#include <QTimer>
#include <QUrl>
#include "logos_api.h"
#include "logos_sdk.h"
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";
// 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 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))
{
// 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);
}
AmmUiBackend::~AmmUiBackend()
{
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()
{
// list_accounts() is non-empty only once the wallet holds accounts, so it
// can't distinguish "no wallet open" from "open but empty" (a wallet that
// was just created and hasn't had an account added yet). Fall back to a
// handle-dependent, account-independent signal: an open wallet always has a
// sequencer address (from its config, defaulted on open), while a closed
// core returns an empty string. This lets us adopt a freshly-created shared
// wallet instead of falling through and re-opening it from disk.
if (!QJsonArray::fromVariantList(m_logos->logos_execution_zone.list_accounts()).isEmpty())
return true;
return !m_logos->logos_execution_zone.get_sequencer_addr().isEmpty();
}
QString AmmUiBackend::createNewDefault(QString password)
{
QDir().mkpath(defaultWalletHome());
return createNew(defaultConfigPath(), defaultStoragePath(), password);
}
QString AmmUiBackend::createNew(QString configPath, QString storagePath, QString password)
{
const QString localConfig = toLocalPath(configPath);
const QString localStorage = toLocalPath(storagePath);
// create_new returns the new wallet's BIP39 mnemonic (empty on failure). We
// hand it back to the caller instead of discarding it: wallet creation is
// the only moment the seed phrase is recoverable, so the UI must force a
// backup step before the user can proceed.
const QString mnemonic = m_logos->logos_execution_zone.create_new(localConfig, localStorage, password);
if (mnemonic.isEmpty()) {
qWarning() << "AmmUiBackend: create_new failed (empty mnemonic)";
return QString();
}
persistConfigPath(localConfig);
persistStoragePath(localStorage);
setWalletExists(true);
QSettings(SETTINGS_ORG, SETTINGS_APP).setValue(DISCONNECTED_KEY, false);
setIsWalletOpen(true);
refreshAccounts();
refreshBlockHeights();
refreshSequencerAddr();
return mnemonic;
}
bool AmmUiBackend::openExisting()
{
// Adopt a shared open wallet (Basecamp), else open our own from disk. A
// freshly-created shared wallet can be open with zero accounts, so probe
// open-ness rather than keying off list_accounts() alone.
if (sharedWalletIsOpen()) {
const QJsonArray existing = QJsonArray::fromVariantList(m_logos->logos_execution_zone.list_accounts());
setIsWalletOpen(true);
m_accountModel->replaceFromJsonArray(existing);
refreshBalances();
refreshSequencerAddr();
QSettings(SETTINGS_ORG, SETTINGS_APP).setValue(DISCONNECTED_KEY, false);
return true;
}
const QString cfg = configPath().isEmpty() ? defaultConfigPath() : configPath();
const QString stg = storagePath().isEmpty() ? defaultStoragePath() : storagePath();
if (!QFileInfo::exists(stg))
return false;
const int err = m_logos->logos_execution_zone.open(cfg, stg);
if (err != WALLET_FFI_SUCCESS) {
qWarning() << "AmmUiBackend: openExisting failed, code" << err;
return false;
}
persistConfigPath(cfg);
persistStoragePath(stg);
setIsWalletOpen(true);
QSettings(SETTINGS_ORG, SETTINGS_APP).setValue(DISCONNECTED_KEY, false);
refreshAccounts();
refreshBlockHeights();
refreshSequencerAddr();
return true;
}
void AmmUiBackend::disconnectWallet()
{
// UI-local lock: persist wallet state, drop our view of it, and remember
// the choice. We do NOT close the core module's wallet handle — in Basecamp
// that instance is shared with other apps.
saveWallet();
setIsWalletOpen(false);
m_accountModel->replaceFromJsonArray(QJsonArray());
QSettings(SETTINGS_ORG, SETTINGS_APP).setValue(DISCONNECTED_KEY, true);
}
QString AmmUiBackend::createAccountPublic()
{
const QString result = m_logos->logos_execution_zone.create_account_public();
if (!result.isEmpty())
refreshAccounts();
return result;
}
QString AmmUiBackend::createAccountPrivate()
{
const QString result = m_logos->logos_execution_zone.create_account_private();
if (!result.isEmpty())
refreshAccounts();
return result;
}
void AmmUiBackend::refreshAccounts()
{
const QJsonArray arr = QJsonArray::fromVariantList(m_logos->logos_execution_zone.list_accounts());
m_accountModel->replaceFromJsonArray(arr);
refreshBalances();
}
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();
}
QString AmmUiBackend::getBalance(QString accountIdHex, bool isPublic)
{
return m_logos->logos_execution_zone.get_balance(accountIdHex, isPublic);
}
void AmmUiBackend::refreshBlockHeights()
{
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);
}
void AmmUiBackend::refreshSequencerAddr()
{
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();
}
void AmmUiBackend::checkReachability()
{
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();
});
}
void AmmUiBackend::saveWallet()
{
if (isWalletOpen())
m_logos->logos_execution_zone.save();
}
// 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)
{
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);
}

View File

@ -0,0 +1,77 @@
#ifndef AMM_UI_BACKEND_H
#define AMM_UI_BACKEND_H
#include <QObject>
#include <QString>
#include "rep_AmmUiBackend_source.h"
#include "AccountModel.h"
class LogosAPI;
struct LogosModules;
class QNetworkAccessManager;
class QTimer;
// 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)
public:
explicit AmmUiBackend(LogosAPI* logosAPI = nullptr, QObject* parent = nullptr);
~AmmUiBackend() override;
AccountModel* accountModel() const { return m_accountModel; }
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;
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 persistConfigPath(const QString& path);
void persistStoragePath(const QString& path);
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();
// 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;
};
#endif // AMM_UI_BACKEND_H

View File

@ -0,0 +1,46 @@
// QtRO view contract for the AMM UI backend. PROPs auto-sync to every QML
// replica; SLOTs are the async surface QML calls via logos.watch(...).
// The account list is exposed separately as a Q_PROPERTY model on the backend
// (reached from QML via logos.model("amm_ui", "accountModel")).
class AmmUiBackend
{
PROP(bool isWalletOpen READONLY)
PROP(bool walletExists READONLY)
PROP(QString configPath READONLY)
PROP(QString storagePath READONLY)
PROP(QString walletHome READONLY)
PROP(int lastSyncedBlock READONLY)
PROP(int currentBlockHeight READONLY)
PROP(QString sequencerAddr READONLY)
// Whether the configured sequencer answered the last reachability probe.
// Defaults true so the UI doesn't flash a warning before the first check.
PROP(bool sequencerReachable READONLY)
// Account management
SLOT(QString createAccountPublic())
SLOT(QString createAccountPrivate())
SLOT(void refreshAccounts())
SLOT(void refreshBalances())
SLOT(QString getBalance(QString accountIdHex, bool isPublic))
// Wallet lifecycle. createNewDefault() is the happy path: it creates a
// fresh per-app wallet at walletHome with no path picking. createNew()
// keeps explicit paths for an "advanced" flow. Both return the new wallet's
// BIP39 mnemonic (empty on failure) so the UI can force a seed-phrase backup
// before the wallet is usable — this is the only chance to record it.
SLOT(QString createNewDefault(QString password))
SLOT(QString createNew(QString configPath, QString storagePath, QString password))
// Re-open the existing on-disk wallet after a disconnect.
SLOT(bool openExisting())
// Close this app's wallet view (lock); does not delete the wallet and, in
// 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))
}

View File

@ -0,0 +1,19 @@
#include "AmmUiPlugin.h"
#include "AmmUiBackend.h"
#include <QDebug>
AmmUiPlugin::AmmUiPlugin(QObject* parent)
: QObject(parent)
{
}
AmmUiPlugin::~AmmUiPlugin() = default;
void AmmUiPlugin::initLogos(LogosAPI* api)
{
if (m_backend) return;
m_backend = new AmmUiBackend(api, this);
setBackend(m_backend);
qDebug() << "AmmUiPlugin: backend initialized";
}

View File

@ -0,0 +1,38 @@
#ifndef AMM_UI_PLUGIN_H
#define AMM_UI_PLUGIN_H
#include <QObject>
#include <QString>
#include <QtPlugin> // for Q_PLUGIN_METADATA, Q_INTERFACES
#include "AmmUiPluginInterface.h"
#include "LogosViewPluginBase.h"
class LogosAPI;
class AmmUiBackend;
// Thin plugin entry point. Holds an AmmUiBackend and lets the generated
// view-plugin base expose it to ui-host.
class AmmUiPlugin : public QObject,
public AmmUiPluginInterface,
public AmmUiBackendViewPluginBase
{
Q_OBJECT
Q_PLUGIN_METADATA(IID AmmUiPluginInterface_iid FILE "../metadata.json")
Q_INTERFACES(AmmUiPluginInterface)
public:
explicit AmmUiPlugin(QObject* parent = nullptr);
~AmmUiPlugin() override;
QString name() const override { return "amm_ui"; }
QString version() const override { return "0.1.0"; }
// Called by ui-host after plugin load. Creates the backend and wires it
// up with the provided LogosAPI.
Q_INVOKABLE void initLogos(LogosAPI* api);
private:
AmmUiBackend* m_backend = nullptr;
};
#endif // AMM_UI_PLUGIN_H

View File

@ -0,0 +1,19 @@
#ifndef AMM_UI_PLUGIN_INTERFACE_H
#define AMM_UI_PLUGIN_INTERFACE_H
#include <QtPlugin> // for Q_DECLARE_INTERFACE
#include "interface.h"
// Marker interface used by Qt's plugin loader to identify the AMM UI plugin.
// The actual API surface (slots, properties, signals) lives in
// AmmUiBackend.rep — this header only carries the IID.
class AmmUiPluginInterface : public PluginInterface
{
public:
virtual ~AmmUiPluginInterface() = default;
};
#define AmmUiPluginInterface_iid "org.logos.AmmUiPluginInterface"
Q_DECLARE_INTERFACE(AmmUiPluginInterface, AmmUiPluginInterface_iid)
#endif // AMM_UI_PLUGIN_INTERFACE_H