use logos protocol with indexer dependency

This commit is contained in:
erhant 2026-06-18 14:13:42 +03:00
parent 8ea8574778
commit f4c180ccdc
19 changed files with 20192 additions and 1130 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

View File

@ -1,116 +1,68 @@
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)
# Hand-written Q_OBJECT classes (widgets/pages/services) need MOC;
# explorer_resources.qrc -> compiled icons via AUTORCC.
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
# Legacy `type: ui` QWidget plugin (hand-written ExplorerPlugin / IComponent
# no `interface: universal`, so no codegen). The builder links the logos
# SDK/protocol (logos_api.h / getClient) automatically; we add the Qt widget
# stack the explorer needs on top of the default Core/RemoteObjects.
logos_module(
NAME lez_explorer_ui
SOURCES
src/ExplorerPlugin.cpp
src/ExplorerPlugin.h
src/ExplorerWidget.cpp
src/ExplorerWidget.h
src/Style.h
src/services/IndexerService.h
src/services/LpIndexerService.cpp
src/services/LpIndexerService.h
src/services/MockIndexerService.cpp
src/services/MockIndexerService.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
FIND_PACKAGES
Qt6Widgets
Qt6Svg
Qt6SvgWidgets
Qt6Network
LINK_LIBRARIES
Qt6::Widgets
Qt6::Svg
Qt6::SvgWidgets
Qt6::Network
INCLUDE_DIRS
src
interfaces
)
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/metadata.json
DESTINATION ${CMAKE_INSTALL_DATADIR}/lez-explorer-ui
)
message(STATUS "LEZ Explorer UI Plugin configured successfully")

19537
flake.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,61 +1,22 @@
{
description = "LEZ Explorer UI - A Qt block explorer for the Logos Execution Zone";
description = "LEZ Explorer UI - a Qt 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";
# The provider it reads from, over the Logos protocol. Input name MUST match
# the metadata.json "dependencies" entry. For local development against an
# un-pushed branch, override with a path:
# lez_indexer_module.url = "path:../logos-execution-zone-indexer-module";
# TODO: use the correct branch & repo name afterwards
lez_indexer_module.url = "github:logos-blockchain/logos-execution-zone-indexer-module/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.mkLogosModule {
src = ./.;
configFile = ./metadata.json;
flakeInputs = inputs;
};
}

View File

@ -1,10 +1,22 @@
{
"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": [],
"category": "explorer",
"capabilities": ["ui_components"]
"description": "LEZ Explorer UI - A block explorer for the Logos Execution Zone",
"main": "lez_explorer_ui_plugin",
"dependencies": ["lez_indexer_module"],
"nix": {
"packages": {
"build": ["qt6.qtsvg"],
"runtime": ["qt6.qtsvg"]
},
"external_libraries": [],
"cmake": {
"find_packages": ["Qt6Widgets", "Qt6Svg", "Qt6SvgWidgets", "Qt6Network"],
"extra_sources": [],
"extra_include_dirs": ["interfaces"],
"extra_link_libraries": ["Qt6::Widgets", "Qt6::Svg", "Qt6::SvgWidgets", "Qt6::Network"]
}
}
}

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

@ -6,9 +6,9 @@ ExplorerPlugin::ExplorerPlugin(QObject* parent)
{
}
QWidget* ExplorerPlugin::createWidget(LogosAPI* /*logosAPI*/)
QWidget* ExplorerPlugin::createWidget(LogosAPI* logosAPI)
{
return new ExplorerWidget();
return new ExplorerWidget(logosAPI);
}
void ExplorerPlugin::destroyWidget(QWidget* widget)

View File

@ -1,6 +1,6 @@
#include "ExplorerWidget.h"
#include "Style.h"
#include "services/RpcIndexerService.h"
#include "services/LpIndexerService.h"
#include "widgets/NavigationBar.h"
#include "widgets/SearchBar.h"
#include "pages/MainPage.h"
@ -12,9 +12,9 @@
#include <QStackedWidget>
#include <QLabel>
ExplorerWidget::ExplorerWidget(QWidget* parent)
ExplorerWidget::ExplorerWidget(LogosAPI* logosAPI, QWidget* parent)
: QWidget(parent)
, m_indexer(std::make_unique<RpcIndexerService>())
, m_indexer(std::make_unique<LpIndexerService>(logosAPI))
{
setStyleSheet(Style::appBackground());

View File

@ -11,6 +11,7 @@ class QStackedWidget;
class NavigationBar;
class SearchBar;
class MainPage;
class LogosAPI;
struct NavHome {};
struct NavBlock { quint64 blockId; };
@ -23,7 +24,7 @@ class ExplorerWidget : public QWidget {
Q_OBJECT
public:
explicit ExplorerWidget(QWidget* parent = nullptr);
explicit ExplorerWidget(LogosAPI* logosAPI = nullptr, QWidget* parent = nullptr);
~ExplorerWidget() override;
private:

View File

@ -30,5 +30,4 @@ struct Block {
QString signature;
QVector<Transaction> transactions;
BedrockStatus bedrockStatus = BedrockStatus::Pending;
QString bedrockParentId;
};

View File

@ -24,12 +24,12 @@ MainPage::MainPage(IndexerService* indexer, QWidget* parent)
auto* endpointRow = new QHBoxLayout();
endpointRow->setSpacing(8);
auto* endpointLabel = new QLabel("Indexer RPC", this);
auto* endpointLabel = new QLabel("Indexer config", 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->setPlaceholderText("absolute path to indexer_config.json (blank = already running)");
m_indexerEndpointInput->setMinimumHeight(34);
m_indexerEndpointInput->setStyleSheet(Style::searchInput());
@ -358,13 +358,10 @@ void MainPage::loadMoreBlocks()
void MainPage::applyIndexerEndpoint()
{
QString endpoint = m_indexerEndpointInput->text().trimmed();
if (endpoint.isEmpty()) {
endpoint = IndexerService::defaultEndpoint();
m_indexerEndpointInput->setText(endpoint);
}
m_indexer->setEndpoint(endpoint);
// The "endpoint" is the indexer config path now; blank is valid and means
// "the indexer is already running" (start is idempotent provider-side).
const QString configPath = m_indexerEndpointInput->text().trimmed();
m_indexer->setEndpoint(configPath);
clearSearchResults();
refresh();
}

View File

@ -21,9 +21,12 @@ public:
explicit IndexerService(QObject* parent = nullptr) : QObject(parent) {}
~IndexerService() override = default;
// Over the Logos protocol the "endpoint" is repurposed to the indexer
// config path (absolute path to indexer_config.json); blank means the
// indexer is already running. No network URL is involved anymore.
static QString defaultEndpoint()
{
return "ws://localhost:8779";
return QString();
}
virtual QString endpoint() const = 0;

View File

@ -0,0 +1,438 @@
#include "LpIndexerService.h"
#include "logos_api.h"
#include "logos_api_client.h"
#include <QByteArray>
#include <QDateTime>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonValue>
#include <QRegularExpression>
#include <QTimer>
#include <QTimeZone>
#include <QVariant>
#include <algorithm>
namespace {
constexpr auto kModuleName = "lez_indexer_module";
constexpr int kPollIntervalMs = 2000;
// 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 {};
}
QDateTime timestampFromSeconds(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);
}
return dt.isValid() ? dt : QDateTime::currentDateTimeUtc();
}
} // namespace
LpIndexerService::LpIndexerService(LogosAPI* logosAPI, const QString& configPath, const QString& port, QObject* parent)
: IndexerService(parent)
, m_logosAPI(logosAPI)
, m_configPath(configPath.trimmed())
, m_port(port.trimmed())
{
if (m_logosAPI) {
m_client = m_logosAPI->getClient(kModuleName);
}
if (!m_client) {
qWarning() << "LpIndexerService: no LogosAPI client for" << kModuleName;
}
// Best-effort boot (no-op provider-side if already running, or if no config).
startIndexer();
// The provider has no push event — poll the tip for live head updates.
m_pollTimer = new QTimer(this);
m_pollTimer->setInterval(kPollIntervalMs);
connect(m_pollTimer, &QTimer::timeout, this, &LpIndexerService::pollForNewBlocks);
m_pollTimer->start();
}
LpIndexerService::~LpIndexerService() = default;
QString LpIndexerService::endpoint() const
{
return m_configPath;
}
void LpIndexerService::setEndpoint(const QString& endpoint)
{
const QString trimmed = endpoint.trimmed();
if (trimmed == m_configPath) {
return;
}
m_configPath = trimmed;
// Re-baseline the live poller; the (re)started indexer may ingest afresh.
m_lastNotifiedBlockId = 0;
m_polledOnce = false;
startIndexer();
}
void LpIndexerService::startIndexer()
{
if (!m_client || m_configPath.isEmpty()) {
return; // assume the indexer was started elsewhere
}
const QVariant result = m_client->invokeRemoteMethod(kModuleName, "start_indexer", m_configPath, m_port);
const int code = result.isValid() ? result.toInt() : -1;
if (code != 0) {
qWarning() << "LpIndexerService: start_indexer returned" << code << "for" << m_configPath;
}
}
std::optional<Account> LpIndexerService::getAccount(const QString& accountId)
{
if (!m_client) {
return std::nullopt;
}
const QVariant r = m_client->invokeRemoteMethod(kModuleName, "getAccount", accountId);
return parseAccount(accountId, r.isValid() ? r.toString() : QString());
}
std::optional<Block> LpIndexerService::getBlockById(quint64 blockId)
{
if (!m_client) {
return std::nullopt;
}
const QVariant r = m_client->invokeRemoteMethod(kModuleName, "getBlockById", QString::number(blockId));
return parseBlock(r.isValid() ? r.toString() : QString());
}
std::optional<Block> LpIndexerService::getBlockByHash(const QString& hash)
{
if (!m_client) {
return std::nullopt;
}
const QVariant r = m_client->invokeRemoteMethod(kModuleName, "getBlockByHash", hash);
return parseBlock(r.isValid() ? r.toString() : QString());
}
std::optional<Transaction> LpIndexerService::getTransaction(const QString& hash)
{
if (!m_client) {
return std::nullopt;
}
const QVariant r = m_client->invokeRemoteMethod(kModuleName, "getTransaction", hash);
const QString json = r.isValid() ? r.toString() : QString();
if (json.isEmpty()) {
return std::nullopt;
}
const QJsonDocument doc = QJsonDocument::fromJson(json.toUtf8());
if (!doc.isObject()) {
return std::nullopt;
}
return parseTransaction(doc.object());
}
QVector<Block> LpIndexerService::getBlocks(std::optional<quint64> before, int limit)
{
if (!m_client) {
return {};
}
const QString beforeArg = before.has_value() ? QString::number(*before) : QString();
const QVariant r = m_client->invokeRemoteMethod(kModuleName, "getBlocks", beforeArg, QString::number(limit));
const QString json = r.isValid() ? r.toString() : QString();
if (json.isEmpty()) {
return {};
}
const QJsonDocument doc = QJsonDocument::fromJson(json.toUtf8());
if (!doc.isArray()) {
return {};
}
QVector<Block> blocks;
const QJsonArray array = doc.array();
blocks.reserve(array.size());
for (const auto& item : array) {
if (auto block = parseBlockObject(item.toObject())) {
blocks.append(*block);
}
}
return blocks;
}
quint64 LpIndexerService::getLastFinalizedBlockId()
{
if (!m_client) {
return 0;
}
const QVariant r = m_client->invokeRemoteMethod(kModuleName, "getLastFinalizedBlockId");
if (!r.isValid()) {
return 0;
}
bool ok = false;
const qulonglong parsed = r.toString().toULongLong(&ok);
return ok ? parsed : 0;
}
QVector<Transaction> LpIndexerService::getTransactionsByAccount(const QString& accountId, int offset, int limit)
{
if (!m_client) {
return {};
}
const QVariant r = m_client->invokeRemoteMethod(
kModuleName, "getTransactionsByAccount", accountId, QString::number(offset), QString::number(limit));
const QString json = r.isValid() ? r.toString() : QString();
if (json.isEmpty()) {
return {};
}
const QJsonDocument doc = QJsonDocument::fromJson(json.toUtf8());
if (!doc.isArray()) {
return {};
}
QVector<Transaction> transactions;
const QJsonArray array = doc.array();
transactions.reserve(array.size());
for (const auto& item : array) {
if (auto tx = parseTransaction(item.toObject())) {
transactions.append(*tx);
}
}
return transactions;
}
SearchResults LpIndexerService::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 bool 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 LpIndexerService::pollForNewBlocks()
{
const quint64 latest = getLastFinalizedBlockId();
if (latest == 0) {
return;
}
// First successful poll establishes a baseline without re-emitting blocks
// the initial MainPage::refresh already loaded.
if (!m_polledOnce) {
m_polledOnce = true;
m_lastNotifiedBlockId = latest;
return;
}
if (latest <= m_lastNotifiedBlockId) {
return;
}
// For a large jump (or a gap), emit only the new head: MainPage::onNewBlock
// detects the discontinuity and rebuilds the recent list from the backend.
if (latest - m_lastNotifiedBlockId > 8) {
if (auto block = getBlockById(latest)) {
m_lastNotifiedBlockId = latest;
emit newBlockAdded(*block);
}
return;
}
for (quint64 id = m_lastNotifiedBlockId + 1; id <= latest; ++id) {
if (auto block = getBlockById(id)) {
emit newBlockAdded(*block);
}
}
m_lastNotifiedBlockId = latest;
}
std::optional<Block> LpIndexerService::parseBlock(const QString& json) const
{
if (json.isEmpty()) {
return std::nullopt;
}
const QJsonDocument doc = QJsonDocument::fromJson(json.toUtf8());
if (!doc.isObject()) {
return std::nullopt;
}
return parseBlockObject(doc.object());
}
std::optional<Block> LpIndexerService::parseBlockObject(const QJsonObject& obj) const
{
if (obj.isEmpty()) {
return std::nullopt;
}
Block block;
block.blockId = jsonU64(obj.value("block_id"));
block.hash = jsonStr(obj.value("hash"));
block.prevBlockHash = jsonStr(obj.value("prev_block_hash"));
block.signature = jsonStr(obj.value("signature"));
block.timestamp = timestampFromSeconds(jsonU64(obj.value("timestamp")));
const QString status = obj.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 = obj.value("transactions").toArray();
block.transactions.reserve(txArray.size());
for (const auto& txValue : txArray) {
if (auto tx = parseTransaction(txValue.toObject())) {
block.transactions.append(*tx);
}
}
return block;
}
std::optional<Transaction> LpIndexerService::parseTransaction(const QJsonObject& obj) const
{
if (obj.isEmpty()) {
return std::nullopt;
}
Transaction tx;
tx.hash = jsonStr(obj.value("hash"));
const QString type = obj.value("type").toString();
auto appendAccounts = [&tx](const QJsonArray& accounts) {
for (const auto& entry : accounts) {
const QJsonObject account = entry.toObject();
AccountRef ref;
ref.accountId = jsonStr(account.value("account_id"));
ref.nonce = jsonStr(account.value("nonce"));
if (ref.nonce.isEmpty()) {
ref.nonce = "0";
}
tx.accounts.append(ref);
}
};
if (type == "Public") {
tx.type = TransactionType::Public;
tx.programId = jsonStr(obj.value("program_id"));
appendAccounts(obj.value("accounts").toArray());
const QJsonArray instructionData = obj.value("instruction_data").toArray();
tx.instructionData.reserve(instructionData.size());
for (const auto& v : instructionData) {
tx.instructionData.append(static_cast<quint32>(jsonU64(v)));
}
tx.signatureCount = obj.value("signature_count").toInt();
return tx;
}
if (type == "PrivacyPreserving") {
tx.type = TransactionType::PrivacyPreserving;
appendAccounts(obj.value("accounts").toArray());
tx.newCommitmentsCount = obj.value("new_commitments_count").toInt();
tx.nullifiersCount = obj.value("nullifiers_count").toInt();
tx.encryptedStatesCount = obj.value("encrypted_states_count").toInt();
tx.validityWindowStart = jsonU64(obj.value("validity_window_start"));
tx.validityWindowEnd = jsonU64(obj.value("validity_window_end"));
tx.signatureCount = obj.value("signature_count").toInt();
tx.proofSizeBytes = obj.value("proof_size").toInt();
return tx;
}
if (type == "ProgramDeployment") {
tx.type = TransactionType::ProgramDeployment;
tx.bytecodeSizeBytes = obj.value("bytecode_size").toInt();
return tx;
}
return std::nullopt;
}
std::optional<Account> LpIndexerService::parseAccount(const QString& accountId, const QString& json) const
{
if (json.isEmpty()) {
return std::nullopt;
}
const QJsonDocument doc = QJsonDocument::fromJson(json.toUtf8());
if (!doc.isObject()) {
return std::nullopt;
}
const QJsonObject obj = doc.object();
Account account;
account.accountId = accountId; // provider payload omits it; inject the queried id
account.programOwner = jsonStr(obj.value("program_owner"));
account.balance = jsonStr(obj.value("balance"));
account.nonce = jsonStr(obj.value("nonce"));
account.dataSizeBytes = obj.value("data_size").toInt();
if (account.balance.isEmpty()) {
account.balance = "0";
}
if (account.nonce.isEmpty()) {
account.nonce = "0";
}
return account;
}

View File

@ -0,0 +1,69 @@
#pragma once
#include "IndexerService.h"
#include <QString>
#include <optional>
class LogosAPI;
class LogosAPIClient;
class QTimer;
class QJsonObject;
// IndexerService implementation that talks to `lez_indexer_module` IN-PROCESS
// over the Logos protocol (Qt Remote Objects), via
// LogosAPI::getClient(...)->invokeRemoteMethod(...). Replaces the old WebSocket
// JSON-RPC RpcIndexerService.
//
// The provider returns a flat, compact JSON schema (hex hashes; 64/128-bit
// values as decimal strings; counts/sizes as numbers; "" == not-found), which
// differs from the old RPC schema — so this class carries its OWN parsers.
//
// The provider exposes no new-block push event, so live head updates are POLLED
// on a 2 s timer over getLastFinalizedBlockId.
//
// `endpoint()`/`setEndpoint()` are repurposed to the indexer CONFIG PATH (an
// absolute path to indexer_config.json) used to best-effort start the indexer;
// blank means "the indexer is already running" (start is idempotent provider-side).
class LpIndexerService : public IndexerService {
Q_OBJECT
public:
explicit LpIndexerService(
LogosAPI* logosAPI,
const QString& configPath = QString(),
const QString& port = QStringLiteral("8779"),
QObject* parent = nullptr
);
~LpIndexerService() override;
QString endpoint() const override; // the indexer config path
void setEndpoint(const QString& endpoint) override; // set config path + (re)start
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:
void startIndexer();
void pollForNewBlocks();
// Parsers for the provider's compact schema.
std::optional<Block> parseBlock(const QString& json) const;
std::optional<Block> parseBlockObject(const QJsonObject& obj) const;
std::optional<Transaction> parseTransaction(const QJsonObject& obj) const;
std::optional<Account> parseAccount(const QString& accountId, const QString& json) const;
LogosAPI* m_logosAPI = nullptr;
LogosAPIClient* m_client = nullptr;
QString m_configPath;
QString m_port;
QTimer* m_pollTimer = nullptr;
quint64 m_lastNotifiedBlockId = 0;
bool m_polledOnce = false;
};

View File

@ -63,7 +63,6 @@ Block MockIndexerService::generateBlock(quint64 blockId, const QString& 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);
@ -218,7 +217,6 @@ void MockIndexerService::generateData()
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) {

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