diff --git a/.gitignore b/.gitignore index b2be92b..aaf410d 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ result +build diff --git a/CMakeLists.txt b/CMakeLists.txt index 34362a3..95f17ce 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 ) diff --git a/flake.nix b/flake.nix index 30b99af..63a354d 100644 --- a/flake.nix +++ b/flake.nix @@ -42,6 +42,8 @@ ]; buildInputs = [ pkgs.qt6.qtbase + pkgs.qt6.qtsvg + pkgs.qt6.qtwebsockets ]; shellHook = '' diff --git a/nix/app.nix b/nix/app.nix index 0e9f287..aa087f3 100644 --- a/nix/app.nix +++ b/nix/app.nix @@ -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 ]); diff --git a/nix/default.nix b/nix/default.nix index 838f23b..5cdd511 100644 --- a/nix/default.nix +++ b/nix/default.nix @@ -15,6 +15,7 @@ buildInputs = [ pkgs.qt6.qtbase pkgs.qt6.qtsvg + pkgs.qt6.qtwebsockets ]; cmakeFlags = [ diff --git a/src/ExplorerWidget.cpp b/src/ExplorerWidget.cpp index 0549dc5..4859933 100644 --- a/src/ExplorerWidget.cpp +++ b/src/ExplorerWidget.cpp @@ -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()) + , m_indexer(std::make_unique()) { setStyleSheet(Style::appBackground()); diff --git a/src/pages/MainPage.cpp b/src/pages/MainPage.cpp index ce7ca7e..8dbdb0b 100644 --- a/src/pages/MainPage.cpp +++ b/src/pages/MainPage.cpp @@ -9,6 +9,9 @@ #include #include #include +#include + +#include 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(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 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() diff --git a/src/pages/MainPage.h b/src/pages/MainPage.h index 38926d2..8d151a1 100644 --- a/src/pages/MainPage.h +++ b/src/pages/MainPage.h @@ -3,11 +3,13 @@ #include "services/IndexerService.h" #include +#include #include 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 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 m_newestLoadedBlockId; std::optional m_oldestLoadedBlockId; + QSet m_displayedBlockIds; + quint64 m_latestKnownBlockId = 0; }; diff --git a/src/services/IndexerService.h b/src/services/IndexerService.h index 4e43325..565104e 100644 --- a/src/services/IndexerService.h +++ b/src/services/IndexerService.h @@ -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 getAccount(const QString& accountId) = 0; virtual std::optional getBlockById(quint64 blockId) = 0; virtual std::optional getBlockByHash(const QString& hash) = 0; virtual std::optional getTransaction(const QString& hash) = 0; virtual QVector getBlocks(std::optional before, int limit) = 0; - virtual quint64 getLatestBlockId() = 0; + virtual quint64 getLastFinalizedBlockId() = 0; virtual QVector getTransactionsByAccount(const QString& accountId, int offset, int limit) = 0; virtual SearchResults search(const QString& query) = 0; diff --git a/src/services/MockIndexerService.cpp b/src/services/MockIndexerService.cpp index 96b6d40..2ec3059 100644 --- a/src/services/MockIndexerService.cpp +++ b/src/services/MockIndexerService.cpp @@ -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 MockIndexerService::getBlocks(std::optional before, int return result; } -quint64 MockIndexerService::getLatestBlockId() +quint64 MockIndexerService::getLastFinalizedBlockId() { if (m_blocks.isEmpty()) return 0; return m_blocks.last().blockId; diff --git a/src/services/MockIndexerService.h b/src/services/MockIndexerService.h index a833ffe..5c57c3e 100644 --- a/src/services/MockIndexerService.h +++ b/src/services/MockIndexerService.h @@ -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 getAccount(const QString& accountId) override; std::optional getBlockById(quint64 blockId) override; std::optional getBlockByHash(const QString& hash) override; std::optional getTransaction(const QString& hash) override; QVector getBlocks(std::optional before, int limit) override; - quint64 getLatestBlockId() override; + quint64 getLastFinalizedBlockId() override; QVector getTransactionsByAccount(const QString& accountId, int offset, int limit) override; SearchResults search(const QString& query) override; @@ -37,4 +40,5 @@ private: QMap m_transactionsByHash; QMap m_accounts; QTimer m_blockTimer; + QString m_endpoint = IndexerService::defaultEndpoint(); }; diff --git a/src/services/RpcIndexerService.cpp b/src/services/RpcIndexerService.cpp new file mode 100644 index 0000000..690af73 --- /dev/null +++ b/src/services/RpcIndexerService.cpp @@ -0,0 +1,741 @@ +#include "RpcIndexerService.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +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(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::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 RpcIndexerService::getAccount(const QString& accountId) +{ + auto result = callRpc("getAccount", QJsonArray{accountId}); + if (!result) { + return std::nullopt; + } + return parseAccount(accountId, *result); +} + +std::optional RpcIndexerService::getBlockById(quint64 blockId) +{ + auto result = callRpc("getBlockById", QJsonArray{static_cast(blockId)}); + if (!result || result->isNull()) { + return std::nullopt; + } + return parseBlock(*result); +} + +std::optional RpcIndexerService::getBlockByHash(const QString& hash) +{ + auto result = callRpc("getBlockByHash", QJsonArray{hash}); + if (!result || result->isNull()) { + return std::nullopt; + } + return parseBlock(*result); +} + +std::optional RpcIndexerService::getTransaction(const QString& hash) +{ + auto result = callRpc("getTransaction", QJsonArray{hash}); + if (!result || result->isNull()) { + return std::nullopt; + } + return parseTransaction(*result); +} + +QVector RpcIndexerService::getBlocks(std::optional before, int limit) +{ + QJsonArray params; + if (before.has_value()) { + params.append(static_cast(*before)); + } else { + params.append(QJsonValue::Null); + } + params.append(limit); + + auto result = callRpc("getBlocks", params); + if (!result || !result->isArray()) { + return {}; + } + + QVector 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 RpcIndexerService::getTransactionsByAccount(const QString& accountId, int offset, int limit) +{ + auto result = callRpc("getTransactionsByAccount", QJsonArray{accountId, offset, limit}); + if (!result || !result->isArray()) { + return {}; + } + + QVector 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::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(requestId)); + request.insert("method", "subscribeToFinalizedBlocks"); + request.insert("params", QJsonArray()); + + m_socket.sendTextMessage(QString::fromUtf8(QJsonDocument(request).toJson(QJsonDocument::Compact))); +} + +std::optional 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(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 result = response.value("result"); + qDebug() << "RPC call result for" << method << "(" << params << "):" << jsonValueToString(*result); + return result; +} + +std::optional 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(timestamp), QTimeZone::UTC); + } else { + block.timestamp = QDateTime::fromSecsSinceEpoch(static_cast(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 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(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 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(number); + if (number == static_cast(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(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(); +} diff --git a/src/services/RpcIndexerService.h b/src/services/RpcIndexerService.h new file mode 100644 index 0000000..23b3ba4 --- /dev/null +++ b/src/services/RpcIndexerService.h @@ -0,0 +1,66 @@ +#pragma once + +#include "IndexerService.h" + +#include +#include +#include +#include +#include +#include + +#include + +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 getAccount(const QString& accountId) override; + std::optional getBlockById(quint64 blockId) override; + std::optional getBlockByHash(const QString& hash) override; + std::optional getTransaction(const QString& hash) override; + QVector getBlocks(std::optional before, int limit) override; + quint64 getLastFinalizedBlockId() override; + QVector 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 callRpc(const QString& method, const QJsonArray& params = QJsonArray()); + + std::optional parseBlock(const QJsonValue& value) const; + std::optional parseTransaction(const QJsonValue& value) const; + std::optional 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 m_responses; + QHash m_waiters; + quint64 m_subscriptionRequestId = 0; + QJsonValue m_subscriptionId; + bool m_subscriptionActive = false; + quint64 m_lastNotifiedBlockId = 0; +};