feat: add real connection

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Daniil Polyakov 2026-04-29 23:18:22 +03:00
parent a99d883240
commit 34a37bcbe7
13 changed files with 988 additions and 29 deletions

1
.gitignore vendored
View File

@ -1 +1,2 @@
result
build

View File

@ -8,7 +8,7 @@ set(CMAKE_AUTORCC ON)
set(CMAKE_AUTOUIC ON)
# Find Qt packages
find_package(Qt6 REQUIRED COMPONENTS Core Widgets Svg SvgWidgets)
find_package(Qt6 REQUIRED COMPONENTS Core Widgets Svg SvgWidgets Network WebSockets)
# Try to find the component-interfaces package first
find_package(component-interfaces QUIET)
@ -29,6 +29,8 @@ set(SOURCES
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
@ -71,6 +73,8 @@ target_link_libraries(lez_explorer_ui PRIVATE
Qt6::Widgets
Qt6::Svg
Qt6::SvgWidgets
Qt6::Network
Qt6::WebSockets
component-interfaces
)

View File

@ -42,6 +42,8 @@
];
buildInputs = [
pkgs.qt6.qtbase
pkgs.qt6.qtsvg
pkgs.qt6.qtwebsockets
];
shellHook = ''

View File

@ -15,6 +15,7 @@ pkgs.stdenv.mkDerivation rec {
qtLibPath = pkgs.lib.makeLibraryPath ([
pkgs.qt6.qtbase
pkgs.qt6.qtwebsockets
] ++ pkgs.lib.optionals pkgs.stdenv.isLinux [
pkgs.libglvnd
]);

View File

@ -15,6 +15,7 @@
buildInputs = [
pkgs.qt6.qtbase
pkgs.qt6.qtsvg
pkgs.qt6.qtwebsockets
];
cmakeFlags = [

View File

@ -1,6 +1,6 @@
#include "ExplorerWidget.h"
#include "Style.h"
#include "services/MockIndexerService.h"
#include "services/RpcIndexerService.h"
#include "widgets/NavigationBar.h"
#include "widgets/SearchBar.h"
#include "pages/MainPage.h"
@ -14,7 +14,7 @@
ExplorerWidget::ExplorerWidget(QWidget* parent)
: QWidget(parent)
, m_indexer(std::make_unique<MockIndexerService>())
, m_indexer(std::make_unique<RpcIndexerService>())
{
setStyleSheet(Style::appBackground());

View File

@ -9,6 +9,9 @@
#include <QFrame>
#include <QPixmap>
#include <QPushButton>
#include <QLineEdit>
#include <algorithm>
MainPage::MainPage(IndexerService* indexer, QWidget* parent)
: QWidget(parent)
@ -17,6 +20,31 @@ MainPage::MainPage(IndexerService* indexer, QWidget* parent)
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);
@ -72,11 +100,12 @@ QWidget* MainPage::createSectionHeader(const QString& title, const QString& icon
return container;
}
void MainPage::addBlockRow(QVBoxLayout* layout, const Block& block)
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);
@ -122,7 +151,63 @@ void MainPage::addBlockRow(QVBoxLayout* layout, const Block& block)
emit blockClicked(blockId);
});
layout->addWidget(frame);
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)
@ -211,8 +296,9 @@ void MainPage::refresh()
{
clearSearchResults();
quint64 latestId = m_indexer->getLatestBlockId();
m_healthLabel->setText(QString("Chain height: %1").arg(latestId));
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) {
@ -223,7 +309,9 @@ void MainPage::refresh()
m_loadMoreBtn = nullptr;
}
m_newestLoadedBlockId = std::nullopt;
m_oldestLoadedBlockId = std::nullopt;
m_displayedBlockIds.clear();
m_recentBlocksWidget = new QWidget();
auto* outerLayout = new QVBoxLayout(m_recentBlocksWidget);
@ -236,11 +324,10 @@ void MainPage::refresh()
m_blocksLayout->setContentsMargins(0, 0, 0, 0);
outerLayout->addWidget(blockRowsWidget);
auto blocks = m_indexer->getBlocks(std::nullopt, 10);
auto blocks = fetchRecentBlocks(10);
for (const auto& block : blocks) {
addBlockRow(m_blocksLayout, block);
if (!m_oldestLoadedBlockId || block.blockId < *m_oldestLoadedBlockId)
m_oldestLoadedBlockId = block.blockId;
insertRecentBlock(block);
}
m_loadMoreBtn = new QPushButton("Load more");
@ -258,13 +345,28 @@ void MainPage::loadMoreBlocks()
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) {
addBlockRow(m_blocksLayout, block);
if (block.blockId < *m_oldestLoadedBlockId)
m_oldestLoadedBlockId = block.blockId;
insertRecentBlock(block);
}
m_loadMoreBtn->setVisible(*m_oldestLoadedBlockId > 1 && !blocks.isEmpty());
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)
@ -319,19 +421,26 @@ void MainPage::onNewBlock(const Block& block)
{
if (!m_blocksLayout) return;
m_healthLabel->setText(QString("Chain height: %1").arg(block.blockId));
m_latestKnownBlockId = std::max(m_latestKnownBlockId, block.blockId);
m_healthLabel->setText(QString("Chain height: %1").arg(m_latestKnownBlockId));
// Append to layout then move to top (index 0)
int countBefore = m_blocksLayout->count();
addBlockRow(m_blocksLayout, block);
if (m_blocksLayout->count() <= countBefore) return;
// 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;
}
QLayoutItem* item = m_blocksLayout->itemAt(m_blocksLayout->count() - 1);
if (!item || !item->widget()) return;
if (block.blockId > *m_newestLoadedBlockId && (block.blockId - *m_newestLoadedBlockId) > 1) {
refresh();
return;
}
QWidget* newRow = item->widget();
m_blocksLayout->removeWidget(newRow);
m_blocksLayout->insertWidget(0, newRow);
insertRecentBlock(block);
if (m_loadMoreBtn) {
m_loadMoreBtn->setVisible(m_oldestLoadedBlockId && *m_oldestLoadedBlockId > 1);
}
}
void MainPage::clearSearchResults()

View File

@ -3,11 +3,13 @@
#include "services/IndexerService.h"
#include <QWidget>
#include <QSet>
#include <optional>
class QVBoxLayout;
class QLabel;
class QPushButton;
class QLineEdit;
class MainPage : public QWidget {
Q_OBJECT
@ -26,11 +28,14 @@ signals:
void accountClicked(const QString& accountId);
private:
void addBlockRow(QVBoxLayout* layout, const Block& block);
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;
@ -39,5 +44,9 @@ private:
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

@ -21,12 +21,20 @@ 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 getLatestBlockId() = 0;
virtual quint64 getLastFinalizedBlockId() = 0;
virtual QVector<Transaction> getTransactionsByAccount(const QString& accountId, int offset, int limit) = 0;
virtual SearchResults search(const QString& query) = 0;

View File

@ -40,6 +40,19 @@ MockIndexerService::MockIndexerService(QObject* parent)
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();
@ -303,7 +316,7 @@ QVector<Block> MockIndexerService::getBlocks(std::optional<quint64> before, int
return result;
}
quint64 MockIndexerService::getLatestBlockId()
quint64 MockIndexerService::getLastFinalizedBlockId()
{
if (m_blocks.isEmpty()) return 0;
return m_blocks.last().blockId;

View File

@ -11,12 +11,15 @@ class MockIndexerService : public IndexerService {
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 getLatestBlockId() override;
quint64 getLastFinalizedBlockId() override;
QVector<Transaction> getTransactionsByAccount(const QString& accountId, int offset, int limit) override;
SearchResults search(const QString& query) override;
@ -37,4 +40,5 @@ private:
QMap<QString, Transaction> m_transactionsByHash;
QMap<QString, Account> m_accounts;
QTimer m_blockTimer;
QString m_endpoint = IndexerService::defaultEndpoint();
};

View File

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

@ -0,0 +1,66 @@
#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;
};