Merge pull request #4 from logos-blockchain/erhant/migr-to-module-builder

feat!: use logos protocol with indexer dependency
This commit is contained in:
Sergio Chouhy 2026-06-22 16:47:07 -03:00 committed by GitHub
commit d46f9a5c5d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
79 changed files with 22046 additions and 3603 deletions

43
.github/pull_request_template.md vendored Normal file
View File

@ -0,0 +1,43 @@
## 🎯 Purpose
*What problem does this PR solve or what feature does it add? Mention issues related to it*
TO COMPLETE
## ⚙️ Approach
*Describe the core changes introduced by this PR.*
TO COMPLETE
- [ ] Change ...
- [ ] Add ...
- [ ] Fix ...
- [ ] ...
## 🧪 How to Test
*How to verify that this PR works as intended.*
TO COMPLETE
## 🔗 Dependencies
*Link PRs that must be merged before this one.*
TO COMPLETE IF APPLICABLE
## 🔜 Future Work
*List any work intentionally left out of this PR, whether due to scope, prioritization, or pending decisions.*
TO COMPLETE IF APPLICABLE
## 📋 PR Completion Checklist
*Mark only completed items. A complete PR should have all boxes ticked.*
- [ ] Complete PR description
- [ ] Implement the core functionality
- [ ] Add/update tests
- [ ] Add/update documentation and inline comments

5
.gitignore vendored
View File

@ -2,4 +2,7 @@ result
build
# ignore lgx builds
lez-explorer-ui-*-lgx-*
lez-explorer-ui-*-lgx-*
# ignore rocksdb (may appear here during indexer tests)
rocksdb

View File

@ -1,116 +1,31 @@
cmake_minimum_required(VERSION 3.16)
project(LEZExplorerUIPlugin VERSION 1.0.0 LANGUAGES CXX)
cmake_minimum_required(VERSION 3.14)
project(LezExplorerUiPlugin LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON)
set(CMAKE_AUTOUIC ON)
# Find Qt packages
find_package(Qt6 REQUIRED COMPONENTS Core Widgets Svg SvgWidgets Network WebSockets)
# Try to find the component-interfaces package first
find_package(component-interfaces QUIET)
# If not found, use the local interfaces folder
if(NOT component-interfaces_FOUND)
include_directories(${CMAKE_CURRENT_SOURCE_DIR}/interfaces)
add_library(component-interfaces INTERFACE)
target_include_directories(component-interfaces INTERFACE ${CMAKE_CURRENT_SOURCE_DIR}/interfaces)
endif()
# Source files
set(SOURCES
src/ExplorerPlugin.cpp
src/ExplorerPlugin.h
src/ExplorerWidget.cpp
src/ExplorerWidget.h
src/Style.h
src/services/MockIndexerService.cpp
src/services/MockIndexerService.h
src/services/RpcIndexerService.cpp
src/services/RpcIndexerService.h
src/services/IndexerService.h
src/models/Block.h
src/models/Transaction.h
src/models/Account.h
src/widgets/SearchBar.cpp
src/widgets/SearchBar.h
src/widgets/NavigationBar.cpp
src/widgets/NavigationBar.h
src/widgets/ClickableFrame.h
src/pages/MainPage.cpp
src/pages/MainPage.h
src/pages/BlockPage.cpp
src/pages/BlockPage.h
src/pages/TransactionPage.cpp
src/pages/TransactionPage.h
src/pages/AccountPage.cpp
src/pages/AccountPage.h
src/explorer_resources.qrc
)
# Create the plugin library
add_library(lez_explorer_ui SHARED ${SOURCES})
# Set output name without lib prefix
set_target_properties(lez_explorer_ui PROPERTIES
PREFIX ""
OUTPUT_NAME "lez_explorer_ui"
)
# Include directories
target_include_directories(lez_explorer_ui PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_CURRENT_SOURCE_DIR}/src
${CMAKE_CURRENT_BINARY_DIR}
)
# Link against libraries
target_link_libraries(lez_explorer_ui PRIVATE
Qt6::Core
Qt6::Widgets
Qt6::Svg
Qt6::SvgWidgets
Qt6::Network
Qt6::WebSockets
component-interfaces
)
# Set common properties for both platforms
set_target_properties(lez_explorer_ui PROPERTIES
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/modules"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/modules"
BUILD_WITH_INSTALL_RPATH TRUE
SKIP_BUILD_RPATH FALSE
)
if(APPLE)
set_target_properties(lez_explorer_ui PROPERTIES
INSTALL_RPATH "@loader_path"
INSTALL_NAME_DIR "@rpath"
BUILD_WITH_INSTALL_NAME_DIR TRUE
)
add_custom_command(TARGET lez_explorer_ui POST_BUILD
COMMAND install_name_tool -id "@rpath/lez_explorer_ui.dylib" $<TARGET_FILE:lez_explorer_ui>
COMMENT "Updating library paths for macOS"
)
# Logos Module CMake helper. For nix builds this is provided via
# LOGOS_MODULE_BUILDER_ROOT.
if(DEFINED ENV{LOGOS_MODULE_BUILDER_ROOT})
include($ENV{LOGOS_MODULE_BUILDER_ROOT}/cmake/LogosModule.cmake)
elseif(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/cmake/LogosModule.cmake")
include(cmake/LogosModule.cmake)
else()
set_target_properties(lez_explorer_ui PROPERTIES
INSTALL_RPATH "$ORIGIN"
INSTALL_RPATH_USE_LINK_PATH FALSE
)
message(FATAL_ERROR "LogosModule.cmake not found. Set LOGOS_MODULE_BUILDER_ROOT.")
endif()
install(TARGETS lez_explorer_ui
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}/logos/modules
RUNTIME DESTINATION ${CMAKE_INSTALL_LIBDIR}/logos/modules
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}/logos/modules
# Universal ui_qml module: we author only the .rep view contract + the *Backend
# class. REP_FILE runs repc; the generated *Plugin / *Interface glue in
# generated_code/ (Q_PLUGIN_METADATA, initLogos wiring, QtRO registration) is
# compiled automatically. The QML view (src/qml/) is delivered by the host
# alongside the plugin no Qt Widgets, no .qrc. The logos SDK/protocol (incl.
# the typed lez_indexer_module wrappers) is linked by the builder.
logos_module(
NAME lez_explorer_ui
REP_FILE src/lez_explorer_ui.rep
SOURCES
src/lez_explorer_ui_backend.h
src/lez_explorer_ui_backend.cpp
INCLUDE_DIRS
src
)
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/metadata.json
DESTINATION ${CMAKE_INSTALL_DATADIR}/lez-explorer-ui
)
message(STATUS "LEZ Explorer UI Plugin configured successfully")

View File

@ -1,39 +1,59 @@
# LEZ Explorer UI
A Qt6/C++ block explorer for the **Logos Execution Zone** (LEZ). Builds as both a shared library (Qt plugin) and a standalone desktop application.
A QML block explorer for the **Logos Execution Zone** (LEZ), packaged as a `logos-module-builder` **`ui_qml`** module (universal authoring model). The QML view runs process-isolated in a `ui-host`, backed by a small C++ backend that reads from the [`lez_indexer_module`](https://github.com/logos-blockchain/lez-indexer-module) **in-process over the Logos protocol** (Qt Remote Objects) — no WebSocket, no external RPC socket.
## Features
- Browse recent blocks with live streaming of new blocks
- View block details: hash, previous hash, timestamp, status, signature, transactions
- Navigate to previous blocks via clickable hash links
- View transaction details (Public, Privacy-Preserving, Program Deployment)
- View account details with paginated transaction history
- Full-text search by block ID, block hash, transaction hash, or account ID
- Browse recent blocks with a live tip poll (new heads appear automatically)
- Block details: hash, previous hash, timestamp, Bedrock status, signature, transactions
- Navigate to the previous block via a clickable hash link
- Transaction details (Public, Privacy-Preserving, Program Deployment)
- Account details with recent transaction history
- Search by block id, block hash, transaction hash, or account id
- Browser-style back/forward navigation
- Pagination with "Load more" for older blocks
- Dark theme (Catppuccin Mocha)
- "Load more" pagination for older blocks
- Copy buttons on hashes / ids; in-app **Settings** JSON editor to configure & (re)start the indexer (no file paths; persists across launches)
- Styled with the shared `Logos.Theme` / `Logos.Controls` design system
## Architecture
- `src/lez_explorer_ui.rep` — the QtRO view contract (PROPs / SLOTs / SIGNALs).
- `src/lez_explorer_ui_backend.{h,cpp}` — the only hand-written C++: derives the
generated `LezExplorerUiSimpleSource` + `LogosUiPluginContext`, calls the
indexer with typed `modules().lez_indexer_module.*`, converts its compact JSON
into QVariant for the view, and polls the chain tip for live updates.
- `src/qml/` — the view (`Main.qml`, `pages/`, `components/`, `icons/`).
The `*Plugin` / `*Interface` glue is generated by the builder from the `.rep` + `metadata.json` (`interface: "universal"`).
## Building with Nix
### Standalone application
```sh
nix build '.#app'
nix run '.#app'
git add -A # nix only sees git-tracked files
nix build # runs repc + compiles → result/lib/lez_explorer_ui_plugin.{so,dylib}
nix run . # preview in logos-standalone-app (spawns the ui-host)
nix build .#lgx # bundle as a .lgx for logos-basecamp
```
### Shared library (Qt plugin)
Running against live data: open **Settings** (gear icon, top-right), edit the indexer config JSON (pre-filled with a working template) and press **Save & Start** — the explorer persists the config and (re)starts the indexer from it, so no file paths are involved, and it auto-restarts from the saved config on the next launch. Load the explorer and indexer `.lgx` bundles together in logos-basecamp for the full experience.
## Testing
`tests/ui-tests.mjs` holds UI smoke tests (auto-detected by `mkLogosQmlModule`): they launch the view, wait for it to come up, and check the home page renders. They don't require a running indexer — with none ingesting, the backend stays in its "Connecting" empty state, which is expected.
```sh
nix build '.#lib'
git add -A # nix only sees tracked files
nix build .#integration-test -L # build + run the tests (logs streamed with -L)
```
### Development shell
```sh
nix develop
```
> [!TIP]
>
> To iterate interactively against a local, un-pushed indexer branch, add the override used during development:
>
> ```sh
> nix build .#integration-test -L \
> --override-input lez_indexer_module path:../lez-indexer-module
> ```
## License

View File

@ -1,44 +0,0 @@
cmake_minimum_required(VERSION 3.16)
project(LEZExplorerUIApp LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)
set(CMAKE_INCLUDE_CURRENT_DIR ON)
# Find Qt packages
find_package(Qt6 REQUIRED COMPONENTS Core Widgets)
# Set output directories
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
# Create the executable
add_executable(lez-explorer-ui-app
main.cpp
mainwindow.cpp
mainwindow.h
)
# Link libraries
target_link_libraries(lez-explorer-ui-app PRIVATE
Qt6::Core
Qt6::Widgets
)
# Set RPATH settings for the executable
if(APPLE)
set_target_properties(lez-explorer-ui-app PROPERTIES
INSTALL_RPATH "@executable_path/../lib"
BUILD_WITH_INSTALL_RPATH TRUE
)
elseif(UNIX)
set_target_properties(lez-explorer-ui-app PROPERTIES
INSTALL_RPATH "$ORIGIN/../lib"
BUILD_WITH_INSTALL_RPATH TRUE
)
endif()
# Install rules
install(TARGETS lez-explorer-ui-app
RUNTIME DESTINATION bin
)

View File

@ -1,13 +0,0 @@
#include "mainwindow.h"
#include <QApplication>
int main(int argc, char* argv[])
{
QApplication app(argc, argv);
MainWindow window;
window.show();
return app.exec();
}

View File

@ -1,60 +0,0 @@
#include "mainwindow.h"
#include <QtWidgets>
MainWindow::MainWindow(QWidget* parent)
: QMainWindow(parent)
{
setupUi();
}
MainWindow::~MainWindow() = default;
void MainWindow::setupUi()
{
// Determine the appropriate plugin extension based on the platform
QString pluginExtension;
#if defined(Q_OS_WIN)
pluginExtension = ".dll";
#elif defined(Q_OS_MAC)
pluginExtension = ".dylib";
#else
pluginExtension = ".so";
#endif
QString pluginPath = QCoreApplication::applicationDirPath() + "/../lez_explorer_ui" + pluginExtension;
QPluginLoader loader(pluginPath);
QWidget* explorerWidget = nullptr;
if (loader.load()) {
QObject* plugin = loader.instance();
if (plugin) {
QMetaObject::invokeMethod(plugin, "createWidget",
Qt::DirectConnection,
Q_RETURN_ARG(QWidget*, explorerWidget));
}
}
if (explorerWidget) {
setCentralWidget(explorerWidget);
} else {
qWarning() << "Failed to load LEZ Explorer UI plugin from:" << pluginPath;
qWarning() << "Error:" << loader.errorString();
auto* fallbackWidget = new QWidget(this);
auto* layout = new QVBoxLayout(fallbackWidget);
auto* messageLabel = new QLabel("LEZ Explorer UI module not loaded", fallbackWidget);
QFont font = messageLabel->font();
font.setPointSize(14);
messageLabel->setFont(font);
messageLabel->setAlignment(Qt::AlignCenter);
layout->addWidget(messageLabel);
setCentralWidget(fallbackWidget);
}
setWindowTitle("LEZ Explorer");
resize(1024, 768);
}

View File

@ -1,14 +0,0 @@
#pragma once
#include <QMainWindow>
class MainWindow : public QMainWindow {
Q_OBJECT
public:
explicit MainWindow(QWidget* parent = nullptr);
~MainWindow() override;
private:
void setupUi();
};

19717
flake.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,61 +1,21 @@
{
description = "LEZ Explorer UI - A Qt block explorer for the Logos Execution Zone";
description = "LEZ Explorer UI - a QML block explorer for the Logos Execution Zone";
inputs = {
# Pinned to the EXACT nixpkgs revision basecamp resolves via logos-nix, so this
# in-process `type: ui` module links the identical qtbase (6.9.2) and does not
# load a second QtCore into basecamp's process (which aborts with "Must construct
# a QApplication before a QWidget"). Matching the version string is not enough —
# the store path must match, so this must be re-pinned whenever basecamp bumps its
# logos-nix/nixpkgs lock.
nixpkgs.url = "github:NixOS/nixpkgs/e9f00bd893984bc8ce46c895c3bf7cac95331127";
logos-module-builder.url = "github:logos-co/logos-module-builder";
nix-bundle-lgx.url = "github:logos-co/nix-bundle-lgx";
# NOTE: git+https (not github:) because the branch name contains a slash —
# the github: fetcher mis-routes slashed refs through the commits API (404).
# TODO: repoint to the merge target once this branch lands / goes public.
lez_indexer_module.url = "git+https://github.com/logos-blockchain/lez-indexer-module?ref=erhant/migr-to-logos-module-builder";
};
outputs = { self, nixpkgs }:
let
systems = [ "aarch64-darwin" "x86_64-darwin" "aarch64-linux" "x86_64-linux" ];
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f {
pkgs = import nixpkgs { inherit system; };
});
in
{
packages = forAllSystems ({ pkgs }:
let
common = import ./nix/default.nix { inherit pkgs; };
src = ./.;
lib = import ./nix/lib.nix { inherit pkgs common src; };
app = import ./nix/app.nix {
inherit pkgs common src;
lezExplorerUI = lib;
};
in
{
lez-explorer-ui-lib = lib;
app = app;
lib = lib;
default = lib;
}
);
devShells = forAllSystems ({ pkgs }: {
default = pkgs.mkShell {
nativeBuildInputs = [
pkgs.cmake
pkgs.ninja
pkgs.pkg-config
];
buildInputs = [
pkgs.qt6.qtbase
pkgs.qt6.qtsvg
pkgs.qt6.qtwebsockets
];
shellHook = ''
echo "LEZ Explorer UI development environment"
'';
};
});
outputs =
inputs@{ logos-module-builder, ... }:
logos-module-builder.lib.mkLogosQmlModule {
src = ./.;
configFile = ./metadata.json;
flakeInputs = inputs;
};
}

View File

@ -1,17 +0,0 @@
#pragma once
#include <QObject>
#include <QWidget>
#include <QtPlugin>
class LogosAPI;
class IComponent {
public:
virtual ~IComponent() = default;
virtual QWidget* createWidget(LogosAPI* logosAPI = nullptr) = 0;
virtual void destroyWidget(QWidget* widget) = 0;
};
#define IComponent_iid "com.logos.component.IComponent"
Q_DECLARE_INTERFACE(IComponent, IComponent_iid)

View File

@ -1,10 +1,26 @@
{
"name": "lez_explorer_ui",
"version": "1.0.0",
"description": "LEZ Explorer UI - A block explorer for the Logos Execution Zone",
"type": "ui",
"main": "lez_explorer_ui",
"dependencies": [],
"type": "ui_qml",
"interface": "universal",
"category": "explorer",
"capabilities": ["ui_components"]
"description": "LEZ Explorer UI - A block explorer for the Logos Execution Zone",
"main": "lez_explorer_ui_plugin",
"icon": "src/qml/icons/box.svg",
"view": "qml/Main.qml",
"dependencies": ["lez_indexer_module"],
"codegen": { "rep": "src/lez_explorer_ui.rep" },
"nix": {
"packages": {
"build": [],
"runtime": ["qt6.qtdeclarative", "zstd", "krb5", "abseil-cpp"]
},
"external_libraries": [],
"cmake": {
"find_packages": [],
"extra_sources": [],
"extra_include_dirs": [],
"extra_link_libraries": []
}
}
}

View File

@ -1,65 +0,0 @@
# Builds the lez-explorer-ui-app standalone application
{ pkgs, common, src, lezExplorerUI }:
pkgs.stdenv.mkDerivation rec {
pname = "lez-explorer-ui-app";
version = common.version;
inherit src;
inherit (common) buildInputs meta;
nativeBuildInputs = common.nativeBuildInputs ++ pkgs.lib.optionals pkgs.stdenv.isLinux [ pkgs.patchelf ];
# This is a GUI application, enable Qt wrapping
dontWrapQtApps = false;
qtLibPath = pkgs.lib.makeLibraryPath ([
pkgs.qt6.qtbase
pkgs.qt6.qtwebsockets
] ++ pkgs.lib.optionals pkgs.stdenv.isLinux [
pkgs.libglvnd
]);
qtPluginPath = "${pkgs.qt6.qtbase}/lib/qt-6/plugins:${pkgs.qt6.qtsvg}/lib/qt-6/plugins";
qtWrapperArgs = [
"--prefix" "LD_LIBRARY_PATH" ":" qtLibPath
"--prefix" "QT_PLUGIN_PATH" ":" qtPluginPath
];
configurePhase = ''
runHook preConfigure
cmake -S app -B build \
-GNinja \
-DCMAKE_BUILD_TYPE=Release
runHook postConfigure
'';
buildPhase = ''
runHook preBuild
cmake --build build
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out/bin $out/lib
# Install the app binary
cp build/bin/lez-explorer-ui-app $out/bin/
# Determine platform-specific plugin extension
OS_EXT="so"
case "$(uname -s)" in
Darwin) OS_EXT="dylib";;
esac
# Copy the explorer UI plugin to root directory (loaded relative to binary)
if [ -f "${lezExplorerUI}/lib/lez_explorer_ui.$OS_EXT" ]; then
cp -L "${lezExplorerUI}/lib/lez_explorer_ui.$OS_EXT" "$out/"
fi
runHook postInstall
'';
}

View File

@ -1,29 +0,0 @@
# Common build configuration shared across all packages
{ pkgs }:
{
pname = "lez-explorer-ui";
version = "1.0.0";
nativeBuildInputs = [
pkgs.cmake
pkgs.ninja
pkgs.pkg-config
pkgs.qt6.wrapQtAppsHook
];
buildInputs = [
pkgs.qt6.qtbase
pkgs.qt6.qtsvg
pkgs.qt6.qtwebsockets
];
cmakeFlags = [
"-GNinja"
];
meta = with pkgs.lib; {
description = "LEZ Explorer UI - A Qt block explorer for the Logos Execution Zone";
platforms = platforms.unix;
};
}

View File

@ -1,45 +0,0 @@
# Builds the lez-explorer-ui library (Qt plugin)
{ pkgs, common, src }:
pkgs.stdenv.mkDerivation {
pname = "${common.pname}-lib";
version = common.version;
inherit src;
inherit (common) buildInputs cmakeFlags meta;
nativeBuildInputs = common.nativeBuildInputs;
# Library (Qt plugin), not an app — no Qt wrapper
dontWrapQtApps = true;
configurePhase = ''
runHook preConfigure
cmake -S . -B build \
-GNinja \
-DCMAKE_BUILD_TYPE=Release
runHook postConfigure
'';
buildPhase = ''
runHook preBuild
cmake --build build
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out/lib
if [ -f build/modules/lez_explorer_ui.dylib ]; then
cp build/modules/lez_explorer_ui.dylib $out/lib/
elif [ -f build/modules/lez_explorer_ui.so ]; then
cp build/modules/lez_explorer_ui.so $out/lib/
else
echo "Error: No library file found in build/modules/"
ls -la build/modules/ 2>/dev/null || true
exit 1
fi
runHook postInstall
'';
}

View File

@ -1,17 +0,0 @@
#include "ExplorerPlugin.h"
#include "ExplorerWidget.h"
ExplorerPlugin::ExplorerPlugin(QObject* parent)
: QObject(parent)
{
}
QWidget* ExplorerPlugin::createWidget(LogosAPI* /*logosAPI*/)
{
return new ExplorerWidget();
}
void ExplorerPlugin::destroyWidget(QWidget* widget)
{
delete widget;
}

View File

@ -1,18 +0,0 @@
#pragma once
#include "IComponent.h"
#include <QObject>
#include <QtPlugin>
class ExplorerPlugin : public QObject, public IComponent {
Q_OBJECT
Q_INTERFACES(IComponent)
Q_PLUGIN_METADATA(IID IComponent_iid FILE "metadata.json")
public:
explicit ExplorerPlugin(QObject* parent = nullptr);
Q_INVOKABLE QWidget* createWidget(LogosAPI* logosAPI = nullptr) override;
void destroyWidget(QWidget* widget) override;
};

View File

@ -1,213 +0,0 @@
#include "ExplorerWidget.h"
#include "Style.h"
#include "services/RpcIndexerService.h"
#include "widgets/NavigationBar.h"
#include "widgets/SearchBar.h"
#include "pages/MainPage.h"
#include "pages/BlockPage.h"
#include "pages/TransactionPage.h"
#include "pages/AccountPage.h"
#include <QVBoxLayout>
#include <QStackedWidget>
#include <QLabel>
ExplorerWidget::ExplorerWidget(QWidget* parent)
: QWidget(parent)
, m_indexer(std::make_unique<RpcIndexerService>())
{
setStyleSheet(Style::appBackground());
auto* layout = new QVBoxLayout(this);
layout->setContentsMargins(16, 12, 16, 12);
layout->setSpacing(8);
// Navigation bar
m_navBar = new NavigationBar(this);
layout->addWidget(m_navBar);
// Search bar
m_searchBar = new SearchBar(this);
layout->addWidget(m_searchBar);
// Page stack
m_stack = new QStackedWidget(this);
layout->addWidget(m_stack, 1);
// Create main page
m_mainPage = new MainPage(m_indexer.get(), this);
m_stack->addWidget(m_mainPage);
connectPageSignals(m_mainPage);
m_currentTarget = NavHome{};
// Connect navigation
connect(m_navBar, &NavigationBar::backClicked, this, &ExplorerWidget::navigateBack);
connect(m_navBar, &NavigationBar::forwardClicked, this, &ExplorerWidget::navigateForward);
connect(m_navBar, &NavigationBar::homeClicked, this, &ExplorerWidget::navigateHome);
connect(m_searchBar, &SearchBar::searchRequested, this, &ExplorerWidget::onSearch);
connect(m_indexer.get(), &IndexerService::newBlockAdded, this, [this](const Block& block) {
m_mainPage->onNewBlock(block);
});
updateNavButtons();
}
ExplorerWidget::~ExplorerWidget() = default;
void ExplorerWidget::connectPageSignals(QWidget* page)
{
if (auto* mainPage = qobject_cast<MainPage*>(page)) {
connect(mainPage, &MainPage::blockClicked, this, [this](quint64 id) {
navigateTo(NavBlock{id});
});
connect(mainPage, &MainPage::transactionClicked, this, [this](const QString& hash) {
navigateTo(NavTransaction{hash});
});
connect(mainPage, &MainPage::accountClicked, this, [this](const QString& id) {
navigateTo(NavAccount{id});
});
} else if (auto* blockPage = qobject_cast<BlockPage*>(page)) {
connect(blockPage, &BlockPage::blockClicked, this, [this](quint64 id) {
navigateTo(NavBlock{id});
});
connect(blockPage, &BlockPage::transactionClicked, this, [this](const QString& hash) {
navigateTo(NavTransaction{hash});
});
} else if (auto* txPage = qobject_cast<TransactionPage*>(page)) {
connect(txPage, &TransactionPage::accountClicked, this, [this](const QString& id) {
navigateTo(NavAccount{id});
});
} else if (auto* accPage = qobject_cast<AccountPage*>(page)) {
connect(accPage, &AccountPage::transactionClicked, this, [this](const QString& hash) {
navigateTo(NavTransaction{hash});
});
}
}
void ExplorerWidget::showPage(QWidget* page)
{
// Remove all pages except main page
while (m_stack->count() > 1) {
auto* w = m_stack->widget(1);
m_stack->removeWidget(w);
w->deleteLater();
}
if (page == m_mainPage) {
m_stack->setCurrentWidget(m_mainPage);
} else {
m_stack->addWidget(page);
m_stack->setCurrentWidget(page);
}
}
void ExplorerWidget::navigateTo(const NavTarget& target, bool addToHistory)
{
if (addToHistory) {
m_backHistory.push(m_currentTarget);
m_forwardHistory.clear();
}
m_currentTarget = target;
std::visit([this](auto&& t) {
using T = std::decay_t<decltype(t)>;
if constexpr (std::is_same_v<T, NavHome>) {
m_mainPage->clearSearchResults();
m_mainPage->refresh();
showPage(m_mainPage);
} else if constexpr (std::is_same_v<T, NavBlock>) {
auto block = m_indexer->getBlockById(t.blockId);
if (block) {
auto* page = new BlockPage(*block, this);
connectPageSignals(page);
showPage(page);
} else {
qWarning("Block #%llu not found", static_cast<unsigned long long>(t.blockId));
showPage(m_mainPage);
}
} else if constexpr (std::is_same_v<T, NavTransaction>) {
auto tx = m_indexer->getTransaction(t.hash);
if (tx) {
auto* page = new TransactionPage(*tx, this);
connectPageSignals(page);
showPage(page);
} else {
qWarning("Transaction %s not found", qPrintable(t.hash));
showPage(m_mainPage);
}
} else if constexpr (std::is_same_v<T, NavAccount>) {
auto account = m_indexer->getAccount(t.accountId);
if (account) {
auto* page = new AccountPage(*account, m_indexer.get(), this);
connectPageSignals(page);
showPage(page);
} else {
qWarning("Account %s not found", qPrintable(t.accountId));
showPage(m_mainPage);
}
}
}, target);
updateNavButtons();
}
void ExplorerWidget::navigateBack()
{
if (m_backHistory.isEmpty()) return;
m_forwardHistory.push(m_currentTarget);
NavTarget target = m_backHistory.pop();
navigateTo(target, false);
}
void ExplorerWidget::navigateForward()
{
if (m_forwardHistory.isEmpty()) return;
m_backHistory.push(m_currentTarget);
NavTarget target = m_forwardHistory.pop();
navigateTo(target, false);
}
void ExplorerWidget::navigateHome()
{
navigateTo(NavHome{});
}
void ExplorerWidget::onSearch(const QString& query)
{
if (query.trimmed().isEmpty()) return;
auto results = m_indexer->search(query);
// If exactly one result, navigate directly to it
int totalResults = results.blocks.size() + results.transactions.size() + results.accounts.size();
if (totalResults == 1) {
if (!results.blocks.isEmpty()) {
navigateTo(NavBlock{results.blocks.first().blockId});
return;
}
if (!results.transactions.isEmpty()) {
navigateTo(NavTransaction{results.transactions.first().hash});
return;
}
if (!results.accounts.isEmpty()) {
navigateTo(NavAccount{results.accounts.first().accountId});
return;
}
}
// Show results on main page
navigateTo(NavHome{});
m_mainPage->showSearchResults(results);
}
void ExplorerWidget::updateNavButtons()
{
m_navBar->setBackEnabled(!m_backHistory.isEmpty());
m_navBar->setForwardEnabled(!m_forwardHistory.isEmpty());
}

View File

@ -1,49 +0,0 @@
#pragma once
#include "services/IndexerService.h"
#include <QWidget>
#include <QStack>
#include <memory>
#include <variant>
class QStackedWidget;
class NavigationBar;
class SearchBar;
class MainPage;
struct NavHome {};
struct NavBlock { quint64 blockId; };
struct NavTransaction { QString hash; };
struct NavAccount { QString accountId; };
using NavTarget = std::variant<NavHome, NavBlock, NavTransaction, NavAccount>;
class ExplorerWidget : public QWidget {
Q_OBJECT
public:
explicit ExplorerWidget(QWidget* parent = nullptr);
~ExplorerWidget() override;
private:
void navigateTo(const NavTarget& target, bool addToHistory = true);
void navigateBack();
void navigateForward();
void navigateHome();
void onSearch(const QString& query);
void updateNavButtons();
void showPage(QWidget* page);
void connectPageSignals(QWidget* page);
std::unique_ptr<IndexerService> m_indexer;
NavigationBar* m_navBar = nullptr;
SearchBar* m_searchBar = nullptr;
QStackedWidget* m_stack = nullptr;
MainPage* m_mainPage = nullptr;
QStack<NavTarget> m_backHistory;
QStack<NavTarget> m_forwardHistory;
NavTarget m_currentTarget;
};

View File

@ -1,142 +0,0 @@
#pragma once
#include <QString>
namespace Style {
// --- Color palette ---
namespace Color {
inline const char* bg() { return "#1e1e2e"; }
inline const char* surface() { return "#2a2a3c"; }
inline const char* surfaceHover() { return "#33334a"; }
inline const char* border() { return "#3b3b52"; }
inline const char* text() { return "#cdd6f4"; }
inline const char* subtext() { return "#7f849c"; }
inline const char* accent() { return "#89b4fa"; }
inline const char* green() { return "#a6e3a1"; }
inline const char* yellow() { return "#f9e2af"; }
inline const char* red() { return "#f38ba8"; }
inline const char* blue() { return "#89b4fa"; }
inline const char* mauve() { return "#cba6f7"; }
inline const char* peach() { return "#fab387"; }
inline const char* teal() { return "#94e2d5"; }
}
// --- Base font size applied to the entire app ---
inline int baseFontSize() { return 14; }
// --- Widget styles ---
inline QString appBackground()
{
return QString("background-color: %1; color: %2; font-size: %3px;")
.arg(Color::bg(), Color::text())
.arg(baseFontSize());
}
inline QString cardFrame()
{
return QString(
"background: %1; border: 1px solid %2; border-radius: 8px; padding: 18px;"
).arg(Color::surface(), Color::border());
}
inline QString cardFrameWithLabels()
{
return QString(
"QFrame { background: %1; border: 1px solid %2; border-radius: 8px; padding: 18px; }"
" QFrame QLabel { color: %3; font-size: %4px; }"
).arg(Color::surface(), Color::border(), Color::text())
.arg(baseFontSize());
}
inline QString clickableRowWithLabels(const QString& selector)
{
return QString(
"%1 { background: %2; border: 1px solid %3; border-radius: 8px; padding: 12px 16px; margin: 3px 0; }"
" %1 QLabel { color: %4; font-size: %5px; }"
).arg(selector, Color::surface(), Color::border(), Color::text())
.arg(baseFontSize());
}
inline QString mutedText()
{
return QString("color: %1;").arg(Color::subtext());
}
inline QString monoText()
{
return QString("font-family: 'Menlo', 'Courier New'; font-size: %1px; color: %2;")
.arg(baseFontSize() - 1)
.arg(Color::accent());
}
inline QString badge(const QString& bgColor)
{
return QString("color: %1; background: %2; border-radius: 4px; padding: 3px 12px; font-weight: bold; font-size: %3px;")
.arg(Color::bg(), bgColor)
.arg(baseFontSize() - 1);
}
inline QString sectionHeader()
{
return QString("color: %1;").arg(Color::text());
}
inline QString navButton()
{
return QString(
"QPushButton { background: %1; color: %2; border: 1px solid %3; border-radius: 6px; padding: 6px 14px; font-size: %7px; }"
" QPushButton:hover { background: %4; border-color: %5; }"
" QPushButton:disabled { color: %6; background: %1; border-color: %3; opacity: 0.4; }"
).arg(Color::surface(), Color::text(), Color::border(), Color::surfaceHover(), Color::accent(), Color::subtext())
.arg(baseFontSize() + 2);
}
inline QString searchInput()
{
return QString(
"QLineEdit { background: %1; color: %2; border: 1px solid %3; border-radius: 6px; padding: 10px 14px; font-size: %6px; }"
" QLineEdit:focus { border-color: %4; }"
" QLineEdit::placeholder { color: %5; }"
).arg(Color::surface(), Color::text(), Color::border(), Color::accent(), Color::subtext())
.arg(baseFontSize());
}
inline QString searchButton()
{
return QString(
"QPushButton { background: %1; color: %2; border: none; border-radius: 6px; padding: 10px 20px; font-size: %4px; font-weight: bold; }"
" QPushButton:hover { background: %3; }"
).arg(Color::accent(), Color::bg(), Color::mauve())
.arg(baseFontSize());
}
inline QString healthLabel()
{
return QString("color: %1; font-weight: bold; font-size: %2px;")
.arg(Color::green())
.arg(baseFontSize());
}
// --- Status colors ---
inline QString statusColor(const QString& status)
{
if (status == "Finalized") return Color::green();
if (status == "Safe") return Color::yellow();
return Color::subtext(); // Pending
}
// --- Transaction type colors ---
inline QString txTypeColor(const QString& type)
{
if (type == "Public") return Color::blue();
if (type == "Privacy-Preserving") return Color::mauve();
return Color::peach(); // Program Deployment
}
} // namespace Style

View File

@ -1,12 +0,0 @@
<RCC>
<qresource prefix="/">
<file>icons/arrow-left.svg</file>
<file>icons/arrow-right.svg</file>
<file>icons/home.svg</file>
<file>icons/search.svg</file>
<file>icons/box.svg</file>
<file>icons/file-text.svg</file>
<file>icons/user.svg</file>
<file>icons/activity.svg</file>
</qresource>
</RCC>

48
src/lez_explorer_ui.rep Normal file
View File

@ -0,0 +1,48 @@
#include <QVariantList>
#include <QVariantMap>
// QtRO view contract for the LEZ block explorer. The QML view talks to this
// surface; the *Plugin / *Interface glue is generated (interface: universal).
//
// State the view binds to is exposed as auto-synced PROPs; one-shot lookups are
// SLOTs whose return value is delivered to QML via logos.watch(...). Everything
// is Qt-typed (the QtRO wire contract); the backend converts the indexer's
// compact JSON into QVariantMap/QVariantList before it crosses this boundary.
class LezExplorerUi
{
// Recent-blocks feed shown on the home page (newest first). Backend-owned:
// the 2 s poll prepends new heads and "Load more" appends older pages.
PROP(QVariantList recentBlocks READONLY)
// Tip block id (from getLastFinalizedBlockId).
PROP(qlonglong chainHeight=0 READONLY)
// "Connecting" / "Connected" / "Error" — drives the health indicator.
PROP(QString connectionStatus READONLY)
// The indexer config JSON currently in effect ("" until one is saved). The
// Settings editor seeds from this (falling back to defaultConfig).
PROP(QString configText READONLY)
// Built-in starter config template (seeded by the backend) for the Settings
// editor's "Reset to default".
PROP(QString defaultConfig READONLY)
// Persist `json` as the indexer config and (re)start ingestion from it, then
// refresh the feed. `port` is the indexer RPC port (blank keeps the current
// one). The backend writes the JSON to a file itself — the UI never deals
// with paths. Returns true on success.
SLOT(bool applyConfigJson(QString json, QString port))
// Reload the latest page into recentBlocks and re-baseline the poller.
SLOT(void refreshBlocks())
// Append the next older page of blocks to recentBlocks.
SLOT(void loadMoreBlocks())
// Detail lookups — each returns an empty map/list when not found.
SLOT(QVariantMap getBlockById(QString id))
SLOT(QVariantMap getBlockByHash(QString hash))
SLOT(QVariantMap getTransaction(QString hash))
SLOT(QVariantMap getAccount(QString accountId))
SLOT(QVariantList getTransactionsByAccount(QString accountId, int offset, int limit))
// Composed client-side: { blocks: [...], transactions: [...], accounts: [...] }.
SLOT(QVariantMap search(QString query))
// Surfaced to the view for transient failures (e.g. a failed start_indexer).
SIGNAL(error(QString message))
}

View File

@ -0,0 +1,548 @@
#include "lez_explorer_ui_backend.h"
#include <algorithm>
#include <utility>
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QJsonValue>
#include <QRegularExpression>
#include <QStandardPaths>
#include <QTimeZone>
#include <QTimer>
// Generated umbrella: LogosModules (behind modules()) built from
// metadata.json#dependencies — the Qt-typed wrappers for lez_indexer_module.
#include "logos_sdk.h"
namespace {
constexpr int kPollIntervalMs = 2000;
// Above this gap we rebuild the feed from the backend rather than fetch every
// missing block one by one (matches the former LpIndexerService threshold).
constexpr quint64 kGapRebuildThreshold = 8;
// Starter config shown in the Settings editor (and its "Reset to default").
// A working local-dev indexer config; the user edits the fields (bedrock addr,
// channel id, initial accounts/commitments, signing key, ...) and saves.
const char* const kDefaultConfig = R"JSON({
"home": ".",
"consensus_info_polling_interval": "1s",
"bedrock_config": {
"addr": "http://localhost:8080",
"backoff": {
"start_delay": "100ms",
"max_retries": 5
}
},
"channel_id": "0101010101010101010101010101010101010101010101010101010101010101",
"initial_accounts": [
{ "account_id": "CbgR6tj5kWx5oziiFptM7jMvrQeYY3Mzaao6ciuhSr2r", "balance": 10000 },
{ "account_id": "2RHZhw9h534Zr3eq2RGhQete2Hh667foECzXPmSkGni2", "balance": 20000 }
],
"initial_commitments": [],
"signing_key": [
37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37,
37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37
]
})JSON";
// 64/128-bit values arrive as decimal strings (to dodge JSON double precision
// loss), but be lenient and also accept a JSON number.
quint64 jsonU64(const QJsonValue& value)
{
if (value.isString()) {
bool ok = false;
const qulonglong parsed = value.toString().toULongLong(&ok);
return ok ? parsed : 0;
}
if (value.isDouble()) {
const double number = value.toDouble();
return number > 0.0 ? static_cast<quint64>(number) : 0;
}
return 0;
}
QString jsonStr(const QJsonValue& value)
{
if (value.isString()) {
return value.toString();
}
if (value.isDouble()) {
return QString::number(static_cast<qulonglong>(value.toDouble()));
}
return {};
}
QString timestampText(quint64 timestamp)
{
QDateTime dt;
// Tolerate either seconds or milliseconds since epoch.
if (timestamp > 1000000000000ULL) {
dt = QDateTime::fromMSecsSinceEpoch(static_cast<qint64>(timestamp), QTimeZone::UTC);
} else {
dt = QDateTime::fromSecsSinceEpoch(static_cast<qint64>(timestamp), QTimeZone::UTC);
}
if (!dt.isValid()) {
dt = QDateTime::currentDateTimeUtc();
}
return dt.toString(QStringLiteral("yyyy-MM-dd hh:mm:ss")) + QStringLiteral(" UTC");
}
QVariantMap txObjectToMap(const QJsonObject& obj)
{
QVariantMap tx;
if (obj.isEmpty()) {
return tx;
}
tx.insert(QStringLiteral("hash"), jsonStr(obj.value(QStringLiteral("hash"))));
const QString type = obj.value(QStringLiteral("type")).toString();
tx.insert(QStringLiteral("type"), type);
const auto accountsOf = [](const QJsonArray& arr) {
QVariantList accounts;
for (const auto& entry : arr) {
const QJsonObject account = entry.toObject();
QVariantMap ref;
ref.insert(QStringLiteral("accountId"), jsonStr(account.value(QStringLiteral("account_id"))));
QString nonce = jsonStr(account.value(QStringLiteral("nonce")));
ref.insert(QStringLiteral("nonce"), nonce.isEmpty() ? QStringLiteral("0") : nonce);
accounts.append(ref);
}
return accounts;
};
if (type == QLatin1String("Public")) {
tx.insert(QStringLiteral("programId"), jsonStr(obj.value(QStringLiteral("program_id"))));
tx.insert(QStringLiteral("accounts"), accountsOf(obj.value(QStringLiteral("accounts")).toArray()));
tx.insert(QStringLiteral("instructionDataCount"),
obj.value(QStringLiteral("instruction_data")).toArray().size());
tx.insert(QStringLiteral("signatureCount"), obj.value(QStringLiteral("signature_count")).toInt());
} else if (type == QLatin1String("PrivacyPreserving")) {
tx.insert(QStringLiteral("accounts"), accountsOf(obj.value(QStringLiteral("accounts")).toArray()));
tx.insert(QStringLiteral("newCommitmentsCount"), obj.value(QStringLiteral("new_commitments_count")).toInt());
tx.insert(QStringLiteral("nullifiersCount"), obj.value(QStringLiteral("nullifiers_count")).toInt());
tx.insert(QStringLiteral("encryptedStatesCount"), obj.value(QStringLiteral("encrypted_states_count")).toInt());
tx.insert(QStringLiteral("validityWindowStart"),
static_cast<qlonglong>(jsonU64(obj.value(QStringLiteral("validity_window_start")))));
tx.insert(QStringLiteral("validityWindowEnd"),
static_cast<qlonglong>(jsonU64(obj.value(QStringLiteral("validity_window_end")))));
tx.insert(QStringLiteral("signatureCount"), obj.value(QStringLiteral("signature_count")).toInt());
tx.insert(QStringLiteral("proofSizeBytes"), obj.value(QStringLiteral("proof_size")).toInt());
} else if (type == QLatin1String("ProgramDeployment")) {
tx.insert(QStringLiteral("bytecodeSizeBytes"), obj.value(QStringLiteral("bytecode_size")).toInt());
}
return tx;
}
QVariantMap blockObjectToMap(const QJsonObject& obj)
{
QVariantMap block;
if (obj.isEmpty()) {
return block;
}
block.insert(QStringLiteral("blockId"), static_cast<qlonglong>(jsonU64(obj.value(QStringLiteral("block_id")))));
block.insert(QStringLiteral("hash"), jsonStr(obj.value(QStringLiteral("hash"))));
block.insert(QStringLiteral("prevBlockHash"), jsonStr(obj.value(QStringLiteral("prev_block_hash"))));
block.insert(QStringLiteral("signature"), jsonStr(obj.value(QStringLiteral("signature"))));
block.insert(QStringLiteral("timestampText"), timestampText(jsonU64(obj.value(QStringLiteral("timestamp")))));
const QString status = obj.value(QStringLiteral("bedrock_status")).toString(QStringLiteral("Pending"));
block.insert(QStringLiteral("status"), status);
const QJsonArray txArray = obj.value(QStringLiteral("transactions")).toArray();
QVariantList transactions;
transactions.reserve(txArray.size());
for (const auto& txValue : txArray) {
const QVariantMap tx = txObjectToMap(txValue.toObject());
if (!tx.isEmpty()) {
transactions.append(tx);
}
}
block.insert(QStringLiteral("transactions"), transactions);
block.insert(QStringLiteral("txCount"), transactions.size());
return block;
}
QVariantMap blockJsonToMap(const QString& json)
{
if (json.isEmpty()) {
return {};
}
const QJsonDocument doc = QJsonDocument::fromJson(json.toUtf8());
return doc.isObject() ? blockObjectToMap(doc.object()) : QVariantMap();
}
QVariantMap accountJsonToMap(const QString& accountId, const QString& json)
{
if (json.isEmpty()) {
return {};
}
const QJsonDocument doc = QJsonDocument::fromJson(json.toUtf8());
if (!doc.isObject()) {
return {};
}
const QJsonObject obj = doc.object();
QVariantMap account;
account.insert(QStringLiteral("accountId"), accountId); // payload omits it; inject the queried id
account.insert(QStringLiteral("programOwner"), jsonStr(obj.value(QStringLiteral("program_owner"))));
QString balance = jsonStr(obj.value(QStringLiteral("balance")));
account.insert(QStringLiteral("balance"), balance.isEmpty() ? QStringLiteral("0") : balance);
QString nonce = jsonStr(obj.value(QStringLiteral("nonce")));
account.insert(QStringLiteral("nonce"), nonce.isEmpty() ? QStringLiteral("0") : nonce);
account.insert(QStringLiteral("dataSizeBytes"), obj.value(QStringLiteral("data_size")).toInt());
return account;
}
QVariantMap txJsonToMap(const QString& json)
{
if (json.isEmpty()) {
return {};
}
const QJsonDocument doc = QJsonDocument::fromJson(json.toUtf8());
return doc.isObject() ? txObjectToMap(doc.object()) : QVariantMap();
}
} // namespace
LezExplorerUiBackend::LezExplorerUiBackend()
{
setDefaultConfig(QString::fromUtf8(kDefaultConfig));
}
LezExplorerUiBackend::~LezExplorerUiBackend() = default;
void LezExplorerUiBackend::onContextReady()
{
setConnectionStatus(QStringLiteral("Connecting"));
// Reload + auto-start the previously-saved config, if any (set-once UX).
const QString saved = readConfigFile();
if (!saved.isEmpty()) {
setConfigText(saved);
startIndexerFromFile();
}
// The indexer pushes no events — poll the tip for live head updates.
m_pollTimer = new QTimer(this);
m_pollTimer->setInterval(kPollIntervalMs);
connect(m_pollTimer, &QTimer::timeout, this, &LezExplorerUiBackend::pollTip);
m_pollTimer->start();
// Kick an immediate poll so the view fills without waiting a full interval.
pollTip();
}
bool LezExplorerUiBackend::applyConfigJson(QString json, QString port)
{
const QString trimmedPort = port.trimmed();
if (!trimmedPort.isEmpty()) {
m_port = trimmedPort;
}
const QString trimmed = json.trimmed();
if (trimmed.isEmpty()) {
emit error(QStringLiteral("Config is empty."));
return false;
}
// Reject invalid JSON up front so we never start the indexer from garbage.
QJsonParseError parseError;
QJsonDocument::fromJson(trimmed.toUtf8(), &parseError);
if (parseError.error != QJsonParseError::NoError) {
emit error(QStringLiteral("Invalid JSON: %1").arg(parseError.errorString()));
return false;
}
if (!writeConfigFile(json)) {
emit error(QStringLiteral("Could not write config to %1").arg(configFilePath()));
return false;
}
setConfigText(json);
const bool ok = startIndexerFromFile();
// Re-baseline the poller; a (re)started indexer may ingest afresh.
m_lastPolledTip = 0;
m_polledOnce = false;
refreshBlocks();
return ok;
}
void LezExplorerUiBackend::refreshBlocks()
{
const QString json = modules().lez_indexer_module.getBlocks(QString(), QStringLiteral("10"));
m_recentBlocks = parseBlockArrayJson(json);
m_newestLoadedId = 0;
m_oldestLoadedId = 0;
for (const QVariant& entry : std::as_const(m_recentBlocks)) {
const quint64 id = entry.toMap().value(QStringLiteral("blockId")).toULongLong();
if (m_newestLoadedId == 0 || id > m_newestLoadedId) {
m_newestLoadedId = id;
}
if (m_oldestLoadedId == 0 || id < m_oldestLoadedId) {
m_oldestLoadedId = id;
}
}
setRecentBlocks(m_recentBlocks);
}
void LezExplorerUiBackend::loadMoreBlocks()
{
if (m_oldestLoadedId <= 1) {
return; // already at genesis
}
const QString json
= modules().lez_indexer_module.getBlocks(QString::number(m_oldestLoadedId), QStringLiteral("10"));
const QVariantList older = parseBlockArrayJson(json);
if (older.isEmpty()) {
return;
}
m_recentBlocks.append(older);
for (const QVariant& entry : older) {
const quint64 id = entry.toMap().value(QStringLiteral("blockId")).toULongLong();
if (id != 0 && (m_oldestLoadedId == 0 || id < m_oldestLoadedId)) {
m_oldestLoadedId = id;
}
}
setRecentBlocks(m_recentBlocks);
}
QVariantMap LezExplorerUiBackend::getBlockById(QString id)
{
return blockJsonToMap(modules().lez_indexer_module.getBlockById(id));
}
QVariantMap LezExplorerUiBackend::getBlockByHash(QString hash)
{
return blockJsonToMap(modules().lez_indexer_module.getBlockByHash(hash));
}
QVariantMap LezExplorerUiBackend::getTransaction(QString hash)
{
return txJsonToMap(modules().lez_indexer_module.getTransaction(hash));
}
QVariantMap LezExplorerUiBackend::getAccount(QString accountId)
{
return accountJsonToMap(accountId, modules().lez_indexer_module.getAccount(accountId));
}
QVariantList LezExplorerUiBackend::getTransactionsByAccount(QString accountId, int offset, int limit)
{
const QString json = modules().lez_indexer_module.getTransactionsByAccount(
accountId, QString::number(offset), QString::number(limit));
if (json.isEmpty()) {
return {};
}
const QJsonDocument doc = QJsonDocument::fromJson(json.toUtf8());
if (!doc.isArray()) {
return {};
}
QVariantList result;
const QJsonArray array = doc.array();
result.reserve(array.size());
for (const auto& item : array) {
const QVariantMap tx = txObjectToMap(item.toObject());
if (!tx.isEmpty()) {
result.append(tx);
}
}
return result;
}
QVariantMap LezExplorerUiBackend::search(QString query)
{
QVariantList blocks;
QVariantList transactions;
QVariantList accounts;
const QString trimmed = query.trimmed();
if (!trimmed.isEmpty()) {
static const QRegularExpression hashRegex(QStringLiteral("^[0-9a-fA-F]{64}$"));
if (hashRegex.match(trimmed).hasMatch()) {
const QVariantMap block = getBlockByHash(trimmed);
if (!block.isEmpty()) {
blocks.append(block);
}
const QVariantMap tx = getTransaction(trimmed);
if (!tx.isEmpty()) {
transactions.append(tx);
}
const QVariantMap account = getAccount(trimmed);
// getAccount always injects the id; treat "no payload fields" as miss.
if (account.size() > 1) {
accounts.append(account);
}
}
bool ok = false;
const quint64 blockId = trimmed.toULongLong(&ok);
if (ok) {
const QVariantMap block = getBlockById(QString::number(blockId));
if (!block.isEmpty()) {
const bool duplicate = std::any_of(blocks.cbegin(), blocks.cend(), [blockId](const QVariant& e) {
return e.toMap().value(QStringLiteral("blockId")).toULongLong() == blockId;
});
if (!duplicate) {
blocks.append(block);
}
}
}
}
QVariantMap results;
results.insert(QStringLiteral("blocks"), blocks);
results.insert(QStringLiteral("transactions"), transactions);
results.insert(QStringLiteral("accounts"), accounts);
return results;
}
void LezExplorerUiBackend::pollTip()
{
const QString tipStr = modules().lez_indexer_module.getLastFinalizedBlockId();
bool ok = false;
const quint64 tip = tipStr.toULongLong(&ok);
if (!ok || tip == 0) {
setConnectionStatus(QStringLiteral("Connecting"));
return;
}
setConnectionStatus(QStringLiteral("Connected"));
setChainHeight(static_cast<qlonglong>(tip));
// First successful poll: fill the feed and baseline without double-loading.
if (!m_polledOnce) {
m_polledOnce = true;
m_lastPolledTip = tip;
refreshBlocks();
return;
}
if (tip <= m_lastPolledTip) {
return;
}
// Large jump / gap: rebuild the recent list from the backend.
if (tip - m_lastPolledTip > kGapRebuildThreshold) {
m_lastPolledTip = tip;
refreshBlocks();
return;
}
// Contiguous growth: fetch the new heads and prepend (newest first).
for (quint64 id = m_lastPolledTip + 1; id <= tip; ++id) {
const QVariantMap block = fetchBlock(id);
if (!block.isEmpty()) {
m_recentBlocks.prepend(block);
if (id > m_newestLoadedId) {
m_newestLoadedId = id;
}
if (m_oldestLoadedId == 0) {
m_oldestLoadedId = id;
}
}
}
m_lastPolledTip = tip;
setRecentBlocks(m_recentBlocks);
}
QVariantMap LezExplorerUiBackend::fetchBlock(quint64 blockId)
{
return blockJsonToMap(modules().lez_indexer_module.getBlockById(QString::number(blockId)));
}
QVariantList LezExplorerUiBackend::parseBlockArrayJson(const QString& json) const
{
if (json.isEmpty()) {
return {};
}
const QJsonDocument doc = QJsonDocument::fromJson(json.toUtf8());
if (!doc.isArray()) {
return {};
}
QVariantList blocks;
const QJsonArray array = doc.array();
blocks.reserve(array.size());
for (const auto& item : array) {
const QVariantMap block = blockObjectToMap(item.toObject());
if (!block.isEmpty()) {
blocks.append(block);
}
}
return blocks;
}
namespace {
// The writable path where the edited config JSON is persisted and the indexer
// is started from (so the UI never deals with paths). Same logic, shared by the
// const and non-const accessors.
QString resolveConfigFilePath()
{
QString dir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
if (dir.isEmpty()) {
dir = QDir::tempPath();
}
return QDir(dir).filePath(QStringLiteral("indexer_config.json"));
}
} // namespace
QString LezExplorerUiBackend::configFilePath()
{
if (m_configFilePath.isEmpty()) {
m_configFilePath = resolveConfigFilePath();
}
return m_configFilePath;
}
bool LezExplorerUiBackend::writeConfigFile(const QString& json)
{
const QString path = configFilePath();
const QDir dir = QFileInfo(path).dir();
if (!dir.exists() && !dir.mkpath(QStringLiteral("."))) {
return false;
}
QFile file(path);
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) {
return false;
}
const QByteArray bytes = json.toUtf8();
const bool ok = file.write(bytes) == bytes.size();
file.close();
return ok;
}
QString LezExplorerUiBackend::readConfigFile() const
{
QFile file(m_configFilePath.isEmpty() ? resolveConfigFilePath() : m_configFilePath);
if (!file.exists() || !file.open(QIODevice::ReadOnly | QIODevice::Text)) {
return {};
}
const QString content = QString::fromUtf8(file.readAll());
file.close();
return content;
}
bool LezExplorerUiBackend::startIndexerFromFile()
{
const qlonglong code = modules().lez_indexer_module.start_indexer(configFilePath(), m_port);
if (code != 0) {
emit error(QStringLiteral("start_indexer failed (code %1)").arg(code));
return false;
}
return true;
}

View File

@ -0,0 +1,76 @@
#pragma once
#include <QString>
#include <QVariantList>
#include <QVariantMap>
#include "logos_ui_plugin_context.h"
#include "rep_lez_explorer_ui_source.h"
class QTimer;
/**
* @brief Backend for the LEZ block explorer ui_qml view (universal model).
*
* The only hand-written C++ in this module: it derives the repc-generated
* `LezExplorerUiSimpleSource` (so it implements the `.rep` view contract) and
* `LogosUiPluginContext` (which supplies `onContextReady()` and the Qt-typed
* `modules()` accessors for the `lez_indexer_module` dependency). The
* `*Plugin` / `*Interface` glue is generated.
*
* It reads from the indexer with typed calls `modules().lez_indexer_module.*`
* which return the indexer's compact JSON as QString. This class converts
* that JSON into QVariantMap / QVariantList (schema preserved from the former
* `LpIndexerService`) for the QML view, and runs a 2 s poll on the chain tip to
* keep `recentBlocks` / `chainHeight` live (the indexer emits no events).
*
* Config is edited in-app (Settings page) as JSON, not as a path: the indexer's
* `start_indexer` takes a file path, so this class persists the edited JSON to a
* writable file and starts the indexer from there. The last-saved config is
* reloaded and auto-started on the next launch.
*/
class LezExplorerUiBackend : public LezExplorerUiSimpleSource, public LogosUiPluginContext {
public:
LezExplorerUiBackend();
~LezExplorerUiBackend() override;
// Fires when ui-host wires the plugin's LogosAPI: modules() is live, so we
// load any saved config, start it, and start the poll here.
void onContextReady() override;
// .rep SLOTs.
bool applyConfigJson(QString json, QString port) override;
void refreshBlocks() override;
void loadMoreBlocks() override;
QVariantMap getBlockById(QString id) override;
QVariantMap getBlockByHash(QString hash) override;
QVariantMap getTransaction(QString hash) override;
QVariantMap getAccount(QString accountId) override;
QVariantList getTransactionsByAccount(QString accountId, int offset, int limit) override;
QVariantMap search(QString query) override;
private:
// Tip poll: detect new heads and append/rebuild the recent-blocks feed.
void pollTip();
// Fetch a single block as a normalized map ({} if missing).
QVariantMap fetchBlock(quint64 blockId);
// Parse a getBlocks(...) JSON array payload into a list of block maps.
QVariantList parseBlockArrayJson(const QString& json) const;
// Writable path where the edited config JSON is persisted, and where the
// indexer is started from. Computed once (lazily).
QString configFilePath();
bool writeConfigFile(const QString& json);
QString readConfigFile() const;
// start_indexer from configFilePath(); returns true on success.
bool startIndexerFromFile();
QString m_port = QStringLiteral("8779");
QString m_configFilePath;
QVariantList m_recentBlocks;
quint64 m_newestLoadedId = 0; // highest block id currently in m_recentBlocks
quint64 m_oldestLoadedId = 0; // lowest block id currently in m_recentBlocks
quint64 m_lastPolledTip = 0;
bool m_polledOnce = false;
QTimer* m_pollTimer = nullptr;
};

View File

@ -1,11 +0,0 @@
#pragma once
#include <QString>
struct Account {
QString accountId;
QString programOwner;
QString balance;
QString nonce;
int dataSizeBytes = 0;
};

View File

@ -1,34 +0,0 @@
#pragma once
#include <QString>
#include <QVector>
#include <QDateTime>
#include "Transaction.h"
enum class BedrockStatus {
Pending,
Safe,
Finalized
};
inline QString bedrockStatusToString(BedrockStatus status)
{
switch (status) {
case BedrockStatus::Pending: return "Pending";
case BedrockStatus::Safe: return "Safe";
case BedrockStatus::Finalized: return "Finalized";
}
return "Unknown";
}
struct Block {
quint64 blockId = 0;
QString hash;
QString prevBlockHash;
QDateTime timestamp;
QString signature;
QVector<Transaction> transactions;
BedrockStatus bedrockStatus = BedrockStatus::Pending;
QString bedrockParentId;
};

View File

@ -1,47 +0,0 @@
#pragma once
#include <QString>
#include <QVector>
enum class TransactionType {
Public,
PrivacyPreserving,
ProgramDeployment
};
inline QString transactionTypeToString(TransactionType type)
{
switch (type) {
case TransactionType::Public: return "Public";
case TransactionType::PrivacyPreserving: return "Privacy-Preserving";
case TransactionType::ProgramDeployment: return "Program Deployment";
}
return "Unknown";
}
struct AccountRef {
QString accountId;
QString nonce;
};
struct Transaction {
QString hash;
TransactionType type = TransactionType::Public;
// Public transaction fields
QString programId;
QVector<AccountRef> accounts;
QVector<quint32> instructionData;
int signatureCount = 0;
int proofSizeBytes = 0;
// Privacy-preserving transaction fields
int newCommitmentsCount = 0;
int nullifiersCount = 0;
int encryptedStatesCount = 0;
quint64 validityWindowStart = 0;
quint64 validityWindowEnd = 0;
// Program deployment fields
int bytecodeSizeBytes = 0;
};

View File

@ -1,160 +0,0 @@
#include "AccountPage.h"
#include "Style.h"
#include "widgets/ClickableFrame.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QScrollArea>
#include <QFrame>
#include <QGridLayout>
#include <QIcon>
namespace {
QLabel* makeFieldLabel(const QString& text)
{
auto* label = new QLabel(text);
label->setStyleSheet(Style::mutedText() + " font-weight: bold;");
return label;
}
QLabel* makeValueLabel(const QString& text)
{
auto* label = new QLabel(text);
label->setTextInteractionFlags(Qt::TextSelectableByMouse);
label->setWordWrap(true);
return label;
}
} // namespace
AccountPage::AccountPage(const Account& account, IndexerService* indexer, QWidget* parent)
: QWidget(parent)
{
auto* outerLayout = new QVBoxLayout(this);
outerLayout->setContentsMargins(0, 0, 0, 0);
auto* scrollArea = new QScrollArea(this);
scrollArea->setWidgetResizable(true);
scrollArea->setFrameShape(QFrame::NoFrame);
scrollArea->setStyleSheet("QScrollArea { background: transparent; border: none; } QWidget#scrollContent { background: transparent; }");
auto* scrollContent = new QWidget();
scrollContent->setObjectName("scrollContent");
auto* layout = new QVBoxLayout(scrollContent);
layout->setAlignment(Qt::AlignTop);
layout->setContentsMargins(0, 0, 0, 0);
// Title with icon
auto* titleRow = new QHBoxLayout();
titleRow->setSpacing(10);
auto* titleIcon = new QLabel();
titleIcon->setPixmap(QIcon(":/icons/user.svg").pixmap(30, 30));
auto* title = new QLabel("Account Details");
QFont titleFont = title->font();
titleFont.setPointSize(24);
titleFont.setBold(true);
title->setFont(titleFont);
title->setStyleSheet(QString("color: %1;").arg(Style::Color::text()));
titleRow->addWidget(titleIcon);
titleRow->addWidget(title);
titleRow->addStretch();
layout->addLayout(titleRow);
layout->addSpacing(8);
// Account info grid
auto* infoFrame = new QFrame();
infoFrame->setFrameShape(QFrame::NoFrame);
infoFrame->setStyleSheet(Style::cardFrameWithLabels());
auto* grid = new QGridLayout(infoFrame);
grid->setColumnStretch(1, 1);
grid->setVerticalSpacing(10);
grid->setHorizontalSpacing(20);
int row = 0;
grid->addWidget(makeFieldLabel("Account ID"), row, 0);
auto* idVal = makeValueLabel(account.accountId);
idVal->setStyleSheet(Style::monoText());
grid->addWidget(idVal, row++, 1);
grid->addWidget(makeFieldLabel("Balance"), row, 0);
auto* balVal = makeValueLabel(account.balance);
balVal->setStyleSheet(QString("color: %1; font-weight: bold;").arg(Style::Color::green()));
grid->addWidget(balVal, row++, 1);
grid->addWidget(makeFieldLabel("Program Owner"), row, 0);
auto* ownerVal = makeValueLabel(account.programOwner);
ownerVal->setStyleSheet(Style::monoText());
grid->addWidget(ownerVal, row++, 1);
grid->addWidget(makeFieldLabel("Nonce"), row, 0);
grid->addWidget(makeValueLabel(account.nonce), row++, 1);
grid->addWidget(makeFieldLabel("Data Size"), row, 0);
grid->addWidget(makeValueLabel(QString("%1 bytes").arg(account.dataSizeBytes)), row++, 1);
layout->addWidget(infoFrame);
// Transaction history
auto transactions = indexer->getTransactionsByAccount(account.accountId, 0, 10);
if (!transactions.isEmpty()) {
auto* headerRow = new QHBoxLayout();
headerRow->setContentsMargins(0, 16, 0, 6);
headerRow->setSpacing(8);
auto* headerIcon = new QLabel();
headerIcon->setPixmap(QIcon(":/icons/file-text.svg").pixmap(24, 24));
auto* txHeader = new QLabel("Transaction History");
QFont headerFont = txHeader->font();
headerFont.setPointSize(20);
headerFont.setBold(true);
txHeader->setFont(headerFont);
txHeader->setStyleSheet(Style::sectionHeader());
headerRow->addWidget(headerIcon);
headerRow->addWidget(txHeader);
headerRow->addStretch();
layout->addLayout(headerRow);
for (const auto& tx : transactions) {
auto* frame = new ClickableFrame();
frame->setFrameShape(QFrame::NoFrame);
frame->setStyleSheet(Style::clickableRowWithLabels("ClickableFrame"));
auto* txRow = new QHBoxLayout(frame);
txRow->setSpacing(10);
auto* icon = new QLabel();
icon->setPixmap(QIcon(":/icons/file-text.svg").pixmap(20, 20));
txRow->addWidget(icon);
auto* hashLabel = new QLabel(tx.hash);
QFont boldFont = hashLabel->font();
boldFont.setBold(true);
hashLabel->setFont(boldFont);
QString typeStr = transactionTypeToString(tx.type);
auto* typeLabel = new QLabel(typeStr);
typeLabel->setStyleSheet(Style::badge(Style::txTypeColor(typeStr)));
txRow->addWidget(hashLabel);
txRow->addWidget(typeLabel);
txRow->addStretch();
QString txHash = tx.hash;
connect(frame, &ClickableFrame::clicked, this, [this, txHash]() {
emit transactionClicked(txHash);
});
layout->addWidget(frame);
}
} else {
auto* noTx = new QLabel("No transactions found for this account.");
noTx->setStyleSheet(Style::mutedText() + " margin-top: 16px;");
layout->addWidget(noTx);
}
scrollArea->setWidget(scrollContent);
outerLayout->addWidget(scrollArea);
}

View File

@ -1,16 +0,0 @@
#pragma once
#include "models/Account.h"
#include "services/IndexerService.h"
#include <QWidget>
class AccountPage : public QWidget {
Q_OBJECT
public:
explicit AccountPage(const Account& account, IndexerService* indexer, QWidget* parent = nullptr);
signals:
void transactionClicked(const QString& hash);
};

View File

@ -1,218 +0,0 @@
#include "BlockPage.h"
#include "Style.h"
#include "widgets/ClickableFrame.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QScrollArea>
#include <QFrame>
#include <QGridLayout>
#include <QIcon>
#include <QEvent>
#include <QMouseEvent>
namespace {
class PrevHashClickFilter : public QObject {
public:
PrevHashClickFilter(quint64 blockId, BlockPage* page, QObject* parent)
: QObject(parent), m_blockId(blockId), m_page(page) {}
protected:
bool eventFilter(QObject* obj, QEvent* event) override {
if (event->type() == QEvent::MouseButtonRelease) {
auto* me = static_cast<QMouseEvent*>(event);
if (me->button() == Qt::LeftButton) {
emit m_page->blockClicked(m_blockId);
return true;
}
}
return QObject::eventFilter(obj, event);
}
private:
quint64 m_blockId;
BlockPage* m_page;
};
QLabel* makeFieldLabel(const QString& text)
{
auto* label = new QLabel(text);
label->setStyleSheet(Style::mutedText() + " font-weight: bold;");
return label;
}
QLabel* makeValueLabel(const QString& text)
{
auto* label = new QLabel(text);
label->setTextInteractionFlags(Qt::TextSelectableByMouse);
label->setWordWrap(true);
return label;
}
} // namespace
BlockPage::BlockPage(const Block& block, QWidget* parent)
: QWidget(parent)
{
auto* outerLayout = new QVBoxLayout(this);
outerLayout->setContentsMargins(0, 0, 0, 0);
auto* scrollArea = new QScrollArea(this);
scrollArea->setWidgetResizable(true);
scrollArea->setFrameShape(QFrame::NoFrame);
scrollArea->setStyleSheet("QScrollArea { background: transparent; border: none; } QWidget#scrollContent { background: transparent; }");
auto* scrollContent = new QWidget();
scrollContent->setObjectName("scrollContent");
auto* layout = new QVBoxLayout(scrollContent);
layout->setAlignment(Qt::AlignTop);
layout->setContentsMargins(0, 0, 0, 0);
// Title with icon
auto* titleRow = new QHBoxLayout();
titleRow->setSpacing(10);
auto* titleIcon = new QLabel();
titleIcon->setPixmap(QIcon(":/icons/box.svg").pixmap(30, 30));
auto* title = new QLabel(QString("Block #%1").arg(block.blockId));
QFont titleFont = title->font();
titleFont.setPointSize(24);
titleFont.setBold(true);
title->setFont(titleFont);
title->setStyleSheet(QString("color: %1;").arg(Style::Color::text()));
titleRow->addWidget(titleIcon);
titleRow->addWidget(title);
titleRow->addStretch();
layout->addLayout(titleRow);
layout->addSpacing(8);
// Block info grid
auto* infoFrame = new QFrame();
infoFrame->setFrameShape(QFrame::NoFrame);
infoFrame->setStyleSheet(Style::cardFrameWithLabels());
auto* grid = new QGridLayout(infoFrame);
grid->setColumnStretch(1, 1);
grid->setVerticalSpacing(10);
grid->setHorizontalSpacing(20);
int row = 0;
grid->addWidget(makeFieldLabel("Block ID"), row, 0);
grid->addWidget(makeValueLabel(QString::number(block.blockId)), row++, 1);
grid->addWidget(makeFieldLabel("Hash"), row, 0);
auto* hashVal = makeValueLabel(block.hash);
hashVal->setStyleSheet(Style::monoText());
grid->addWidget(hashVal, row++, 1);
grid->addWidget(makeFieldLabel("Previous Hash"), row, 0);
if (block.blockId > 1) {
auto* prevHashLabel = new QLabel(block.prevBlockHash);
prevHashLabel->setStyleSheet(QString("color: %1; text-decoration: underline;").arg(Style::Color::accent()));
prevHashLabel->setCursor(Qt::PointingHandCursor);
prevHashLabel->setWordWrap(true);
prevHashLabel->setTextInteractionFlags(Qt::TextSelectableByMouse);
quint64 prevBlockId = block.blockId - 1;
prevHashLabel->installEventFilter(new PrevHashClickFilter(prevBlockId, this, this));
grid->addWidget(prevHashLabel, row++, 1);
} else {
grid->addWidget(makeValueLabel(block.prevBlockHash), row++, 1);
}
grid->addWidget(makeFieldLabel("Timestamp"), row, 0);
grid->addWidget(makeValueLabel(block.timestamp.toString("yyyy-MM-dd hh:mm:ss UTC")), row++, 1);
grid->addWidget(makeFieldLabel("Status"), row, 0);
QString statusStr = bedrockStatusToString(block.bedrockStatus);
auto* statusLabel = new QLabel(statusStr);
statusLabel->setStyleSheet(Style::badge(Style::statusColor(statusStr)));
statusLabel->setMaximumWidth(120);
grid->addWidget(statusLabel, row++, 1);
grid->addWidget(makeFieldLabel("Signature"), row, 0);
// Insert zero-width spaces every 32 chars so QLabel can word-wrap the long hex string
QString wrappableSig = block.signature;
for (int i = 32; i < wrappableSig.size(); i += 33) {
wrappableSig.insert(i, QChar(0x200B));
}
auto* sigLabel = new QLabel(wrappableSig);
sigLabel->setStyleSheet(Style::monoText());
sigLabel->setTextInteractionFlags(Qt::TextSelectableByMouse);
sigLabel->setWordWrap(true);
grid->addWidget(sigLabel, row++, 1);
grid->addWidget(makeFieldLabel("Transactions"), row, 0);
grid->addWidget(makeValueLabel(QString::number(block.transactions.size())), row++, 1);
layout->addWidget(infoFrame);
// Transactions section
if (!block.transactions.isEmpty()) {
auto* headerRow = new QHBoxLayout();
headerRow->setContentsMargins(0, 16, 0, 6);
headerRow->setSpacing(8);
auto* headerIcon = new QLabel();
headerIcon->setPixmap(QIcon(":/icons/file-text.svg").pixmap(24, 24));
auto* txHeader = new QLabel("Transactions");
QFont headerFont = txHeader->font();
headerFont.setPointSize(20);
headerFont.setBold(true);
txHeader->setFont(headerFont);
txHeader->setStyleSheet(Style::sectionHeader());
headerRow->addWidget(headerIcon);
headerRow->addWidget(txHeader);
headerRow->addStretch();
layout->addLayout(headerRow);
for (const auto& tx : block.transactions) {
auto* frame = new ClickableFrame();
frame->setFrameShape(QFrame::NoFrame);
frame->setStyleSheet(Style::clickableRowWithLabels("ClickableFrame"));
auto* txRow = new QHBoxLayout(frame);
txRow->setSpacing(10);
auto* icon = new QLabel();
icon->setPixmap(QIcon(":/icons/file-text.svg").pixmap(20, 20));
txRow->addWidget(icon);
auto* hashLabel = new QLabel(tx.hash);
QFont boldFont = hashLabel->font();
boldFont.setBold(true);
hashLabel->setFont(boldFont);
QString typeStr = transactionTypeToString(tx.type);
auto* typeLabel = new QLabel(typeStr);
typeLabel->setStyleSheet(Style::badge(Style::txTypeColor(typeStr)));
auto* metaLabel = new QLabel();
switch (tx.type) {
case TransactionType::Public:
metaLabel->setText(QString("%1 accounts").arg(tx.accounts.size()));
break;
case TransactionType::PrivacyPreserving:
metaLabel->setText(QString("%1 accounts, %2 commitments").arg(tx.accounts.size()).arg(tx.newCommitmentsCount));
break;
case TransactionType::ProgramDeployment:
metaLabel->setText(QString("%1 bytes").arg(tx.bytecodeSizeBytes));
break;
}
metaLabel->setStyleSheet(Style::mutedText());
txRow->addWidget(hashLabel);
txRow->addWidget(typeLabel);
txRow->addWidget(metaLabel, 1);
QString txHash = tx.hash;
connect(frame, &ClickableFrame::clicked, this, [this, txHash]() {
emit transactionClicked(txHash);
});
layout->addWidget(frame);
}
}
scrollArea->setWidget(scrollContent);
outerLayout->addWidget(scrollArea);
}

View File

@ -1,16 +0,0 @@
#pragma once
#include "models/Block.h"
#include <QWidget>
class BlockPage : public QWidget {
Q_OBJECT
public:
explicit BlockPage(const Block& block, QWidget* parent = nullptr);
signals:
void blockClicked(quint64 blockId);
void transactionClicked(const QString& hash);
};

View File

@ -1,456 +0,0 @@
#include "MainPage.h"
#include "Style.h"
#include "widgets/ClickableFrame.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QScrollArea>
#include <QFrame>
#include <QPixmap>
#include <QPushButton>
#include <QLineEdit>
#include <algorithm>
MainPage::MainPage(IndexerService* indexer, QWidget* parent)
: QWidget(parent)
, m_indexer(indexer)
{
auto* outerLayout = new QVBoxLayout(this);
outerLayout->setContentsMargins(0, 0, 0, 0);
// Indexer endpoint selector
auto* endpointRow = new QHBoxLayout();
endpointRow->setSpacing(8);
auto* endpointLabel = new QLabel("Indexer RPC", this);
endpointLabel->setStyleSheet(Style::mutedText() + " font-weight: bold;");
m_indexerEndpointInput = new QLineEdit(this);
m_indexerEndpointInput->setText(m_indexer->endpoint());
m_indexerEndpointInput->setPlaceholderText(IndexerService::defaultEndpoint());
m_indexerEndpointInput->setMinimumHeight(34);
m_indexerEndpointInput->setStyleSheet(Style::searchInput());
auto* applyEndpointBtn = new QPushButton("Apply", this);
applyEndpointBtn->setMinimumHeight(34);
applyEndpointBtn->setStyleSheet(Style::searchButton());
endpointRow->addWidget(endpointLabel);
endpointRow->addWidget(m_indexerEndpointInput, 1);
endpointRow->addWidget(applyEndpointBtn);
outerLayout->addLayout(endpointRow);
connect(applyEndpointBtn, &QPushButton::clicked, this, &MainPage::applyIndexerEndpoint);
connect(m_indexerEndpointInput, &QLineEdit::returnPressed, this, &MainPage::applyIndexerEndpoint);
// Health indicator
auto* healthRow = new QHBoxLayout();
auto* healthIcon = new QLabel(this);
healthIcon->setPixmap(QIcon(":/icons/activity.svg").pixmap(20, 20));
m_healthLabel = new QLabel(this);
healthRow->addWidget(healthIcon);
healthRow->addWidget(m_healthLabel);
healthRow->addStretch();
outerLayout->addLayout(healthRow);
// Scroll area for content
auto* scrollArea = new QScrollArea(this);
scrollArea->setWidgetResizable(true);
scrollArea->setFrameShape(QFrame::NoFrame);
scrollArea->setStyleSheet("QScrollArea { background: transparent; border: none; } QWidget#scrollContent { background: transparent; }");
auto* scrollContent = new QWidget();
scrollContent->setObjectName("scrollContent");
m_contentLayout = new QVBoxLayout(scrollContent);
m_contentLayout->setAlignment(Qt::AlignTop);
m_contentLayout->setContentsMargins(0, 0, 0, 0);
scrollArea->setWidget(scrollContent);
outerLayout->addWidget(scrollArea);
refresh();
}
QWidget* MainPage::createSectionHeader(const QString& title, const QString& iconPath)
{
auto* container = new QWidget();
auto* layout = new QHBoxLayout(container);
layout->setContentsMargins(0, 12, 0, 6);
layout->setSpacing(8);
layout->setAlignment(Qt::AlignVCenter);
if (!iconPath.isEmpty()) {
auto* icon = new QLabel();
icon->setPixmap(QIcon(iconPath).pixmap(24, 24));
icon->setFixedSize(24, 24);
layout->addWidget(icon);
}
auto* label = new QLabel(title);
QFont font = label->font();
font.setPointSize(20);
font.setBold(true);
label->setFont(font);
label->setStyleSheet(Style::sectionHeader());
layout->addWidget(label);
layout->addStretch();
return container;
}
void MainPage::addBlockRow(QVBoxLayout* layout, const Block& block, int insertIndex)
{
auto* frame = new ClickableFrame();
frame->setFrameShape(QFrame::NoFrame);
frame->setStyleSheet(Style::clickableRowWithLabels("ClickableFrame"));
frame->setProperty("blockId", QVariant::fromValue<qulonglong>(block.blockId));
auto* row = new QHBoxLayout(frame);
row->setSpacing(10);
auto* icon = new QLabel();
icon->setPixmap(QIcon(":/icons/box.svg").pixmap(20, 20));
row->addWidget(icon);
auto* idLabel = new QLabel(QString("Block #%1").arg(block.blockId));
QFont boldFont = idLabel->font();
boldFont.setBold(true);
idLabel->setFont(boldFont);
QString statusStr = bedrockStatusToString(block.bedrockStatus);
auto* statusLabel = new QLabel(statusStr);
statusLabel->setStyleSheet(Style::badge(Style::statusColor(statusStr)));
auto* hashLabel = new QLabel(block.hash);
hashLabel->setStyleSheet(Style::mutedText() + " font-family: 'Menlo', 'Courier New'; font-size: 11px;");
hashLabel->setFixedWidth(470);
auto* txIcon = new QLabel();
txIcon->setPixmap(QIcon(":/icons/file-text.svg").pixmap(18, 18));
auto* txCount = new QLabel(QString::number(block.transactions.size()));
auto* timeLabel = new QLabel(block.timestamp.toString("yyyy-MM-dd hh:mm:ss UTC"));
timeLabel->setStyleSheet(Style::mutedText());
statusLabel->setFixedWidth(90);
statusLabel->setAlignment(Qt::AlignCenter);
row->addWidget(idLabel);
row->addWidget(hashLabel);
row->addStretch(1);
row->addWidget(txIcon);
row->addWidget(txCount);
row->addWidget(timeLabel);
row->addWidget(statusLabel);
quint64 blockId = block.blockId;
connect(frame, &ClickableFrame::clicked, this, [this, blockId]() {
emit blockClicked(blockId);
});
if (insertIndex >= 0) {
layout->insertWidget(insertIndex, frame);
} else {
layout->addWidget(frame);
}
}
void MainPage::insertRecentBlock(const Block& block)
{
if (!m_blocksLayout || m_displayedBlockIds.contains(block.blockId)) {
return;
}
int insertIndex = m_blocksLayout->count();
for (int i = 0; i < m_blocksLayout->count(); ++i) {
QLayoutItem* item = m_blocksLayout->itemAt(i);
if (!item || !item->widget()) {
continue;
}
bool ok = false;
const quint64 existingId = item->widget()->property("blockId").toULongLong(&ok);
if (ok && block.blockId > existingId) {
insertIndex = i;
break;
}
}
addBlockRow(m_blocksLayout, block, insertIndex);
m_displayedBlockIds.insert(block.blockId);
if (!m_newestLoadedBlockId || block.blockId > *m_newestLoadedBlockId) {
m_newestLoadedBlockId = block.blockId;
}
if (!m_oldestLoadedBlockId || block.blockId < *m_oldestLoadedBlockId) {
m_oldestLoadedBlockId = block.blockId;
}
}
QVector<Block> MainPage::fetchRecentBlocks(int limit)
{
if (limit <= 0) {
return {};
}
auto blocks = m_indexer->getBlocks(std::nullopt, limit);
std::sort(blocks.begin(), blocks.end(), [](const Block& lhs, const Block& rhs) {
return lhs.blockId > rhs.blockId;
});
if (blocks.size() > limit) {
blocks.resize(limit);
}
return blocks;
}
void MainPage::addTransactionRow(QVBoxLayout* layout, const Transaction& tx)
{
auto* frame = new ClickableFrame();
frame->setFrameShape(QFrame::NoFrame);
frame->setStyleSheet(Style::clickableRowWithLabels("ClickableFrame"));
auto* row = new QHBoxLayout(frame);
row->setSpacing(10);
auto* icon = new QLabel();
icon->setPixmap(QIcon(":/icons/file-text.svg").pixmap(20, 20));
row->addWidget(icon);
auto* hashLabel = new QLabel(tx.hash);
QFont boldFont = hashLabel->font();
boldFont.setBold(true);
hashLabel->setFont(boldFont);
QString typeStr = transactionTypeToString(tx.type);
auto* typeLabel = new QLabel(typeStr);
typeLabel->setStyleSheet(Style::badge(Style::txTypeColor(typeStr)));
auto* metaLabel = new QLabel();
switch (tx.type) {
case TransactionType::Public:
metaLabel->setText(QString("%1 accounts").arg(tx.accounts.size()));
break;
case TransactionType::PrivacyPreserving:
metaLabel->setText(QString("%1 accounts, %2 commitments").arg(tx.accounts.size()).arg(tx.newCommitmentsCount));
break;
case TransactionType::ProgramDeployment:
metaLabel->setText(QString("%1 bytes").arg(tx.bytecodeSizeBytes));
break;
}
metaLabel->setStyleSheet(Style::mutedText());
row->addWidget(hashLabel);
row->addWidget(typeLabel);
row->addWidget(metaLabel, 1);
QString txHash = tx.hash;
connect(frame, &ClickableFrame::clicked, this, [this, txHash]() {
emit transactionClicked(txHash);
});
layout->addWidget(frame);
}
void MainPage::addAccountRow(QVBoxLayout* layout, const Account& account)
{
auto* frame = new ClickableFrame();
frame->setFrameShape(QFrame::NoFrame);
frame->setStyleSheet(Style::clickableRowWithLabels("ClickableFrame"));
auto* row = new QHBoxLayout(frame);
row->setSpacing(10);
auto* icon = new QLabel();
icon->setPixmap(QIcon(":/icons/user.svg").pixmap(20, 20));
row->addWidget(icon);
auto* idLabel = new QLabel(account.accountId);
QFont boldFont = idLabel->font();
boldFont.setBold(true);
idLabel->setFont(boldFont);
auto* balanceLabel = new QLabel(QString("Balance: %1").arg(account.balance));
auto* nonceLabel = new QLabel(QString("Nonce: %1").arg(account.nonce));
nonceLabel->setStyleSheet(Style::mutedText());
row->addWidget(idLabel);
row->addWidget(balanceLabel, 1);
row->addWidget(nonceLabel);
QString accId = account.accountId;
connect(frame, &ClickableFrame::clicked, this, [this, accId]() {
emit accountClicked(accId);
});
layout->addWidget(frame);
}
void MainPage::refresh()
{
clearSearchResults();
quint64 latestId = m_indexer->getLastFinalizedBlockId();
m_latestKnownBlockId = latestId;
m_healthLabel->setText(QString("Chain height: %1").arg(m_latestKnownBlockId));
m_healthLabel->setStyleSheet(Style::healthLabel());
if (m_recentBlocksWidget) {
m_contentLayout->removeWidget(m_recentBlocksWidget);
m_recentBlocksWidget->deleteLater();
m_recentBlocksWidget = nullptr;
m_blocksLayout = nullptr;
m_loadMoreBtn = nullptr;
}
m_newestLoadedBlockId = std::nullopt;
m_oldestLoadedBlockId = std::nullopt;
m_displayedBlockIds.clear();
m_recentBlocksWidget = new QWidget();
auto* outerLayout = new QVBoxLayout(m_recentBlocksWidget);
outerLayout->setContentsMargins(0, 0, 0, 0);
outerLayout->addWidget(createSectionHeader("Recent Blocks", ":/icons/box.svg"));
// Dedicated layout for block rows — button is appended after this in outerLayout
auto* blockRowsWidget = new QWidget();
m_blocksLayout = new QVBoxLayout(blockRowsWidget);
m_blocksLayout->setContentsMargins(0, 0, 0, 0);
outerLayout->addWidget(blockRowsWidget);
auto blocks = fetchRecentBlocks(10);
for (const auto& block : blocks) {
insertRecentBlock(block);
}
m_loadMoreBtn = new QPushButton("Load more");
m_loadMoreBtn->setStyleSheet(Style::searchButton());
m_loadMoreBtn->setVisible(m_oldestLoadedBlockId && *m_oldestLoadedBlockId > 1);
connect(m_loadMoreBtn, &QPushButton::clicked, this, &MainPage::loadMoreBlocks);
outerLayout->addWidget(m_loadMoreBtn, 0, Qt::AlignHCenter);
m_contentLayout->addWidget(m_recentBlocksWidget);
}
void MainPage::loadMoreBlocks()
{
if (!m_oldestLoadedBlockId || *m_oldestLoadedBlockId <= 1)
return;
auto blocks = m_indexer->getBlocks(m_oldestLoadedBlockId, 10);
std::sort(blocks.begin(), blocks.end(), [](const Block& lhs, const Block& rhs) {
return lhs.blockId > rhs.blockId;
});
for (const auto& block : blocks) {
insertRecentBlock(block);
}
m_loadMoreBtn->setVisible(m_oldestLoadedBlockId && *m_oldestLoadedBlockId > 1 && !blocks.isEmpty());
}
void MainPage::applyIndexerEndpoint()
{
QString endpoint = m_indexerEndpointInput->text().trimmed();
if (endpoint.isEmpty()) {
endpoint = IndexerService::defaultEndpoint();
m_indexerEndpointInput->setText(endpoint);
}
m_indexer->setEndpoint(endpoint);
clearSearchResults();
refresh();
}
void MainPage::showSearchResults(const SearchResults& results)
{
clearSearchResults();
if (m_recentBlocksWidget) {
m_recentBlocksWidget->hide();
}
m_searchResultsWidget = new QWidget();
auto* layout = new QVBoxLayout(m_searchResultsWidget);
layout->setContentsMargins(0, 0, 0, 0);
bool hasResults = false;
if (!results.blocks.isEmpty()) {
layout->addWidget(createSectionHeader("Blocks", ":/icons/box.svg"));
for (const auto& block : results.blocks) {
addBlockRow(layout, block);
}
hasResults = true;
}
if (!results.transactions.isEmpty()) {
layout->addWidget(createSectionHeader("Transactions", ":/icons/file-text.svg"));
for (const auto& tx : results.transactions) {
addTransactionRow(layout, tx);
}
hasResults = true;
}
if (!results.accounts.isEmpty()) {
layout->addWidget(createSectionHeader("Accounts", ":/icons/user.svg"));
for (const auto& acc : results.accounts) {
addAccountRow(layout, acc);
}
hasResults = true;
}
if (!hasResults) {
auto* noResults = new QLabel("No results found.");
noResults->setAlignment(Qt::AlignCenter);
noResults->setStyleSheet(Style::mutedText() + " padding: 20px; font-size: 14px;");
layout->addWidget(noResults);
}
m_contentLayout->insertWidget(0, m_searchResultsWidget);
}
void MainPage::onNewBlock(const Block& block)
{
if (!m_blocksLayout) return;
m_latestKnownBlockId = std::max(m_latestKnownBlockId, block.blockId);
m_healthLabel->setText(QString("Chain height: %1").arg(m_latestKnownBlockId));
// If notifications jump ahead, rebuild recent list from backend
// so we do not mix one fresh head block with stale rows.
if (!m_newestLoadedBlockId) {
refresh();
return;
}
if (block.blockId > *m_newestLoadedBlockId && (block.blockId - *m_newestLoadedBlockId) > 1) {
refresh();
return;
}
insertRecentBlock(block);
if (m_loadMoreBtn) {
m_loadMoreBtn->setVisible(m_oldestLoadedBlockId && *m_oldestLoadedBlockId > 1);
}
}
void MainPage::clearSearchResults()
{
if (m_searchResultsWidget) {
m_contentLayout->removeWidget(m_searchResultsWidget);
m_searchResultsWidget->deleteLater();
m_searchResultsWidget = nullptr;
}
if (m_recentBlocksWidget) {
m_recentBlocksWidget->show();
}
}

View File

@ -1,52 +0,0 @@
#pragma once
#include "services/IndexerService.h"
#include <QWidget>
#include <QSet>
#include <optional>
class QVBoxLayout;
class QLabel;
class QPushButton;
class QLineEdit;
class MainPage : public QWidget {
Q_OBJECT
public:
explicit MainPage(IndexerService* indexer, QWidget* parent = nullptr);
void refresh();
void showSearchResults(const SearchResults& results);
void clearSearchResults();
void onNewBlock(const Block& block);
signals:
void blockClicked(quint64 blockId);
void transactionClicked(const QString& hash);
void accountClicked(const QString& accountId);
private:
void addBlockRow(QVBoxLayout* layout, const Block& block, int insertIndex = -1);
void addTransactionRow(QVBoxLayout* layout, const Transaction& tx);
void addAccountRow(QVBoxLayout* layout, const Account& account);
QWidget* createSectionHeader(const QString& title, const QString& iconPath = {});
QVector<Block> fetchRecentBlocks(int limit);
void insertRecentBlock(const Block& block);
void loadMoreBlocks();
void applyIndexerEndpoint();
IndexerService* m_indexer = nullptr;
QVBoxLayout* m_contentLayout = nullptr;
QWidget* m_searchResultsWidget = nullptr;
QWidget* m_recentBlocksWidget = nullptr;
QVBoxLayout* m_blocksLayout = nullptr;
QPushButton* m_loadMoreBtn = nullptr;
QLabel* m_healthLabel = nullptr;
QLineEdit* m_indexerEndpointInput = nullptr;
std::optional<quint64> m_newestLoadedBlockId;
std::optional<quint64> m_oldestLoadedBlockId;
QSet<quint64> m_displayedBlockIds;
quint64 m_latestKnownBlockId = 0;
};

View File

@ -1,185 +0,0 @@
#include "TransactionPage.h"
#include "Style.h"
#include "widgets/ClickableFrame.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QScrollArea>
#include <QFrame>
#include <QGridLayout>
#include <QIcon>
namespace {
QLabel* makeFieldLabel(const QString& text)
{
auto* label = new QLabel(text);
label->setStyleSheet(Style::mutedText() + " font-weight: bold;");
return label;
}
QLabel* makeValueLabel(const QString& text)
{
auto* label = new QLabel(text);
label->setTextInteractionFlags(Qt::TextSelectableByMouse);
label->setWordWrap(true);
return label;
}
} // namespace
TransactionPage::TransactionPage(const Transaction& tx, QWidget* parent)
: QWidget(parent)
{
auto* outerLayout = new QVBoxLayout(this);
outerLayout->setContentsMargins(0, 0, 0, 0);
auto* scrollArea = new QScrollArea(this);
scrollArea->setWidgetResizable(true);
scrollArea->setFrameShape(QFrame::NoFrame);
scrollArea->setStyleSheet("QScrollArea { background: transparent; border: none; } QWidget#scrollContent { background: transparent; }");
auto* scrollContent = new QWidget();
scrollContent->setObjectName("scrollContent");
auto* layout = new QVBoxLayout(scrollContent);
layout->setAlignment(Qt::AlignTop);
layout->setContentsMargins(0, 0, 0, 0);
// Title with icon
auto* titleRow = new QHBoxLayout();
titleRow->setSpacing(10);
auto* titleIcon = new QLabel();
titleIcon->setPixmap(QIcon(":/icons/file-text.svg").pixmap(30, 30));
auto* title = new QLabel("Transaction Details");
QFont titleFont = title->font();
titleFont.setPointSize(24);
titleFont.setBold(true);
title->setFont(titleFont);
title->setStyleSheet(QString("color: %1;").arg(Style::Color::text()));
titleRow->addWidget(titleIcon);
titleRow->addWidget(title);
titleRow->addStretch();
layout->addLayout(titleRow);
layout->addSpacing(8);
// Transaction info grid
auto* infoFrame = new QFrame();
infoFrame->setFrameShape(QFrame::NoFrame);
infoFrame->setStyleSheet(Style::cardFrameWithLabels());
auto* grid = new QGridLayout(infoFrame);
grid->setColumnStretch(1, 1);
grid->setVerticalSpacing(10);
grid->setHorizontalSpacing(20);
int row = 0;
grid->addWidget(makeFieldLabel("Hash"), row, 0);
auto* hashVal = makeValueLabel(tx.hash);
hashVal->setStyleSheet(Style::monoText());
grid->addWidget(hashVal, row++, 1);
grid->addWidget(makeFieldLabel("Type"), row, 0);
QString typeStr = transactionTypeToString(tx.type);
auto* typeLabel = new QLabel(typeStr);
typeLabel->setStyleSheet(Style::badge(Style::txTypeColor(typeStr)));
typeLabel->setMaximumWidth(180);
grid->addWidget(typeLabel, row++, 1);
// Type-specific fields
switch (tx.type) {
case TransactionType::Public:
grid->addWidget(makeFieldLabel("Program ID"), row, 0);
{ auto* v = makeValueLabel(tx.programId); v->setStyleSheet(Style::monoText()); grid->addWidget(v, row++, 1); }
grid->addWidget(makeFieldLabel("Instruction Data"), row, 0);
grid->addWidget(makeValueLabel(QString("%1 items").arg(tx.instructionData.size())), row++, 1);
grid->addWidget(makeFieldLabel("Proof Size"), row, 0);
grid->addWidget(makeValueLabel(QString("%1 bytes").arg(tx.proofSizeBytes)), row++, 1);
grid->addWidget(makeFieldLabel("Signatures"), row, 0);
grid->addWidget(makeValueLabel(QString::number(tx.signatureCount)), row++, 1);
break;
case TransactionType::PrivacyPreserving:
grid->addWidget(makeFieldLabel("Public Accounts"), row, 0);
grid->addWidget(makeValueLabel(QString::number(tx.accounts.size())), row++, 1);
grid->addWidget(makeFieldLabel("New Commitments"), row, 0);
grid->addWidget(makeValueLabel(QString::number(tx.newCommitmentsCount)), row++, 1);
grid->addWidget(makeFieldLabel("Nullifiers"), row, 0);
grid->addWidget(makeValueLabel(QString::number(tx.nullifiersCount)), row++, 1);
grid->addWidget(makeFieldLabel("Encrypted States"), row, 0);
grid->addWidget(makeValueLabel(QString::number(tx.encryptedStatesCount)), row++, 1);
grid->addWidget(makeFieldLabel("Proof Size"), row, 0);
grid->addWidget(makeValueLabel(QString("%1 bytes").arg(tx.proofSizeBytes)), row++, 1);
grid->addWidget(makeFieldLabel("Validity Window"), row, 0);
grid->addWidget(makeValueLabel(QString("[%1, %2)").arg(tx.validityWindowStart).arg(tx.validityWindowEnd)), row++, 1);
break;
case TransactionType::ProgramDeployment:
grid->addWidget(makeFieldLabel("Bytecode Size"), row, 0);
grid->addWidget(makeValueLabel(QString("%1 bytes").arg(tx.bytecodeSizeBytes)), row++, 1);
break;
}
layout->addWidget(infoFrame);
// Accounts section
if (!tx.accounts.isEmpty()) {
auto* headerRow = new QHBoxLayout();
headerRow->setContentsMargins(0, 16, 0, 6);
headerRow->setSpacing(8);
auto* headerIcon = new QLabel();
headerIcon->setPixmap(QIcon(":/icons/user.svg").pixmap(24, 24));
auto* accHeader = new QLabel("Accounts");
QFont headerFont = accHeader->font();
headerFont.setPointSize(20);
headerFont.setBold(true);
accHeader->setFont(headerFont);
accHeader->setStyleSheet(Style::sectionHeader());
headerRow->addWidget(headerIcon);
headerRow->addWidget(accHeader);
headerRow->addStretch();
layout->addLayout(headerRow);
for (const auto& accRef : tx.accounts) {
auto* frame = new ClickableFrame();
frame->setFrameShape(QFrame::NoFrame);
frame->setStyleSheet(Style::clickableRowWithLabels("ClickableFrame"));
auto* accRow = new QHBoxLayout(frame);
accRow->setSpacing(10);
auto* icon = new QLabel();
icon->setPixmap(QIcon(":/icons/user.svg").pixmap(20, 20));
accRow->addWidget(icon);
auto* idLabel = new QLabel(accRef.accountId);
QFont boldFont = idLabel->font();
boldFont.setBold(true);
idLabel->setFont(boldFont);
auto* nonceLabel = new QLabel(QString("Nonce: %1").arg(accRef.nonce));
nonceLabel->setStyleSheet(Style::mutedText());
accRow->addWidget(idLabel, 1);
accRow->addWidget(nonceLabel);
QString accId = accRef.accountId;
connect(frame, &ClickableFrame::clicked, this, [this, accId]() {
emit accountClicked(accId);
});
layout->addWidget(frame);
}
}
scrollArea->setWidget(scrollContent);
outerLayout->addWidget(scrollArea);
}

View File

@ -1,15 +0,0 @@
#pragma once
#include "models/Transaction.h"
#include <QWidget>
class TransactionPage : public QWidget {
Q_OBJECT
public:
explicit TransactionPage(const Transaction& tx, QWidget* parent = nullptr);
signals:
void accountClicked(const QString& accountId);
};

165
src/qml/Main.qml Normal file
View File

@ -0,0 +1,165 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Logos.Controls
import Logos.Theme
import "components"
Rectangle {
id: root
color: Theme.palette.background
// Typed replica of the backend; non-null once the plugin is registered,
// `ready` tracks the live connection.
readonly property var backend: logos.module("lez_explorer_ui")
property bool ready: false
// Self-managed navigation history (browser-style back/forward). Each entry
// is a descriptor { type, param?, query? }.
property var history: []
property int histIndex: -1
// --- Navigation API (pages call these via the injected `explorer`) ---
function sameDescriptor(a, b) {
if (!a || !b)
return false;
if (a.type !== b.type)
return false;
if (a.type === "home")
return true;
if (a.type === "search")
return a.query === b.query;
return a.param === b.param;
}
function navigateTo(desc) {
// Navigating to the page we're already on is a no-op don't grow the
// history (so e.g. Home-while-home doesn't wake the Back button).
var current = root.histIndex >= 0 ? root.history[root.histIndex] : null;
if (root.sameDescriptor(current, desc))
return;
var h = root.history.slice(0, root.histIndex + 1);
h.push(desc);
root.history = h;
root.histIndex = h.length - 1;
root.render();
}
function navigateBlock(id) { root.navigateTo({ type: "block", param: id }); }
function navigateTx(hash) { root.navigateTo({ type: "tx", param: hash }); }
function navigateAccount(id) { root.navigateTo({ type: "account", param: id }); }
function navigateSettings() { root.navigateTo({ type: "settings" }); }
function showSearchResults(results, query) {
root.navigateTo({ type: "search", param: results, query: query });
}
function goHome() { root.navigateTo({ type: "home" }); }
function goBack() { if (root.histIndex > 0) { root.histIndex--; root.render(); } }
function goForward() { if (root.histIndex < root.history.length - 1) { root.histIndex++; root.render(); } }
function render() {
if (root.histIndex < 0)
return;
var d = root.history[root.histIndex];
var props = { explorer: root };
var url = "pages/HomePage.qml";
if (d.type === "block") {
url = "pages/BlockPage.qml";
props.blockId = d.param;
} else if (d.type === "tx") {
url = "pages/TransactionPage.qml";
props.txHash = d.param;
} else if (d.type === "account") {
url = "pages/AccountPage.qml";
props.accountId = d.param;
} else if (d.type === "search") {
url = "pages/SearchResultsPage.qml";
props.results = d.param;
props.query = d.query || "";
} else if (d.type === "settings") {
url = "pages/SettingsPage.qml";
}
pageLoader.setSource(Qt.resolvedUrl(url), props);
navBar.canGoBack = root.histIndex > 0;
navBar.canGoForward = root.histIndex < root.history.length - 1;
}
Connections {
target: logos
function onViewModuleReadyChanged(moduleName, isReady) {
if (moduleName === "lez_explorer_ui")
root.ready = isReady && root.backend !== null;
}
}
Component.onCompleted: {
root.ready = root.backend !== null && logos.isViewModuleReady("lez_explorer_ui");
root.goHome();
}
ColumnLayout {
anchors.fill: parent
anchors.margins: Theme.spacing.large
spacing: Theme.spacing.medium
NavigationBar {
id: navBar
Layout.fillWidth: true
onBackClicked: root.goBack()
onForwardClicked: root.goForward()
onHomeClicked: root.goHome()
onSettingsClicked: root.navigateSettings()
}
SearchBar {
Layout.fillWidth: true
onSearchRequested: function (query) {
if (!root.backend || query.trim().length === 0)
return;
logos.watch(root.backend.search(query), function (results) {
var blocks = results && results.blocks ? results.blocks : [];
var txs = results && results.transactions ? results.transactions : [];
var accts = results && results.accounts ? results.accounts : [];
var total = blocks.length + txs.length + accts.length;
if (total === 1) {
if (blocks.length === 1)
root.navigateBlock(blocks[0].blockId);
else if (txs.length === 1)
root.navigateTx(txs[0].hash);
else
root.navigateAccount(accts[0].accountId);
} else {
root.showSearchResults(results, query);
}
}, function (err) {
root.showSearchResults({ blocks: [], transactions: [], accounts: [] }, query);
});
}
}
Loader {
id: pageLoader
Layout.fillWidth: true
Layout.fillHeight: true
}
}
// Readiness overlay covers the view until the backend connects.
Rectangle {
anchors.fill: parent
visible: !root.ready
color: Theme.palette.background
ColumnLayout {
anchors.centerIn: parent
spacing: Theme.spacing.medium
BusyIndicator {
running: !root.ready
Layout.alignment: Qt.AlignHCenter
}
LogosText {
text: "Connecting to indexer backend…"
color: Theme.palette.textSecondary
font.pixelSize: Theme.typography.secondaryText
Layout.alignment: Qt.AlignHCenter
}
}
}
}

View File

@ -0,0 +1,57 @@
import QtQuick
import QtQuick.Layouts
import Logos.Controls
import Logos.Theme
// Clickable row for an account reference ({ accountId, nonce?, balance? }).
// Used for tx account lists and account search results.
Rectangle {
id: root
property var account: ({})
signal clicked()
height: layout.implicitHeight + Theme.spacing.medium * 2
radius: Theme.spacing.radiusMedium
color: hover.hovered ? Theme.palette.backgroundSecondary : "transparent"
border.width: 1
border.color: hover.hovered ? Theme.palette.borderInteractive : Theme.palette.borderSecondary
HoverHandler { id: hover }
TapHandler { onTapped: root.clicked() }
RowLayout {
id: layout
anchors.fill: parent
anchors.margins: Theme.spacing.medium
spacing: Theme.spacing.medium
ColumnLayout {
Layout.fillWidth: true
spacing: Theme.spacing.tiny
MonoText {
text: root.account.accountId || ""
color: Theme.palette.text
wrapMode: Text.NoWrap
elide: Text.ElideMiddle
Layout.fillWidth: true
}
LogosText {
visible: root.account.nonce !== undefined
text: "Nonce: " + (root.account.nonce !== undefined ? root.account.nonce : "")
color: Theme.palette.textMuted
font.pixelSize: Theme.typography.secondaryText
}
}
LogosText {
visible: root.account.balance !== undefined
text: (root.account.balance !== undefined ? root.account.balance : "")
color: Theme.palette.success
font.pixelSize: Theme.typography.secondaryText
font.weight: Theme.typography.weightBold
Layout.alignment: Qt.AlignVCenter
}
}
}

View File

@ -0,0 +1,27 @@
import QtQuick
import Logos.Controls
import Logos.Theme
// A small coloured pill. `accent` tints both the text and a translucent fill.
Rectangle {
id: root
property string text: ""
property color accent: Theme.palette.textSecondary
implicitWidth: label.implicitWidth + Theme.spacing.medium * 2
implicitHeight: label.implicitHeight + Theme.spacing.small
radius: Theme.spacing.radiusSmall
color: Qt.rgba(root.accent.r, root.accent.g, root.accent.b, 0.15)
border.width: 1
border.color: Qt.rgba(root.accent.r, root.accent.g, root.accent.b, 0.4)
LogosText {
id: label
anchors.centerIn: parent
text: root.text
color: root.accent
font.pixelSize: Theme.typography.badgeText
font.weight: Theme.typography.weightBold
}
}

View File

@ -0,0 +1,59 @@
import QtQuick
import QtQuick.Layouts
import Logos.Controls
import Logos.Theme
// Clickable summary row for a block map ({ blockId, hash, txCount,
// timestampText, status }). Used on the home feed and in search results.
Rectangle {
id: root
property var block: ({})
signal clicked()
height: layout.implicitHeight + Theme.spacing.medium * 2
radius: Theme.spacing.radiusMedium
color: hover.hovered ? Theme.palette.backgroundSecondary : "transparent"
border.width: 1
border.color: hover.hovered ? Theme.palette.borderInteractive : Theme.palette.borderSecondary
HoverHandler { id: hover }
TapHandler { onTapped: root.clicked() }
RowLayout {
id: layout
anchors.fill: parent
anchors.margins: Theme.spacing.medium
spacing: Theme.spacing.medium
ColumnLayout {
Layout.fillWidth: true
spacing: Theme.spacing.tiny
LogosText {
text: "Block #" + (root.block.blockId !== undefined ? root.block.blockId : "?")
color: Theme.palette.text
font.pixelSize: Theme.typography.primaryText
font.weight: Theme.typography.weightBold
}
MonoText {
text: root.block.hash || ""
color: Theme.palette.textSecondary
wrapMode: Text.NoWrap
elide: Text.ElideMiddle
Layout.fillWidth: true
}
LogosText {
text: (root.block.txCount !== undefined ? root.block.txCount : 0) + " txs · "
+ (root.block.timestampText || "")
color: Theme.palette.textMuted
font.pixelSize: Theme.typography.secondaryText
}
}
StatusBadge {
status: root.block.status || "Pending"
Layout.alignment: Qt.AlignVCenter
}
}
}

View File

@ -0,0 +1,30 @@
import QtQuick
import QtQuick.Layouts
import Logos.Theme
// A themed surface panel that sizes to its content. Children are stacked
// vertically (they become items of the inner ColumnLayout).
Rectangle {
id: root
property int padding: Theme.spacing.large
property int spacing: Theme.spacing.medium
default property alias content: inner.data
implicitHeight: inner.implicitHeight + padding * 2
implicitWidth: inner.implicitWidth + padding * 2
color: Theme.palette.backgroundElevated
border.color: Theme.palette.borderSecondary
border.width: 1
radius: Theme.spacing.radiusLarge
ColumnLayout {
id: inner
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: root.padding
spacing: root.spacing
}
}

View File

@ -0,0 +1,55 @@
import QtQuick
import Logos.Theme
// Tiny copy-to-clipboard button. Self-contained: writes `value` to the system
// clipboard via a hidden TextEdit (which runs in the host GUI process, so it
// reaches the real desktop clipboard), then flips to a check mark briefly as
// confirmation.
Item {
id: root
property string value: ""
property int size: 22
implicitWidth: size
implicitHeight: size
// Off-screen helper used purely to push text onto the clipboard.
TextEdit { id: clip; visible: false }
Rectangle {
anchors.fill: parent
radius: Theme.spacing.radiusSmall
color: hover.hovered ? Theme.palette.backgroundSecondary : "transparent"
HoverHandler { id: hover }
TapHandler {
onTapped: {
clip.text = root.value;
clip.selectAll();
clip.copy();
clip.text = "";
icon.source = icon.checkUrl;
resetTimer.restart();
}
}
Image {
id: icon
anchors.centerIn: parent
property url copyUrl: Qt.resolvedUrl("../icons/copy.svg")
property url checkUrl: Qt.resolvedUrl("../icons/check.svg")
source: copyUrl
sourceSize.width: Math.round(root.size * 0.72)
sourceSize.height: Math.round(root.size * 0.72)
width: Math.round(root.size * 0.72)
height: Math.round(root.size * 0.72)
fillMode: Image.PreserveAspectFit
}
Timer {
id: resetTimer
interval: 1200
onTriggered: icon.source = icon.copyUrl
}
}
}

View File

@ -0,0 +1,13 @@
import QtQuick
import QtQuick.Layouts
import Logos.Controls
import Logos.Theme
// The fixed-width label used at the start of a detail-card row. Pairs with
// InfoRow (plain values) or a custom delegate built inline in a page.
LogosText {
color: Theme.palette.textSecondary
font.pixelSize: Theme.typography.secondaryText
Layout.preferredWidth: 150
Layout.alignment: Qt.AlignTop
}

View File

@ -0,0 +1,33 @@
import QtQuick
import Logos.Theme
// Themed square icon button. `source` is an SVG url; emits clicked().
Rectangle {
id: root
property url source
property bool enabled: true
property int size: 40
signal clicked()
implicitWidth: size
implicitHeight: size
radius: Theme.spacing.radiusMedium
opacity: root.enabled ? 1.0 : 0.4
color: hover.hovered && root.enabled ? Theme.palette.backgroundSecondary : Theme.palette.backgroundElevated
border.width: 1
border.color: Theme.palette.borderSecondary
HoverHandler { id: hover; enabled: root.enabled }
TapHandler { enabled: root.enabled; onTapped: root.clicked() }
Image {
anchors.centerIn: parent
source: root.source
sourceSize.width: Math.round(root.size * 0.5)
sourceSize.height: Math.round(root.size * 0.5)
width: Math.round(root.size * 0.5)
height: Math.round(root.size * 0.5)
fillMode: Image.PreserveAspectFit
}
}

View File

@ -0,0 +1,46 @@
import QtQuick
import QtQuick.Layouts
import Logos.Controls
import Logos.Theme
// One "Label value" row in a detail card. The value can be monospace. For
// non-text values (badges, clickable links) build a small RowLayout in the page
// with a FieldLabel instead.
RowLayout {
id: root
property string label: ""
property string value: ""
property bool mono: false
property bool copyable: false
property color valueColor: Theme.palette.text
spacing: Theme.spacing.medium
Layout.fillWidth: true
FieldLabel { text: root.label }
LogosText {
visible: !root.mono
text: root.value
color: root.valueColor
font.pixelSize: Theme.typography.secondaryText
wrapMode: Text.WrapAnywhere
Layout.fillWidth: true
Layout.alignment: Qt.AlignTop
}
MonoText {
visible: root.mono
text: root.value
color: root.valueColor
Layout.fillWidth: true
Layout.alignment: Qt.AlignTop
}
CopyButton {
visible: root.copyable && root.value.length > 0
value: root.value
Layout.alignment: Qt.AlignTop
}
}

View File

@ -0,0 +1,12 @@
import QtQuick
import Logos.Controls
import Logos.Theme
// Monospace, selectable text for hashes / ids / signatures.
LogosText {
font.family: "Menlo, Monaco, Courier New, monospace"
font.pixelSize: Theme.typography.secondaryText
color: Theme.palette.text
textFormat: Text.PlainText
wrapMode: Text.WrapAnywhere
}

View File

@ -0,0 +1,49 @@
import QtQuick
import QtQuick.Layouts
import Logos.Controls
import Logos.Theme
// Top navigation: back / forward / home + title.
RowLayout {
id: root
property bool canGoBack: false
property bool canGoForward: false
signal backClicked()
signal forwardClicked()
signal homeClicked()
signal settingsClicked()
spacing: Theme.spacing.small
IconButton {
source: Qt.resolvedUrl("../icons/arrow-left.svg")
enabled: root.canGoBack
onClicked: root.backClicked()
}
IconButton {
source: Qt.resolvedUrl("../icons/arrow-right.svg")
enabled: root.canGoForward
onClicked: root.forwardClicked()
}
IconButton {
source: Qt.resolvedUrl("../icons/home.svg")
onClicked: root.homeClicked()
}
LogosText {
text: "LEZ Explorer"
color: Theme.palette.primary
font.pixelSize: Theme.typography.titleText
font.weight: Theme.typography.weightBold
Layout.leftMargin: Theme.spacing.small
Layout.alignment: Qt.AlignVCenter
}
Item { Layout.fillWidth: true }
IconButton {
source: Qt.resolvedUrl("../icons/settings.svg")
onClicked: root.settingsClicked()
}
}

View File

@ -0,0 +1,70 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Logos.Controls
import Logos.Theme
// Search input + button. Emits searchRequested(query) on Enter or button click.
RowLayout {
id: root
signal searchRequested(string query)
spacing: Theme.spacing.small
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 40
radius: Theme.spacing.radiusMedium
color: Theme.palette.backgroundElevated
border.width: 1
border.color: field.activeFocus ? Theme.palette.borderInteractive : Theme.palette.borderSecondary
RowLayout {
anchors.fill: parent
anchors.leftMargin: Theme.spacing.medium
anchors.rightMargin: Theme.spacing.medium
spacing: Theme.spacing.small
Image {
source: Qt.resolvedUrl("../icons/search.svg")
sourceSize.width: 18
sourceSize.height: 18
width: 18
height: 18
fillMode: Image.PreserveAspectFit
Layout.alignment: Qt.AlignVCenter
}
TextField {
id: field
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
placeholderText: "Search by block id / block hash / tx hash / account id…"
color: Theme.palette.text
placeholderTextColor: Theme.palette.textMuted
font.pixelSize: Theme.typography.secondaryText
background: Item {}
onAccepted: root.searchRequested(field.text)
}
}
}
Rectangle {
Layout.preferredHeight: 40
Layout.preferredWidth: 92
radius: Theme.spacing.radiusMedium
color: btnHover.hovered ? Qt.lighter(Theme.palette.primary, 1.1) : Theme.palette.primary
HoverHandler { id: btnHover }
TapHandler { onTapped: root.searchRequested(field.text) }
LogosText {
anchors.centerIn: parent
text: "Search"
color: Theme.palette.background
font.pixelSize: Theme.typography.secondaryText
font.weight: Theme.typography.weightBold
}
}
}

View File

@ -0,0 +1,11 @@
import QtQuick
import Logos.Controls
import Logos.Theme
LogosText {
property string title: ""
text: title
color: Theme.palette.text
font.pixelSize: Theme.typography.titleText
font.weight: Theme.typography.weightBold
}

View File

@ -0,0 +1,11 @@
import QtQuick
import Logos.Theme
// Bedrock status pill: Finalized (success), Safe (warning), Pending (muted).
Badge {
property string status: "Pending"
text: status
accent: status === "Finalized" ? Theme.palette.success
: status === "Safe" ? Theme.palette.warning
: Theme.palette.textMuted
}

View File

@ -0,0 +1,43 @@
import QtQuick
import QtQuick.Layouts
import Logos.Controls
import Logos.Theme
// Clickable summary row for a transaction map ({ hash, type, ... }). Used in
// the block-detail tx list, account history, and search results.
Rectangle {
id: root
property var tx: ({})
signal clicked()
height: layout.implicitHeight + Theme.spacing.medium * 2
radius: Theme.spacing.radiusMedium
color: hover.hovered ? Theme.palette.backgroundSecondary : "transparent"
border.width: 1
border.color: hover.hovered ? Theme.palette.borderInteractive : Theme.palette.borderSecondary
HoverHandler { id: hover }
TapHandler { onTapped: root.clicked() }
RowLayout {
id: layout
anchors.fill: parent
anchors.margins: Theme.spacing.medium
spacing: Theme.spacing.medium
MonoText {
text: root.tx.hash || ""
color: Theme.palette.text
wrapMode: Text.NoWrap
elide: Text.ElideMiddle
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
}
TxTypeBadge {
type: root.tx.type || "Public"
Layout.alignment: Qt.AlignVCenter
}
}
}

View File

@ -0,0 +1,13 @@
import QtQuick
import Logos.Theme
// Transaction-type pill, with a friendly label per kind.
Badge {
property string type: "Public"
text: type === "PrivacyPreserving" ? "Privacy-Preserving"
: type === "ProgramDeployment" ? "Program Deployment"
: type
accent: type === "PrivacyPreserving" ? Theme.palette.primary
: type === "ProgramDeployment" ? Theme.palette.accentOrange
: Theme.palette.info
}

View File

Before

Width:  |  Height:  |  Size: 216 B

After

Width:  |  Height:  |  Size: 216 B

View File

Before

Width:  |  Height:  |  Size: 241 B

After

Width:  |  Height:  |  Size: 241 B

View File

Before

Width:  |  Height:  |  Size: 242 B

After

Width:  |  Height:  |  Size: 242 B

View File

Before

Width:  |  Height:  |  Size: 395 B

After

Width:  |  Height:  |  Size: 395 B

3
src/qml/icons/check.svg Normal file
View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#a6e3a1" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>

After

Width:  |  Height:  |  Size: 201 B

4
src/qml/icons/copy.svg Normal file
View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#cdd6f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>

After

Width:  |  Height:  |  Size: 290 B

View File

Before

Width:  |  Height:  |  Size: 390 B

After

Width:  |  Height:  |  Size: 390 B

View File

Before

Width:  |  Height:  |  Size: 267 B

After

Width:  |  Height:  |  Size: 267 B

View File

Before

Width:  |  Height:  |  Size: 243 B

After

Width:  |  Height:  |  Size: 243 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#cdd6f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<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 0 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 0 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 0 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 0 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: 832 B

View File

Before

Width:  |  Height:  |  Size: 250 B

After

Width:  |  Height:  |  Size: 250 B

View File

@ -0,0 +1,113 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Logos.Controls
import Logos.Theme
import "../components"
Item {
id: page
property var explorer
readonly property var backend: explorer ? explorer.backend : null
property string accountId: ""
property var account: ({})
property bool loaded: false
property var txs: []
property bool txsLoaded: false
Component.onCompleted: page.fetch()
Flickable {
anchors.fill: parent
contentWidth: width
contentHeight: col.implicitHeight
clip: true
ColumnLayout {
id: col
width: parent.width
spacing: Theme.spacing.medium
SectionHeader { title: "Account Details" }
LogosText {
visible: !page.loaded
text: "Loading…"
color: Theme.palette.textMuted
font.pixelSize: Theme.typography.secondaryText
}
LogosText {
visible: page.loaded && Object.keys(page.account).length <= 1
text: "Account not found."
color: Theme.palette.textMuted
font.pixelSize: Theme.typography.secondaryText
}
Card {
Layout.fillWidth: true
visible: page.loaded && Object.keys(page.account).length > 1
InfoRow { label: "Account ID"; value: page.account.accountId || ""; mono: true; copyable: true }
InfoRow {
label: "Balance"
value: page.account.balance || "0"
valueColor: Theme.palette.success
}
InfoRow { label: "Program Owner"; value: page.account.programOwner || ""; mono: true; copyable: true }
InfoRow { label: "Nonce"; value: page.account.nonce || "0" }
InfoRow {
label: "Data Size"
value: (page.account.dataSizeBytes !== undefined ? page.account.dataSizeBytes : 0) + " bytes"
}
}
SectionHeader {
visible: page.loaded && Object.keys(page.account).length > 1
title: "Transaction History"
}
LogosText {
visible: page.txsLoaded && page.txs.length === 0 && Object.keys(page.account).length > 1
text: "No transactions found."
color: Theme.palette.textMuted
font.pixelSize: Theme.typography.secondaryText
}
Repeater {
model: page.txs
delegate: TxRow {
Layout.fillWidth: true
tx: modelData
onClicked: page.explorer.navigateTx(modelData.hash)
}
}
Item { Layout.preferredHeight: Theme.spacing.large }
}
}
function fetch() {
if (!page.backend)
return;
logos.watch(page.backend.getAccount(page.accountId),
function (result) {
page.account = result || ({});
page.loaded = true;
},
function (err) {
page.account = ({});
page.loaded = true;
});
logos.watch(page.backend.getTransactionsByAccount(page.accountId, 0, 10),
function (result) {
page.txs = result || [];
page.txsLoaded = true;
},
function (err) {
page.txs = [];
page.txsLoaded = true;
});
}
}

123
src/qml/pages/BlockPage.qml Normal file
View File

@ -0,0 +1,123 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Logos.Controls
import Logos.Theme
import "../components"
Item {
id: page
property var explorer
readonly property var backend: explorer ? explorer.backend : null
property var blockId: 0
property var block: ({})
property bool loaded: false
Component.onCompleted: page.fetch()
Flickable {
anchors.fill: parent
contentWidth: width
contentHeight: col.implicitHeight
clip: true
ColumnLayout {
id: col
width: parent.width
spacing: Theme.spacing.medium
SectionHeader { title: "Block #" + page.blockId }
LogosText {
visible: !page.loaded
text: "Loading…"
color: Theme.palette.textMuted
font.pixelSize: Theme.typography.secondaryText
}
LogosText {
visible: page.loaded && Object.keys(page.block).length === 0
text: "Block not found."
color: Theme.palette.textMuted
font.pixelSize: Theme.typography.secondaryText
}
Card {
Layout.fillWidth: true
visible: page.loaded && Object.keys(page.block).length > 0
InfoRow { label: "Block ID"; value: String(page.block.blockId !== undefined ? page.block.blockId : "") }
InfoRow { label: "Hash"; value: page.block.hash || ""; mono: true; copyable: true }
// Previous hash a clickable link to the previous block when not genesis.
RowLayout {
Layout.fillWidth: true
spacing: Theme.spacing.medium
FieldLabel { text: "Previous Hash" }
MonoText {
Layout.fillWidth: true
Layout.alignment: Qt.AlignTop
text: page.block.prevBlockHash || ""
color: page.blockId > 1 ? Theme.palette.primary : Theme.palette.text
font.underline: page.blockId > 1 && prevHover.hovered
HoverHandler { id: prevHover; enabled: page.blockId > 1 }
TapHandler {
enabled: page.blockId > 1
onTapped: page.explorer.navigateBlock(Number(page.blockId) - 1)
}
}
CopyButton {
visible: (page.block.prevBlockHash || "").length > 0
value: page.block.prevBlockHash || ""
Layout.alignment: Qt.AlignTop
}
}
InfoRow { label: "Timestamp"; value: page.block.timestampText || "" }
RowLayout {
Layout.fillWidth: true
spacing: Theme.spacing.medium
FieldLabel { text: "Status" }
StatusBadge { status: page.block.status || "Pending"; Layout.alignment: Qt.AlignTop }
Item { Layout.fillWidth: true }
}
InfoRow { label: "Signature"; value: page.block.signature || ""; mono: true; copyable: true }
InfoRow { label: "Transactions"; value: String(page.block.txCount !== undefined ? page.block.txCount : 0) }
}
SectionHeader {
visible: page.loaded && page.block.transactions !== undefined && page.block.transactions.length > 0
title: "Transactions (" + (page.block.transactions ? page.block.transactions.length : 0) + ")"
}
Repeater {
model: page.block.transactions !== undefined ? page.block.transactions : []
delegate: TxRow {
Layout.fillWidth: true
tx: modelData
onClicked: page.explorer.navigateTx(modelData.hash)
}
}
Item { Layout.preferredHeight: Theme.spacing.large }
}
}
function fetch() {
if (!page.backend)
return;
logos.watch(page.backend.getBlockById(String(page.blockId)),
function (result) {
page.block = result || ({});
page.loaded = true;
},
function (err) {
page.block = ({});
page.loaded = true;
});
}
}

155
src/qml/pages/HomePage.qml Normal file
View File

@ -0,0 +1,155 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Logos.Controls
import Logos.Theme
import "../components"
Item {
id: page
// Injected by Main: the controller (navigation + backend access).
property var explorer
readonly property var backend: explorer ? explorer.backend : null
readonly property bool connected: backend && backend.connectionStatus === "Connected"
ColumnLayout {
anchors.fill: parent
spacing: Theme.spacing.medium
// Health bar.
Card {
Layout.fillWidth: true
RowLayout {
Layout.fillWidth: true
spacing: Theme.spacing.small
Rectangle {
width: 9
height: 9
radius: 4.5
Layout.alignment: Qt.AlignVCenter
color: page.connected ? Theme.palette.success : Theme.palette.warning
}
LogosText {
text: {
if (!page.backend)
return "";
var height = page.backend.chainHeight > 0 ? page.backend.chainHeight : "—";
return "Chain height: " + height + " · " + page.backend.connectionStatus;
}
color: Theme.palette.textMuted
font.pixelSize: Theme.typography.secondaryText
Layout.alignment: Qt.AlignVCenter
}
Item { Layout.fillWidth: true }
Rectangle {
Layout.preferredHeight: 34
Layout.preferredWidth: 100
radius: Theme.spacing.radiusMedium
color: settingsHover.hovered ? Theme.palette.backgroundSecondary : Theme.palette.backgroundElevated
border.width: 1
border.color: Theme.palette.borderSecondary
HoverHandler { id: settingsHover }
TapHandler { onTapped: if (page.explorer) page.explorer.navigateSettings() }
LogosText {
anchors.centerIn: parent
text: "Settings"
color: Theme.palette.textSecondary
font.pixelSize: Theme.typography.secondaryText
}
}
}
}
SectionHeader { title: "Recent Blocks" }
ListView {
id: blockList
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
spacing: Theme.spacing.small
model: page.backend ? page.backend.recentBlocks : []
delegate: BlockRow {
width: ListView.view.width
block: modelData
onClicked: page.explorer.navigateBlock(modelData.blockId)
}
footer: Item {
width: blockList.width
height: loadMore.visible ? loadMore.implicitHeight + Theme.spacing.large : 0
Rectangle {
id: loadMore
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
anchors.topMargin: Theme.spacing.medium
visible: blockList.count > 0
implicitHeight: 36
implicitWidth: 140
radius: Theme.spacing.radiusMedium
color: moreHover.hovered ? Theme.palette.backgroundSecondary : Theme.palette.backgroundElevated
border.width: 1
border.color: Theme.palette.borderSecondary
HoverHandler { id: moreHover }
TapHandler { onTapped: if (page.backend) page.backend.loadMoreBlocks() }
LogosText {
anchors.centerIn: parent
text: "Load more"
color: Theme.palette.textSecondary
font.pixelSize: Theme.typography.secondaryText
}
}
}
// Empty state.
ColumnLayout {
anchors.centerIn: parent
visible: blockList.count === 0
width: parent.width * 0.8
spacing: Theme.spacing.medium
LogosText {
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
text: page.connected
? "No blocks indexed yet."
: "No indexer running yet. Open Settings to configure and start it."
color: Theme.palette.textMuted
font.pixelSize: Theme.typography.secondaryText
}
Rectangle {
visible: !page.connected
Layout.alignment: Qt.AlignHCenter
implicitHeight: 38
implicitWidth: 160
radius: Theme.spacing.radiusMedium
color: openHover.hovered ? Qt.lighter(Theme.palette.primary, 1.1) : Theme.palette.primary
HoverHandler { id: openHover }
TapHandler { onTapped: if (page.explorer) page.explorer.navigateSettings() }
LogosText {
anchors.centerIn: parent
text: "Open Settings"
color: Theme.palette.background
font.pixelSize: Theme.typography.secondaryText
font.weight: Theme.typography.weightBold
}
}
}
}
}
}

View File

@ -0,0 +1,88 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Logos.Controls
import Logos.Theme
import "../components"
Item {
id: page
property var explorer
readonly property var backend: explorer ? explorer.backend : null
property var results: ({ blocks: [], transactions: [], accounts: [] })
property string query: ""
readonly property var blocks: results && results.blocks ? results.blocks : []
readonly property var transactions: results && results.transactions ? results.transactions : []
readonly property var accounts: results && results.accounts ? results.accounts : []
readonly property int total: blocks.length + transactions.length + accounts.length
Flickable {
anchors.fill: parent
contentWidth: width
contentHeight: col.implicitHeight
clip: true
ColumnLayout {
id: col
width: parent.width
spacing: Theme.spacing.medium
SectionHeader { title: "Search Results" }
LogosText {
visible: page.total === 0
text: page.query.length > 0
? "No results for \"" + page.query + "\"."
: "No results."
color: Theme.palette.textMuted
font.pixelSize: Theme.typography.secondaryText
}
SectionHeader {
visible: page.blocks.length > 0
title: "Blocks (" + page.blocks.length + ")"
font.pixelSize: Theme.typography.subtitleText
}
Repeater {
model: page.blocks
delegate: BlockRow {
Layout.fillWidth: true
block: modelData
onClicked: page.explorer.navigateBlock(modelData.blockId)
}
}
SectionHeader {
visible: page.transactions.length > 0
title: "Transactions (" + page.transactions.length + ")"
font.pixelSize: Theme.typography.subtitleText
}
Repeater {
model: page.transactions
delegate: TxRow {
Layout.fillWidth: true
tx: modelData
onClicked: page.explorer.navigateTx(modelData.hash)
}
}
SectionHeader {
visible: page.accounts.length > 0
title: "Accounts (" + page.accounts.length + ")"
font.pixelSize: Theme.typography.subtitleText
}
Repeater {
model: page.accounts
delegate: AccountRow {
Layout.fillWidth: true
account: modelData
onClicked: page.explorer.navigateAccount(modelData.accountId)
}
}
Item { Layout.preferredHeight: Theme.spacing.large }
}
}
}

View File

@ -0,0 +1,158 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Logos.Controls
import Logos.Theme
import "../components"
Item {
id: page
property var explorer
readonly property var backend: explorer ? explorer.backend : null
property bool statusIsError: false
Component.onCompleted: {
// Seed the editor with the saved config, falling back to the template.
if (page.backend) {
var current = page.backend.configText;
editor.text = (current && current.length > 0) ? current : page.backend.defaultConfig;
}
}
// Surface backend-side failures (write/start) in the status line.
Connections {
target: page.backend
ignoreUnknownSignals: true
function onError(message) {
page.setStatus(message, true);
}
}
ColumnLayout {
anchors.fill: parent
spacing: Theme.spacing.medium
SectionHeader { title: "Indexer Settings" }
LogosText {
Layout.fillWidth: true
wrapMode: Text.WordWrap
text: "Edit the indexer configuration below and press Save. The config is "
+ "persisted and the indexer is (re)started from it — no file paths needed. "
+ "It is reloaded automatically the next time you open the explorer."
color: Theme.palette.textSecondary
font.pixelSize: Theme.typography.secondaryText
}
// JSON editor. The TextArea is anchored to fill its container directly
// (NOT wrapped in a ScrollView there it doesn't inherit the viewport
// size and collapses to an invisible implicit size). It scrolls its
// viewport to follow the cursor; that's enough for a config-sized doc.
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
radius: Theme.spacing.radiusLarge
color: Theme.palette.backgroundElevated
border.width: 1
border.color: editor.activeFocus ? Theme.palette.borderInteractive
: Theme.palette.borderSecondary
clip: true
TextArea {
id: editor
anchors.fill: parent
anchors.margins: Theme.spacing.medium
font.family: "Menlo, Monaco, Courier New, monospace"
font.pixelSize: Theme.typography.secondaryText
color: Theme.palette.text
wrapMode: TextArea.Wrap
background: Item {}
}
}
RowLayout {
Layout.fillWidth: true
spacing: Theme.spacing.medium
LogosText {
id: statusLabel
Layout.fillWidth: true
wrapMode: Text.WordWrap
color: page.statusIsError ? Theme.palette.error : Theme.palette.success
font.pixelSize: Theme.typography.secondaryText
}
// Reset to the built-in template.
Rectangle {
Layout.preferredHeight: 38
Layout.preferredWidth: 150
radius: Theme.spacing.radiusMedium
color: resetHover.hovered ? Theme.palette.backgroundSecondary : Theme.palette.backgroundElevated
border.width: 1
border.color: Theme.palette.borderSecondary
HoverHandler { id: resetHover }
TapHandler { onTapped: if (page.backend) { editor.text = page.backend.defaultConfig; page.setStatus("", false); } }
LogosText {
anchors.centerIn: parent
text: "Reset to default"
color: Theme.palette.textSecondary
font.pixelSize: Theme.typography.secondaryText
}
}
// Save + (re)start.
Rectangle {
Layout.preferredHeight: 38
Layout.preferredWidth: 150
radius: Theme.spacing.radiusMedium
color: saveHover.hovered ? Qt.lighter(Theme.palette.primary, 1.1) : Theme.palette.primary
HoverHandler { id: saveHover }
TapHandler { onTapped: page.save() }
LogosText {
anchors.centerIn: parent
text: "Save & Start"
color: Theme.palette.background
font.pixelSize: Theme.typography.secondaryText
font.weight: Theme.typography.weightBold
}
}
}
}
function setStatus(message, isError) {
page.statusIsError = isError;
statusLabel.text = message;
}
function save() {
if (!page.backend)
return;
// Validate locally first for an immediate, precise error.
try {
JSON.parse(editor.text);
} catch (e) {
page.setStatus("Invalid JSON: " + e, true);
return;
}
page.setStatus("Saving…", false);
// Port is an internal detail of the indexer's own RPC listener; the
// explorer reads over the Logos protocol, so we pass "" (keep default).
logos.watch(page.backend.applyConfigJson(editor.text, ""),
function (ok) {
if (ok) {
page.setStatus("Saved. Indexer starting…", false);
page.explorer.goHome();
} else {
page.setStatus("Save failed — check the config and try again.", true);
}
},
function (err) {
page.setStatus("Error: " + err, true);
});
}
}

View File

@ -0,0 +1,143 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Logos.Controls
import Logos.Theme
import "../components"
Item {
id: page
property var explorer
readonly property var backend: explorer ? explorer.backend : null
property string txHash: ""
property var tx: ({})
property bool loaded: false
readonly property string txType: tx.type || ""
Component.onCompleted: page.fetch()
Flickable {
anchors.fill: parent
contentWidth: width
contentHeight: col.implicitHeight
clip: true
ColumnLayout {
id: col
width: parent.width
spacing: Theme.spacing.medium
SectionHeader { title: "Transaction Details" }
LogosText {
visible: !page.loaded
text: "Loading…"
color: Theme.palette.textMuted
font.pixelSize: Theme.typography.secondaryText
}
LogosText {
visible: page.loaded && Object.keys(page.tx).length === 0
text: "Transaction not found."
color: Theme.palette.textMuted
font.pixelSize: Theme.typography.secondaryText
}
Card {
Layout.fillWidth: true
visible: page.loaded && Object.keys(page.tx).length > 0
InfoRow { label: "Hash"; value: page.tx.hash || ""; mono: true; copyable: true }
RowLayout {
Layout.fillWidth: true
spacing: Theme.spacing.medium
FieldLabel { text: "Type" }
TxTypeBadge { type: page.txType || "Public"; Layout.alignment: Qt.AlignTop }
Item { Layout.fillWidth: true }
}
// Public.
InfoRow {
visible: page.txType === "Public"
label: "Program ID"; value: page.tx.programId || ""; mono: true; copyable: true
}
InfoRow {
visible: page.txType === "Public"
label: "Instruction Data"
value: (page.tx.instructionDataCount !== undefined ? page.tx.instructionDataCount : 0) + " words"
}
InfoRow {
visible: page.txType === "Public" || page.txType === "PrivacyPreserving"
label: "Signatures"
value: String(page.tx.signatureCount !== undefined ? page.tx.signatureCount : 0)
}
// Privacy-preserving.
InfoRow {
visible: page.txType === "PrivacyPreserving"
label: "New Commitments"
value: String(page.tx.newCommitmentsCount !== undefined ? page.tx.newCommitmentsCount : 0)
}
InfoRow {
visible: page.txType === "PrivacyPreserving"
label: "Nullifiers"
value: String(page.tx.nullifiersCount !== undefined ? page.tx.nullifiersCount : 0)
}
InfoRow {
visible: page.txType === "PrivacyPreserving"
label: "Encrypted States"
value: String(page.tx.encryptedStatesCount !== undefined ? page.tx.encryptedStatesCount : 0)
}
InfoRow {
visible: page.txType === "PrivacyPreserving"
label: "Proof Size"
value: (page.tx.proofSizeBytes !== undefined ? page.tx.proofSizeBytes : 0) + " bytes"
}
InfoRow {
visible: page.txType === "PrivacyPreserving"
label: "Validity Window"
value: "[" + (page.tx.validityWindowStart !== undefined ? page.tx.validityWindowStart : 0)
+ ", " + (page.tx.validityWindowEnd !== undefined ? page.tx.validityWindowEnd : 0) + ")"
}
// Program deployment.
InfoRow {
visible: page.txType === "ProgramDeployment"
label: "Bytecode Size"
value: (page.tx.bytecodeSizeBytes !== undefined ? page.tx.bytecodeSizeBytes : 0) + " bytes"
}
}
SectionHeader {
visible: page.loaded && page.tx.accounts !== undefined && page.tx.accounts.length > 0
title: "Accounts (" + (page.tx.accounts ? page.tx.accounts.length : 0) + ")"
}
Repeater {
model: page.tx.accounts !== undefined ? page.tx.accounts : []
delegate: AccountRow {
Layout.fillWidth: true
account: modelData
onClicked: page.explorer.navigateAccount(modelData.accountId)
}
}
Item { Layout.preferredHeight: Theme.spacing.large }
}
}
function fetch() {
if (!page.backend)
return;
logos.watch(page.backend.getTransaction(page.txHash),
function (result) {
page.tx = result || ({});
page.loaded = true;
},
function (err) {
page.tx = ({});
page.loaded = true;
});
}
}

View File

@ -1,43 +0,0 @@
#pragma once
#include "models/Block.h"
#include "models/Transaction.h"
#include "models/Account.h"
#include <QObject>
#include <QVector>
#include <optional>
struct SearchResults {
QVector<Block> blocks;
QVector<Transaction> transactions;
QVector<Account> accounts;
};
class IndexerService : public QObject {
Q_OBJECT
public:
explicit IndexerService(QObject* parent = nullptr) : QObject(parent) {}
~IndexerService() override = default;
static QString defaultEndpoint()
{
return "ws://localhost:8779";
}
virtual QString endpoint() const = 0;
virtual void setEndpoint(const QString& endpoint) = 0;
virtual std::optional<Account> getAccount(const QString& accountId) = 0;
virtual std::optional<Block> getBlockById(quint64 blockId) = 0;
virtual std::optional<Block> getBlockByHash(const QString& hash) = 0;
virtual std::optional<Transaction> getTransaction(const QString& hash) = 0;
virtual QVector<Block> getBlocks(std::optional<quint64> before, int limit) = 0;
virtual quint64 getLastFinalizedBlockId() = 0;
virtual QVector<Transaction> getTransactionsByAccount(const QString& accountId, int offset, int limit) = 0;
virtual SearchResults search(const QString& query) = 0;
signals:
void newBlockAdded(Block block);
};

View File

@ -1,380 +0,0 @@
#include "MockIndexerService.h"
#include <QRandomGenerator>
#include <algorithm>
namespace {
QString randomHexString(int bytes)
{
QString result;
result.reserve(bytes * 2);
auto* rng = QRandomGenerator::global();
for (int i = 0; i < bytes; ++i) {
result += QString("%1").arg(rng->bounded(256), 2, 16, QChar('0'));
}
return result;
}
// Generate a base58-like string (simplified — uses alphanumeric chars without 0/O/I/l)
QString randomBase58String(int length)
{
static const char chars[] = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
QString result;
result.reserve(length);
auto* rng = QRandomGenerator::global();
for (int i = 0; i < length; ++i) {
result += chars[rng->bounded(static_cast<int>(sizeof(chars) - 1))];
}
return result;
}
} // namespace
MockIndexerService::MockIndexerService(QObject* parent)
: IndexerService(parent)
{
generateData();
connect(&m_blockTimer, &QTimer::timeout, this, &MockIndexerService::onGenerateBlock);
m_blockTimer.start(30000); // 30 seconds
}
QString MockIndexerService::endpoint() const
{
return m_endpoint;
}
void MockIndexerService::setEndpoint(const QString& endpoint)
{
m_endpoint = endpoint.trimmed();
if (m_endpoint.isEmpty()) {
m_endpoint = IndexerService::defaultEndpoint();
}
}
Block MockIndexerService::generateBlock(quint64 blockId, const QString& prevHash)
{
auto* rng = QRandomGenerator::global();
Block block;
block.blockId = blockId;
block.prevBlockHash = prevHash;
block.hash = randomHash();
block.timestamp = QDateTime::currentDateTimeUtc();
block.signature = randomHexString(64);
block.bedrockParentId = randomHexString(32);
block.bedrockStatus = BedrockStatus::Pending;
int txCount = rng->bounded(1, 6);
for (int t = 0; t < txCount; ++t) {
Transaction tx;
int typeRoll = rng->bounded(100);
if (typeRoll < 60) {
tx = generatePublicTransaction();
} else if (typeRoll < 85) {
tx = generatePrivacyPreservingTransaction();
} else {
tx = generateProgramDeploymentTransaction();
}
block.transactions.append(tx);
m_transactionsByHash[tx.hash] = tx;
for (const auto& accRef : tx.accounts) {
if (!m_accounts.contains(accRef.accountId)) {
Account acc;
acc.accountId = accRef.accountId;
acc.programOwner = randomBase58String(44);
acc.balance = QString::number(rng->bounded(0, 1000000));
acc.nonce = accRef.nonce;
acc.dataSizeBytes = rng->bounded(0, 4096);
m_accounts[acc.accountId] = acc;
}
}
}
return block;
}
void MockIndexerService::onGenerateBlock()
{
quint64 newId = m_blocks.isEmpty() ? 1 : m_blocks.last().blockId + 1;
QString prevHash = m_blocks.isEmpty() ? QString(64, '0') : m_blocks.last().hash;
Block block = generateBlock(newId, prevHash);
m_blocks.append(block);
m_blocksByHash[block.hash] = block;
emit newBlockAdded(block);
}
QString MockIndexerService::randomHash()
{
return randomHexString(32);
}
QString MockIndexerService::randomAccountId()
{
return randomBase58String(44);
}
Transaction MockIndexerService::generatePublicTransaction()
{
auto* rng = QRandomGenerator::global();
Transaction tx;
tx.hash = randomHash();
tx.type = TransactionType::Public;
tx.programId = randomBase58String(44);
tx.signatureCount = rng->bounded(1, 4);
tx.proofSizeBytes = rng->bounded(0, 2048);
int accountCount = rng->bounded(1, 5);
for (int i = 0; i < accountCount; ++i) {
// Reuse existing accounts sometimes
QString accId;
if (!m_accounts.isEmpty() && rng->bounded(100) < 60) {
auto keys = m_accounts.keys();
accId = keys[rng->bounded(keys.size())];
} else {
accId = randomAccountId();
}
tx.accounts.append({accId, QString::number(rng->bounded(1, 100))});
}
int instrCount = rng->bounded(1, 8);
for (int i = 0; i < instrCount; ++i) {
tx.instructionData.append(rng->generate());
}
return tx;
}
Transaction MockIndexerService::generatePrivacyPreservingTransaction()
{
auto* rng = QRandomGenerator::global();
Transaction tx;
tx.hash = randomHash();
tx.type = TransactionType::PrivacyPreserving;
tx.proofSizeBytes = rng->bounded(512, 4096);
tx.newCommitmentsCount = rng->bounded(1, 6);
tx.nullifiersCount = rng->bounded(1, 4);
tx.encryptedStatesCount = rng->bounded(1, 3);
tx.validityWindowStart = rng->bounded(1, 50);
tx.validityWindowEnd = tx.validityWindowStart + rng->bounded(50, 200);
int accountCount = rng->bounded(1, 4);
for (int i = 0; i < accountCount; ++i) {
QString accId;
if (!m_accounts.isEmpty() && rng->bounded(100) < 60) {
auto keys = m_accounts.keys();
accId = keys[rng->bounded(keys.size())];
} else {
accId = randomAccountId();
}
tx.accounts.append({accId, QString::number(rng->bounded(1, 100))});
}
return tx;
}
Transaction MockIndexerService::generateProgramDeploymentTransaction()
{
auto* rng = QRandomGenerator::global();
Transaction tx;
tx.hash = randomHash();
tx.type = TransactionType::ProgramDeployment;
tx.bytecodeSizeBytes = rng->bounded(1024, 65536);
tx.signatureCount = 1;
return tx;
}
void MockIndexerService::generateData()
{
auto* rng = QRandomGenerator::global();
// Generate accounts
for (int i = 0; i < 15; ++i) {
Account acc;
acc.accountId = randomAccountId();
acc.programOwner = randomBase58String(44);
acc.balance = QString::number(rng->bounded(0, 1000000));
acc.nonce = QString::number(rng->bounded(0, 500));
acc.dataSizeBytes = rng->bounded(0, 4096);
m_accounts[acc.accountId] = acc;
}
// Generate blocks
QString prevHash = QString(64, '0');
QDateTime timestamp = QDateTime::currentDateTimeUtc().addSecs(-25 * 12); // ~5 min ago
for (quint64 id = 1; id <= 25; ++id) {
Block block;
block.blockId = id;
block.prevBlockHash = prevHash;
block.hash = randomHash();
block.timestamp = timestamp;
block.signature = randomHexString(64);
block.bedrockParentId = randomHexString(32);
// Older blocks are finalized, middle ones safe, recent ones pending
if (id <= 15) {
block.bedrockStatus = BedrockStatus::Finalized;
} else if (id <= 22) {
block.bedrockStatus = BedrockStatus::Safe;
} else {
block.bedrockStatus = BedrockStatus::Pending;
}
// Generate 1-5 transactions per block
int txCount = rng->bounded(1, 6);
for (int t = 0; t < txCount; ++t) {
Transaction tx;
int typeRoll = rng->bounded(100);
if (typeRoll < 60) {
tx = generatePublicTransaction();
} else if (typeRoll < 85) {
tx = generatePrivacyPreservingTransaction();
} else {
tx = generateProgramDeploymentTransaction();
}
block.transactions.append(tx);
m_transactionsByHash[tx.hash] = tx;
// Ensure accounts referenced in transactions exist
for (const auto& accRef : tx.accounts) {
if (!m_accounts.contains(accRef.accountId)) {
Account acc;
acc.accountId = accRef.accountId;
acc.programOwner = randomBase58String(44);
acc.balance = QString::number(rng->bounded(0, 1000000));
acc.nonce = accRef.nonce;
acc.dataSizeBytes = rng->bounded(0, 4096);
m_accounts[acc.accountId] = acc;
}
}
}
m_blocks.append(block);
m_blocksByHash[block.hash] = block;
prevHash = block.hash;
timestamp = timestamp.addSecs(12);
}
}
std::optional<Account> MockIndexerService::getAccount(const QString& accountId)
{
auto it = m_accounts.find(accountId);
if (it != m_accounts.end()) {
return *it;
}
return std::nullopt;
}
std::optional<Block> MockIndexerService::getBlockById(quint64 blockId)
{
if (blockId >= 1 && blockId <= static_cast<quint64>(m_blocks.size())) {
return m_blocks[static_cast<int>(blockId - 1)];
}
return std::nullopt;
}
std::optional<Block> MockIndexerService::getBlockByHash(const QString& hash)
{
auto it = m_blocksByHash.find(hash);
if (it != m_blocksByHash.end()) {
return *it;
}
return std::nullopt;
}
std::optional<Transaction> MockIndexerService::getTransaction(const QString& hash)
{
auto it = m_transactionsByHash.find(hash);
if (it != m_transactionsByHash.end()) {
return *it;
}
return std::nullopt;
}
QVector<Block> MockIndexerService::getBlocks(std::optional<quint64> before, int limit)
{
QVector<Block> result;
int startIdx = m_blocks.size() - 1;
if (before.has_value()) {
startIdx = static_cast<int>(*before) - 2; // -1 for 0-index, -1 for "before"
}
for (int i = startIdx; i >= 0 && result.size() < limit; --i) {
result.append(m_blocks[i]);
}
return result;
}
quint64 MockIndexerService::getLastFinalizedBlockId()
{
if (m_blocks.isEmpty()) return 0;
return m_blocks.last().blockId;
}
QVector<Transaction> MockIndexerService::getTransactionsByAccount(const QString& accountId, int offset, int limit)
{
QVector<Transaction> result;
// Search all transactions for ones referencing this account
for (auto it = m_transactionsByHash.begin(); it != m_transactionsByHash.end(); ++it) {
for (const auto& accRef : it->accounts) {
if (accRef.accountId == accountId) {
result.append(*it);
break;
}
}
}
// Apply offset and limit
if (offset >= result.size()) return {};
return result.mid(offset, limit);
}
SearchResults MockIndexerService::search(const QString& query)
{
SearchResults results;
QString trimmed = query.trimmed();
if (trimmed.isEmpty()) return results;
// Try as block ID (numeric)
bool ok = false;
quint64 blockId = trimmed.toULongLong(&ok);
if (ok) {
auto block = getBlockById(blockId);
if (block) {
results.blocks.append(*block);
}
}
// Try as hash (hex string, 64 chars)
if (trimmed.size() == 64) {
auto block = getBlockByHash(trimmed);
if (block) {
results.blocks.append(*block);
}
auto tx = getTransaction(trimmed);
if (tx) {
results.transactions.append(*tx);
}
}
// Try as account ID
auto account = getAccount(trimmed);
if (account) {
results.accounts.append(*account);
}
return results;
}

View File

@ -1,44 +0,0 @@
#pragma once
#include "IndexerService.h"
#include <QMap>
#include <QTimer>
class MockIndexerService : public IndexerService {
Q_OBJECT
public:
explicit MockIndexerService(QObject* parent = nullptr);
QString endpoint() const override;
void setEndpoint(const QString& endpoint) override;
std::optional<Account> getAccount(const QString& accountId) override;
std::optional<Block> getBlockById(quint64 blockId) override;
std::optional<Block> getBlockByHash(const QString& hash) override;
std::optional<Transaction> getTransaction(const QString& hash) override;
QVector<Block> getBlocks(std::optional<quint64> before, int limit) override;
quint64 getLastFinalizedBlockId() override;
QVector<Transaction> getTransactionsByAccount(const QString& accountId, int offset, int limit) override;
SearchResults search(const QString& query) override;
private slots:
void onGenerateBlock();
private:
void generateData();
QString randomHash();
QString randomAccountId();
Transaction generatePublicTransaction();
Transaction generatePrivacyPreservingTransaction();
Transaction generateProgramDeploymentTransaction();
Block generateBlock(quint64 blockId, const QString& prevHash);
QVector<Block> m_blocks;
QMap<QString, Block> m_blocksByHash;
QMap<QString, Transaction> m_transactionsByHash;
QMap<QString, Account> m_accounts;
QTimer m_blockTimer;
QString m_endpoint = IndexerService::defaultEndpoint();
};

View File

@ -1,741 +0,0 @@
#include "RpcIndexerService.h"
#include <QByteArray>
#include <QDateTime>
#include <QEventLoop>
#include <QJsonDocument>
#include <QJsonValue>
#include <QRegularExpression>
#include <QTimer>
#include <QTimeZone>
#include <algorithm>
namespace {
QString compactJson(const QJsonValue& value)
{
if (value.isObject()) {
return QString::fromUtf8(QJsonDocument(value.toObject()).toJson(QJsonDocument::Compact));
}
if (value.isArray()) {
return QString::fromUtf8(QJsonDocument(value.toArray()).toJson(QJsonDocument::Compact));
}
return {};
}
quint64 extractFinalizedBlockId(const QJsonValue& value)
{
if (value.isDouble()) {
const double number = value.toDouble();
if (number <= 0.0) {
return 0;
}
return static_cast<quint64>(number);
}
if (value.isString()) {
bool ok = false;
const qulonglong parsed = value.toString().toULongLong(&ok);
return ok ? parsed : 0;
}
if (value.isObject()) {
const QJsonObject obj = value.toObject();
if (obj.contains("block_id")) {
return extractFinalizedBlockId(obj.value("block_id"));
}
if (obj.contains("blockId")) {
return extractFinalizedBlockId(obj.value("blockId"));
}
if (obj.contains("id")) {
return extractFinalizedBlockId(obj.value("id"));
}
if (obj.contains("result")) {
return extractFinalizedBlockId(obj.value("result"));
}
if (obj.contains("header")) {
return extractFinalizedBlockId(obj.value("header").toObject().value("block_id"));
}
}
if (value.isArray()) {
const QJsonArray arr = value.toArray();
for (const auto& item : arr) {
const quint64 blockId = extractFinalizedBlockId(item);
if (blockId) {
return blockId;
}
}
}
return 0;
}
} // namespace
RpcIndexerService::RpcIndexerService(const QString& endpoint, QObject* parent)
: IndexerService(parent)
, m_endpoint(QUrl(endpoint.trimmed()))
{
connect(&m_socket, &QWebSocket::connected, this, &RpcIndexerService::onConnected);
connect(&m_socket, &QWebSocket::disconnected, this, &RpcIndexerService::onDisconnected);
connect(&m_socket, &QWebSocket::textMessageReceived, this, &RpcIndexerService::onTextMessageReceived);
#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0)
connect(&m_socket, &QWebSocket::errorOccurred, this, &RpcIndexerService::onSocketError);
#else
connect(&m_socket,
QOverload<QAbstractSocket::SocketError>::of(&QWebSocket::error),
this,
&RpcIndexerService::onSocketError);
#endif
}
RpcIndexerService::~RpcIndexerService()
{
m_socket.close();
}
QString RpcIndexerService::endpoint() const
{
return m_endpoint.toString();
}
void RpcIndexerService::setEndpoint(const QString& endpoint)
{
const QString trimmed = endpoint.trimmed();
const QUrl newEndpoint(trimmed.isEmpty() ? IndexerService::defaultEndpoint() : trimmed);
if (!newEndpoint.isValid()) {
qWarning() << "Invalid indexer endpoint URL:" << endpoint;
return;
}
if (newEndpoint == m_endpoint) {
return;
}
m_endpoint = newEndpoint;
m_nextRequestId = 1;
m_responses.clear();
m_subscriptionRequestId = 0;
m_subscriptionId = QJsonValue();
m_subscriptionActive = false;
m_lastNotifiedBlockId = 0;
for (auto it = m_waiters.begin(); it != m_waiters.end(); ++it) {
if (it.value()) {
it.value()->quit();
}
}
m_waiters.clear();
if (m_socket.state() != QAbstractSocket::UnconnectedState) {
m_socket.close();
}
}
std::optional<Account> RpcIndexerService::getAccount(const QString& accountId)
{
auto result = callRpc("getAccount", QJsonArray{accountId});
if (!result) {
return std::nullopt;
}
return parseAccount(accountId, *result);
}
std::optional<Block> RpcIndexerService::getBlockById(quint64 blockId)
{
auto result = callRpc("getBlockById", QJsonArray{static_cast<qint64>(blockId)});
if (!result || result->isNull()) {
return std::nullopt;
}
return parseBlock(*result);
}
std::optional<Block> RpcIndexerService::getBlockByHash(const QString& hash)
{
auto result = callRpc("getBlockByHash", QJsonArray{hash});
if (!result || result->isNull()) {
return std::nullopt;
}
return parseBlock(*result);
}
std::optional<Transaction> RpcIndexerService::getTransaction(const QString& hash)
{
auto result = callRpc("getTransaction", QJsonArray{hash});
if (!result || result->isNull()) {
return std::nullopt;
}
return parseTransaction(*result);
}
QVector<Block> RpcIndexerService::getBlocks(std::optional<quint64> before, int limit)
{
QJsonArray params;
if (before.has_value()) {
params.append(static_cast<qint64>(*before));
} else {
params.append(QJsonValue::Null);
}
params.append(limit);
auto result = callRpc("getBlocks", params);
if (!result || !result->isArray()) {
return {};
}
QVector<Block> blocks;
const QJsonArray array = result->toArray();
blocks.reserve(array.size());
for (const auto& item : array) {
auto block = parseBlock(item);
if (block) {
blocks.append(*block);
}
}
return blocks;
}
quint64 RpcIndexerService::getLastFinalizedBlockId()
{
auto result = callRpc("getLastFinalizedBlockId");
if (!result) {
return 0;
}
const quint64 latestFinalized = jsonValueToU64(*result);
if (latestFinalized > m_lastNotifiedBlockId) {
m_lastNotifiedBlockId = latestFinalized;
}
return latestFinalized;
}
QVector<Transaction> RpcIndexerService::getTransactionsByAccount(const QString& accountId, int offset, int limit)
{
auto result = callRpc("getTransactionsByAccount", QJsonArray{accountId, offset, limit});
if (!result || !result->isArray()) {
return {};
}
QVector<Transaction> transactions;
const QJsonArray array = result->toArray();
transactions.reserve(array.size());
for (const auto& item : array) {
auto tx = parseTransaction(item);
if (tx) {
transactions.append(*tx);
}
}
return transactions;
}
SearchResults RpcIndexerService::search(const QString& query)
{
SearchResults results;
const QString trimmed = query.trimmed();
if (trimmed.isEmpty()) {
return results;
}
static const QRegularExpression hashRegex(QStringLiteral("^[0-9a-fA-F]{64}$"));
if (hashRegex.match(trimmed).hasMatch()) {
if (auto block = getBlockByHash(trimmed)) {
results.blocks.append(*block);
}
if (auto tx = getTransaction(trimmed)) {
results.transactions.append(*tx);
}
}
if (auto account = getAccount(trimmed)) {
results.accounts.append(*account);
}
bool ok = false;
const quint64 blockId = trimmed.toULongLong(&ok);
if (ok) {
if (auto block = getBlockById(blockId)) {
const auto duplicate = std::any_of(results.blocks.begin(), results.blocks.end(),
[blockId](const Block& existing) {
return existing.blockId == blockId;
});
if (!duplicate) {
results.blocks.append(*block);
}
}
}
return results;
}
void RpcIndexerService::onConnected()
{
qInfo() << "Connected to indexer RPC:" << m_endpoint;
ensureFinalizedBlocksSubscription();
}
void RpcIndexerService::onDisconnected()
{
m_subscriptionRequestId = 0;
m_subscriptionId = QJsonValue();
m_subscriptionActive = false;
for (auto it = m_waiters.begin(); it != m_waiters.end(); ++it) {
if (it.value()) {
it.value()->quit();
}
}
m_waiters.clear();
}
void RpcIndexerService::onSocketError(QAbstractSocket::SocketError error)
{
Q_UNUSED(error);
qWarning() << "Indexer RPC socket error:" << m_socket.errorString();
}
void RpcIndexerService::onTextMessageReceived(const QString& message)
{
QJsonParseError parseError;
const QJsonDocument doc = QJsonDocument::fromJson(message.toUtf8(), &parseError);
if (parseError.error != QJsonParseError::NoError || !doc.isObject()) {
qWarning() << "Invalid JSON-RPC payload:" << parseError.errorString();
return;
}
const QJsonObject payload = doc.object();
const QString method = payload.value("method").toString();
if (method == "subscribeToFinalizedBlocks") {
const QJsonObject params = payload.value("params").toObject();
if (params.isEmpty()) {
return;
}
if (!m_subscriptionId.isNull() && params.contains("subscription")) {
const QJsonValue subscriptionValue = params.value("subscription");
if (subscriptionValue != m_subscriptionId) {
return;
}
}
handleFinalizedBlockNotification(params.value("result"));
return;
}
if (!payload.contains("id")) {
return;
}
const quint64 id = jsonValueToU64(payload.value("id"));
if (!id) {
return;
}
if (id == m_subscriptionRequestId) {
m_subscriptionRequestId = 0;
if (payload.contains("error")) {
m_subscriptionActive = false;
m_subscriptionId = QJsonValue();
qWarning() << "Failed to subscribe to finalized blocks:" << compactJson(payload.value("error"));
} else {
m_subscriptionId = payload.value("result");
m_subscriptionActive = !m_subscriptionId.isUndefined() && !m_subscriptionId.isNull();
if (!m_subscriptionActive) {
qWarning() << "Unexpected finalized blocks subscription result";
}
}
}
m_responses.insert(id, payload);
auto* waiter = m_waiters.value(id, nullptr);
if (waiter) {
waiter->quit();
}
}
void RpcIndexerService::handleFinalizedBlockNotification(const QJsonValue& result)
{
const quint64 blockId = extractFinalizedBlockId(result);
if (!blockId || blockId <= m_lastNotifiedBlockId) {
return;
}
QTimer::singleShot(0, this, [this, blockId]() {
fetchAndEmitFinalizedBlock(blockId);
});
}
void RpcIndexerService::fetchAndEmitFinalizedBlock(quint64 blockId)
{
auto block = getBlockById(blockId);
if (block) {
if (blockId <= m_lastNotifiedBlockId) {
return;
}
m_lastNotifiedBlockId = blockId;
emit newBlockAdded(*block);
return;
}
// Fallback to latest block snapshot if the specific id is briefly unavailable.
auto latestBlocks = getBlocks(std::nullopt, 1);
if (!latestBlocks.isEmpty()) {
const Block& latest = latestBlocks.first();
if (latest.blockId >= blockId && latest.blockId > m_lastNotifiedBlockId) {
m_lastNotifiedBlockId = latest.blockId;
emit newBlockAdded(latest);
return;
}
}
qWarning() << "Finalized block announced but not yet available:" << blockId;
}
bool RpcIndexerService::ensureConnected()
{
if (!m_endpoint.isValid() || m_endpoint.scheme().isEmpty()) {
qWarning() << "Invalid indexer RPC URL:" << m_endpoint;
return false;
}
if (m_socket.state() == QAbstractSocket::ConnectedState) {
ensureFinalizedBlocksSubscription();
return true;
}
if (m_socket.state() == QAbstractSocket::UnconnectedState) {
m_socket.open(m_endpoint);
}
QEventLoop waitLoop;
QTimer timeout;
bool timedOut = false;
timeout.setSingleShot(true);
connect(&timeout, &QTimer::timeout, &waitLoop, [&]() {
timedOut = true;
waitLoop.quit();
});
connect(&m_socket, &QWebSocket::connected, &waitLoop, &QEventLoop::quit);
connect(&m_socket, &QWebSocket::disconnected, &waitLoop, &QEventLoop::quit);
#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0)
connect(&m_socket, &QWebSocket::errorOccurred, &waitLoop, &QEventLoop::quit);
#else
connect(&m_socket,
QOverload<QAbstractSocket::SocketError>::of(&QWebSocket::error),
&waitLoop,
&QEventLoop::quit);
#endif
timeout.start(4000);
waitLoop.exec();
if (timedOut) {
qWarning() << "Timeout connecting to indexer RPC:" << m_endpoint;
m_socket.abort();
return false;
}
const bool isConnected = m_socket.state() == QAbstractSocket::ConnectedState;
if (isConnected) {
ensureFinalizedBlocksSubscription();
}
return isConnected;
}
void RpcIndexerService::ensureFinalizedBlocksSubscription()
{
if (m_socket.state() != QAbstractSocket::ConnectedState) {
return;
}
if (m_subscriptionActive || m_subscriptionRequestId != 0) {
return;
}
const quint64 requestId = m_nextRequestId++;
m_subscriptionRequestId = requestId;
QJsonObject request;
request.insert("jsonrpc", "2.0");
request.insert("id", static_cast<qint64>(requestId));
request.insert("method", "subscribeToFinalizedBlocks");
request.insert("params", QJsonArray());
m_socket.sendTextMessage(QString::fromUtf8(QJsonDocument(request).toJson(QJsonDocument::Compact)));
}
std::optional<QJsonValue> RpcIndexerService::callRpc(const QString& method, const QJsonArray& params)
{
if (!ensureConnected()) {
return std::nullopt;
}
const quint64 id = m_nextRequestId++;
QJsonObject request;
request.insert("jsonrpc", "2.0");
request.insert("id", static_cast<qint64>(id));
request.insert("method", method);
request.insert("params", params);
QEventLoop waitLoop;
QTimer timeout;
bool timedOut = false;
timeout.setSingleShot(true);
connect(&timeout, &QTimer::timeout, &waitLoop, [&]() {
timedOut = true;
waitLoop.quit();
});
connect(&m_socket, &QWebSocket::disconnected, &waitLoop, &QEventLoop::quit);
m_waiters.insert(id, &waitLoop);
m_socket.sendTextMessage(QString::fromUtf8(QJsonDocument(request).toJson(QJsonDocument::Compact)));
timeout.start(5000);
waitLoop.exec();
m_waiters.remove(id);
if (timedOut) {
qWarning() << "RPC call timeout:" << method;
return std::nullopt;
}
if (!m_responses.contains(id)) {
qWarning() << "No RPC response for" << method;
return std::nullopt;
}
const QJsonObject response = m_responses.take(id);
if (response.contains("error")) {
qWarning() << "RPC error for" << method << compactJson(response.value("error"));
return std::nullopt;
}
if (!response.contains("result")) {
qWarning() << "RPC result missing for" << method;
return std::nullopt;
}
const std::optional<QJsonValue> result = response.value("result");
qDebug() << "RPC call result for" << method << "(" << params << "):" << jsonValueToString(*result);
return result;
}
std::optional<Block> RpcIndexerService::parseBlock(const QJsonValue& value) const
{
if (!value.isObject()) {
return std::nullopt;
}
const QJsonObject blockObj = value.toObject();
const QJsonObject headerObj = blockObj.value("header").toObject();
const QJsonObject bodyObj = blockObj.value("body").toObject();
if (headerObj.isEmpty()) {
return std::nullopt;
}
Block block;
block.blockId = jsonValueToU64(headerObj.value("block_id"));
block.hash = jsonValueToString(headerObj.value("hash"));
block.prevBlockHash = jsonValueToString(headerObj.value("prev_block_hash"));
block.signature = jsonValueToString(headerObj.value("signature"));
block.bedrockParentId = jsonValueToString(blockObj.value("bedrock_parent_id"));
const quint64 timestamp = jsonValueToU64(headerObj.value("timestamp"));
if (timestamp > 1000000000000ULL) {
block.timestamp = QDateTime::fromMSecsSinceEpoch(static_cast<qint64>(timestamp), QTimeZone::UTC);
} else {
block.timestamp = QDateTime::fromSecsSinceEpoch(static_cast<qint64>(timestamp), QTimeZone::UTC);
}
if (!block.timestamp.isValid()) {
block.timestamp = QDateTime::currentDateTimeUtc();
}
const QString status = blockObj.value("bedrock_status").toString("Pending");
if (status == "Finalized") {
block.bedrockStatus = BedrockStatus::Finalized;
} else if (status == "Safe") {
block.bedrockStatus = BedrockStatus::Safe;
} else {
block.bedrockStatus = BedrockStatus::Pending;
}
const QJsonArray txArray = bodyObj.value("transactions").toArray();
block.transactions.reserve(txArray.size());
for (const auto& txValue : txArray) {
auto tx = parseTransaction(txValue);
if (tx) {
block.transactions.append(*tx);
}
}
return block;
}
std::optional<Transaction> RpcIndexerService::parseTransaction(const QJsonValue& value) const
{
if (!value.isObject()) {
return std::nullopt;
}
const QJsonObject wrapper = value.toObject();
Transaction tx;
auto appendAccounts = [&tx](const QJsonArray& accountIds, const QJsonArray& nonces) {
const int pairedCount = std::min(accountIds.size(), nonces.size());
for (int i = 0; i < pairedCount; ++i) {
AccountRef account;
account.accountId = jsonValueToString(accountIds.at(i));
account.nonce = jsonValueToString(nonces.at(i));
tx.accounts.append(account);
}
for (int i = pairedCount; i < accountIds.size(); ++i) {
AccountRef account;
account.accountId = jsonValueToString(accountIds.at(i));
account.nonce = "0";
tx.accounts.append(account);
}
};
auto fillWitnessFields = [&tx](const QJsonObject& witnessSet) {
tx.signatureCount = witnessSet.value("signatures_and_public_keys").toArray().size();
if (witnessSet.value("proof").isString()) {
tx.proofSizeBytes = base64Size(witnessSet.value("proof").toString());
}
};
if (wrapper.contains("Public")) {
const QJsonObject publicTx = wrapper.value("Public").toObject();
const QJsonObject message = publicTx.value("message").toObject();
tx.type = TransactionType::Public;
tx.hash = jsonValueToString(publicTx.value("hash"));
tx.programId = jsonValueToString(message.value("program_id"));
appendAccounts(message.value("account_ids").toArray(), message.value("nonces").toArray());
const QJsonArray instructionData = message.value("instruction_data").toArray();
tx.instructionData.reserve(instructionData.size());
for (const auto& v : instructionData) {
tx.instructionData.append(static_cast<quint32>(jsonValueToU64(v)));
}
fillWitnessFields(publicTx.value("witness_set").toObject());
return tx;
}
if (wrapper.contains("PrivacyPreserving")) {
const QJsonObject privateTx = wrapper.value("PrivacyPreserving").toObject();
const QJsonObject message = privateTx.value("message").toObject();
tx.type = TransactionType::PrivacyPreserving;
tx.hash = jsonValueToString(privateTx.value("hash"));
appendAccounts(message.value("public_account_ids").toArray(), message.value("nonces").toArray());
tx.newCommitmentsCount = message.value("new_commitments").toArray().size();
tx.nullifiersCount = message.value("new_nullifiers").toArray().size();
tx.encryptedStatesCount = message.value("encrypted_private_post_states").toArray().size();
const QJsonArray validityWindow = message.value("block_validity_window").toArray();
if (!validityWindow.isEmpty()) {
tx.validityWindowStart = jsonValueToU64(validityWindow.at(0));
}
if (validityWindow.size() > 1) {
tx.validityWindowEnd = jsonValueToU64(validityWindow.at(1));
}
fillWitnessFields(privateTx.value("witness_set").toObject());
return tx;
}
if (wrapper.contains("ProgramDeployment")) {
const QJsonObject deployTx = wrapper.value("ProgramDeployment").toObject();
const QJsonObject message = deployTx.value("message").toObject();
tx.type = TransactionType::ProgramDeployment;
tx.hash = jsonValueToString(deployTx.value("hash"));
tx.bytecodeSizeBytes = base64Size(message.value("bytecode").toString());
tx.signatureCount = 0;
tx.proofSizeBytes = 0;
return tx;
}
return std::nullopt;
}
std::optional<Account> RpcIndexerService::parseAccount(const QString& accountId, const QJsonValue& value) const
{
if (!value.isObject()) {
return std::nullopt;
}
const QJsonObject accountObj = value.toObject();
Account account;
account.accountId = accountId;
account.programOwner = jsonValueToString(accountObj.value("program_owner"));
account.balance = jsonValueToString(accountObj.value("balance"));
account.nonce = jsonValueToString(accountObj.value("nonce"));
account.dataSizeBytes = base64Size(accountObj.value("data").toString());
if (account.balance.isEmpty()) {
account.balance = "0";
}
if (account.nonce.isEmpty()) {
account.nonce = "0";
}
return account;
}
QString RpcIndexerService::jsonValueToString(const QJsonValue& value)
{
if (value.isString()) {
return value.toString();
}
if (value.isDouble()) {
const double number = value.toDouble();
const qulonglong integer = static_cast<qulonglong>(number);
if (number == static_cast<double>(integer)) {
return QString::number(integer);
}
return QString::number(number, 'g', 16);
}
if (value.isBool()) {
return value.toBool() ? "true" : "false";
}
return compactJson(value);
}
quint64 RpcIndexerService::jsonValueToU64(const QJsonValue& value)
{
if (value.isDouble()) {
const double number = value.toDouble();
if (number <= 0.0) {
return 0;
}
return static_cast<quint64>(number);
}
if (value.isString()) {
bool ok = false;
const qulonglong parsed = value.toString().toULongLong(&ok);
return ok ? parsed : 0;
}
return 0;
}
int RpcIndexerService::base64Size(const QString& value)
{
if (value.isEmpty()) {
return 0;
}
return QByteArray::fromBase64(value.toUtf8()).size();
}

View File

@ -1,66 +0,0 @@
#pragma once
#include "IndexerService.h"
#include <QHash>
#include <QJsonArray>
#include <QJsonObject>
#include <QJsonValue>
#include <QUrl>
#include <QWebSocket>
#include <optional>
class QEventLoop;
class RpcIndexerService : public IndexerService {
Q_OBJECT
public:
explicit RpcIndexerService(const QString& endpoint = IndexerService::defaultEndpoint(), QObject* parent = nullptr);
~RpcIndexerService() override;
QString endpoint() const override;
void setEndpoint(const QString& endpoint) override;
std::optional<Account> getAccount(const QString& accountId) override;
std::optional<Block> getBlockById(quint64 blockId) override;
std::optional<Block> getBlockByHash(const QString& hash) override;
std::optional<Transaction> getTransaction(const QString& hash) override;
QVector<Block> getBlocks(std::optional<quint64> before, int limit) override;
quint64 getLastFinalizedBlockId() override;
QVector<Transaction> getTransactionsByAccount(const QString& accountId, int offset, int limit) override;
SearchResults search(const QString& query) override;
private slots:
void onConnected();
void onDisconnected();
void onSocketError(QAbstractSocket::SocketError error);
void onTextMessageReceived(const QString& message);
private:
void handleFinalizedBlockNotification(const QJsonValue& result);
void fetchAndEmitFinalizedBlock(quint64 blockId);
bool ensureConnected();
void ensureFinalizedBlocksSubscription();
std::optional<QJsonValue> callRpc(const QString& method, const QJsonArray& params = QJsonArray());
std::optional<Block> parseBlock(const QJsonValue& value) const;
std::optional<Transaction> parseTransaction(const QJsonValue& value) const;
std::optional<Account> parseAccount(const QString& accountId, const QJsonValue& value) const;
static QString jsonValueToString(const QJsonValue& value);
static quint64 jsonValueToU64(const QJsonValue& value);
static int base64Size(const QString& value);
QWebSocket m_socket;
QUrl m_endpoint;
quint64 m_nextRequestId = 1;
QHash<quint64, QJsonObject> m_responses;
QHash<quint64, QEventLoop*> m_waiters;
quint64 m_subscriptionRequestId = 0;
QJsonValue m_subscriptionId;
bool m_subscriptionActive = false;
quint64 m_lastNotifiedBlockId = 0;
};

View File

@ -1,47 +0,0 @@
#pragma once
#include "Style.h"
#include <QFrame>
#include <QMouseEvent>
class ClickableFrame : public QFrame {
Q_OBJECT
public:
explicit ClickableFrame(QWidget* parent = nullptr)
: QFrame(parent)
{
setCursor(Qt::PointingHandCursor);
}
signals:
void clicked();
protected:
void mousePressEvent(QMouseEvent* event) override
{
if (event->button() == Qt::LeftButton) {
emit clicked();
}
QFrame::mousePressEvent(event);
}
void enterEvent(QEnterEvent* event) override
{
m_baseStyleSheet = styleSheet();
setStyleSheet(m_baseStyleSheet +
QString(" ClickableFrame { background: %1 !important; border-color: %2 !important; }")
.arg(Style::Color::surfaceHover(), Style::Color::accent()));
QFrame::enterEvent(event);
}
void leaveEvent(QEvent* event) override
{
setStyleSheet(m_baseStyleSheet);
QFrame::leaveEvent(event);
}
private:
QString m_baseStyleSheet;
};

View File

@ -1,63 +0,0 @@
#include "NavigationBar.h"
#include "Style.h"
#include <QHBoxLayout>
#include <QPushButton>
#include <QLabel>
#include <QIcon>
NavigationBar::NavigationBar(QWidget* parent)
: QWidget(parent)
{
auto* layout = new QHBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(6);
const QSize iconSize(22, 22);
m_backBtn = new QPushButton(this);
m_backBtn->setIcon(QIcon(":/icons/arrow-left.svg"));
m_backBtn->setIconSize(iconSize);
m_backBtn->setFixedSize(42, 42);
m_backBtn->setEnabled(false);
m_backBtn->setToolTip("Back");
m_backBtn->setStyleSheet(Style::navButton());
m_forwardBtn = new QPushButton(this);
m_forwardBtn->setIcon(QIcon(":/icons/arrow-right.svg"));
m_forwardBtn->setIconSize(iconSize);
m_forwardBtn->setFixedSize(42, 42);
m_forwardBtn->setEnabled(false);
m_forwardBtn->setToolTip("Forward");
m_forwardBtn->setStyleSheet(Style::navButton());
auto* homeBtn = new QPushButton(this);
homeBtn->setIcon(QIcon(":/icons/home.svg"));
homeBtn->setIconSize(iconSize);
homeBtn->setFixedSize(42, 42);
homeBtn->setToolTip("Home");
homeBtn->setStyleSheet(Style::navButton());
auto* titleLabel = new QLabel("LEZ Explorer");
titleLabel->setStyleSheet(QString("color: %1; font-size: 14px; font-weight: bold; margin-left: 8px;").arg(Style::Color::accent()));
layout->addWidget(m_backBtn);
layout->addWidget(m_forwardBtn);
layout->addWidget(homeBtn);
layout->addWidget(titleLabel);
layout->addStretch();
connect(m_backBtn, &QPushButton::clicked, this, &NavigationBar::backClicked);
connect(m_forwardBtn, &QPushButton::clicked, this, &NavigationBar::forwardClicked);
connect(homeBtn, &QPushButton::clicked, this, &NavigationBar::homeClicked);
}
void NavigationBar::setBackEnabled(bool enabled)
{
m_backBtn->setEnabled(enabled);
}
void NavigationBar::setForwardEnabled(bool enabled)
{
m_forwardBtn->setEnabled(enabled);
}

View File

@ -1,24 +0,0 @@
#pragma once
#include <QWidget>
class QPushButton;
class NavigationBar : public QWidget {
Q_OBJECT
public:
explicit NavigationBar(QWidget* parent = nullptr);
void setBackEnabled(bool enabled);
void setForwardEnabled(bool enabled);
signals:
void backClicked();
void forwardClicked();
void homeClicked();
private:
QPushButton* m_backBtn = nullptr;
QPushButton* m_forwardBtn = nullptr;
};

View File

@ -1,38 +0,0 @@
#include "SearchBar.h"
#include "Style.h"
#include <QHBoxLayout>
#include <QLineEdit>
#include <QPushButton>
SearchBar::SearchBar(QWidget* parent)
: QWidget(parent)
{
auto* layout = new QHBoxLayout(this);
layout->setContentsMargins(0, 4, 0, 4);
layout->setSpacing(8);
m_input = new QLineEdit(this);
m_input->setPlaceholderText("Search by block ID / block hash / tx hash / account ID...");
m_input->setMinimumHeight(38);
m_input->setStyleSheet(Style::searchInput());
auto* searchBtn = new QPushButton("Search", this);
searchBtn->setMinimumHeight(38);
searchBtn->setStyleSheet(Style::searchButton());
layout->addWidget(m_input, 1);
layout->addWidget(searchBtn);
connect(m_input, &QLineEdit::returnPressed, this, [this]() {
emit searchRequested(m_input->text());
});
connect(searchBtn, &QPushButton::clicked, this, [this]() {
emit searchRequested(m_input->text());
});
}
void SearchBar::clear()
{
m_input->clear();
}

View File

@ -1,20 +0,0 @@
#pragma once
#include <QWidget>
class QLineEdit;
class SearchBar : public QWidget {
Q_OBJECT
public:
explicit SearchBar(QWidget* parent = nullptr);
void clear();
signals:
void searchRequested(const QString& query);
private:
QLineEdit* m_input = nullptr;
};

28
tests/ui-tests.mjs Normal file
View File

@ -0,0 +1,28 @@
import { resolve } from "node:path";
// CI sets LOGOS_QT_MCP automatically; for interactive use:
// nix build .#test-framework -o result-mcp
const root = process.env.LOGOS_QT_MCP || new URL("../result-mcp", import.meta.url).pathname;
const { test, run } = await import(resolve(root, "test-framework/framework.mjs"));
// Smoke tests: the view loads and the home page renders. They do NOT assert a
// live indexer connection — with no indexer ingesting, the backend stays in
// "Connecting" (chain tip is 0), which is the correct empty state. They only
// verify the QML view comes up and the backend is wired (readiness overlay
// clears, revealing the navigation bar + home page).
test("lez_explorer_ui: loads the view", async (app) => {
await app.waitFor(
async () => { await app.expectTexts(["LEZ Explorer"]); },
{ timeout: 30000, interval: 500, description: "the explorer view to load" }
);
});
test("lez_explorer_ui: renders the home page", async (app) => {
await app.waitFor(
async () => { await app.expectTexts(["Recent Blocks", "Settings"]); },
{ timeout: 15000, interval: 500, description: "the home page to render" }
);
});
run();