#include "lez_explorer_ui_backend.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include // Generated umbrella: LogosModules (behind modules()) built from // metadata.json#dependencies — the Qt-typed wrappers for lez_indexer_module. #include "logos_sdk.h" namespace { constexpr int kPollIntervalMs = 2000; // Above this gap we rebuild the feed from the backend rather than fetch every // missing block one by one (matches the former LpIndexerService threshold). constexpr quint64 kGapRebuildThreshold = 8; // Starter config shown in the Settings editor (and its "Reset to default"). // A working local-dev indexer config; the user edits the fields (bedrock addr, // channel id, initial accounts/commitments, signing key, ...) and saves. const char* const kDefaultConfig = R"JSON({ "consensus_info_polling_interval": "1s", "bedrock_config": { "addr": "http://localhost:18080", "backoff": { "start_delay": "100ms", "max_retries": 5 } }, "channel_id": "0101010101010101010101010101010101010101010101010101010101010101", "initial_accounts": [ { "account_id": "CbgR6tj5kWx5oziiFptM7jMvrQeYY3Mzaao6ciuhSr2r", "balance": 10000 }, { "account_id": "2RHZhw9h534Zr3eq2RGhQete2Hh667foECzXPmSkGni2", "balance": 20000 } ], "initial_commitments": [], "signing_key": [ 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37 ] })JSON"; // 64/128-bit values arrive as decimal strings (to dodge JSON double precision // loss), but be lenient and also accept a JSON number. quint64 jsonU64(const QJsonValue& value) { if (value.isString()) { bool ok = false; const qulonglong parsed = value.toString().toULongLong(&ok); return ok ? parsed : 0; } if (value.isDouble()) { const double number = value.toDouble(); return number > 0.0 ? static_cast(number) : 0; } return 0; } QString jsonStr(const QJsonValue& value) { if (value.isString()) { return value.toString(); } if (value.isDouble()) { return QString::number(static_cast(value.toDouble())); } return {}; } QString timestampText(quint64 timestamp) { QDateTime dt; // Tolerate either seconds or milliseconds since epoch. if (timestamp > 1000000000000ULL) { dt = QDateTime::fromMSecsSinceEpoch(static_cast(timestamp), QTimeZone::UTC); } else { dt = QDateTime::fromSecsSinceEpoch(static_cast(timestamp), QTimeZone::UTC); } if (!dt.isValid()) { dt = QDateTime::currentDateTimeUtc(); } return dt.toString(QStringLiteral("yyyy-MM-dd hh:mm:ss")) + QStringLiteral(" UTC"); } QVariantMap txObjectToMap(const QJsonObject& obj) { QVariantMap tx; if (obj.isEmpty()) { return tx; } tx.insert(QStringLiteral("hash"), jsonStr(obj.value(QStringLiteral("hash")))); const QString type = obj.value(QStringLiteral("type")).toString(); tx.insert(QStringLiteral("type"), type); const auto accountsOf = [](const QJsonArray& arr) { QVariantList accounts; for (const auto& entry : arr) { const QJsonObject account = entry.toObject(); QVariantMap ref; ref.insert(QStringLiteral("accountId"), jsonStr(account.value(QStringLiteral("account_id")))); QString nonce = jsonStr(account.value(QStringLiteral("nonce"))); ref.insert(QStringLiteral("nonce"), nonce.isEmpty() ? QStringLiteral("0") : nonce); accounts.append(ref); } return accounts; }; if (type == QLatin1String("Public")) { tx.insert(QStringLiteral("programId"), jsonStr(obj.value(QStringLiteral("program_id")))); tx.insert(QStringLiteral("accounts"), accountsOf(obj.value(QStringLiteral("accounts")).toArray())); tx.insert(QStringLiteral("instructionDataCount"), obj.value(QStringLiteral("instruction_data")).toArray().size()); tx.insert(QStringLiteral("signatureCount"), obj.value(QStringLiteral("signature_count")).toInt()); } else if (type == QLatin1String("PrivacyPreserving")) { tx.insert(QStringLiteral("accounts"), accountsOf(obj.value(QStringLiteral("accounts")).toArray())); tx.insert(QStringLiteral("newCommitmentsCount"), obj.value(QStringLiteral("new_commitments_count")).toInt()); tx.insert(QStringLiteral("nullifiersCount"), obj.value(QStringLiteral("nullifiers_count")).toInt()); tx.insert(QStringLiteral("encryptedStatesCount"), obj.value(QStringLiteral("encrypted_states_count")).toInt()); tx.insert(QStringLiteral("validityWindowStart"), static_cast(jsonU64(obj.value(QStringLiteral("validity_window_start"))))); tx.insert(QStringLiteral("validityWindowEnd"), static_cast(jsonU64(obj.value(QStringLiteral("validity_window_end"))))); tx.insert(QStringLiteral("signatureCount"), obj.value(QStringLiteral("signature_count")).toInt()); tx.insert(QStringLiteral("proofSizeBytes"), obj.value(QStringLiteral("proof_size")).toInt()); } else if (type == QLatin1String("ProgramDeployment")) { tx.insert(QStringLiteral("bytecodeSizeBytes"), obj.value(QStringLiteral("bytecode_size")).toInt()); } return tx; } QVariantMap blockObjectToMap(const QJsonObject& obj) { QVariantMap block; if (obj.isEmpty()) { return block; } block.insert(QStringLiteral("blockId"), static_cast(jsonU64(obj.value(QStringLiteral("block_id"))))); block.insert(QStringLiteral("hash"), jsonStr(obj.value(QStringLiteral("hash")))); block.insert(QStringLiteral("prevBlockHash"), jsonStr(obj.value(QStringLiteral("prev_block_hash")))); block.insert(QStringLiteral("signature"), jsonStr(obj.value(QStringLiteral("signature")))); block.insert(QStringLiteral("timestampText"), timestampText(jsonU64(obj.value(QStringLiteral("timestamp"))))); const QString status = obj.value(QStringLiteral("bedrock_status")).toString(QStringLiteral("Pending")); block.insert(QStringLiteral("status"), status); const QJsonArray txArray = obj.value(QStringLiteral("transactions")).toArray(); QVariantList transactions; transactions.reserve(txArray.size()); for (const auto& txValue : txArray) { const QVariantMap tx = txObjectToMap(txValue.toObject()); if (!tx.isEmpty()) { transactions.append(tx); } } block.insert(QStringLiteral("transactions"), transactions); block.insert(QStringLiteral("txCount"), transactions.size()); return block; } QVariantMap blockJsonToMap(const QString& json) { if (json.isEmpty()) { return {}; } const QJsonDocument doc = QJsonDocument::fromJson(json.toUtf8()); return doc.isObject() ? blockObjectToMap(doc.object()) : QVariantMap(); } QVariantMap accountJsonToMap(const QString& accountId, const QString& json) { if (json.isEmpty()) { return {}; } const QJsonDocument doc = QJsonDocument::fromJson(json.toUtf8()); if (!doc.isObject()) { return {}; } const QJsonObject obj = doc.object(); QVariantMap account; account.insert(QStringLiteral("accountId"), accountId); // payload omits it; inject the queried id account.insert(QStringLiteral("programOwner"), jsonStr(obj.value(QStringLiteral("program_owner")))); QString balance = jsonStr(obj.value(QStringLiteral("balance"))); account.insert(QStringLiteral("balance"), balance.isEmpty() ? QStringLiteral("0") : balance); QString nonce = jsonStr(obj.value(QStringLiteral("nonce"))); account.insert(QStringLiteral("nonce"), nonce.isEmpty() ? QStringLiteral("0") : nonce); account.insert(QStringLiteral("dataSizeBytes"), obj.value(QStringLiteral("data_size")).toInt()); return account; } QVariantMap txJsonToMap(const QString& json) { if (json.isEmpty()) { return {}; } const QJsonDocument doc = QJsonDocument::fromJson(json.toUtf8()); return doc.isObject() ? txObjectToMap(doc.object()) : QVariantMap(); } } // namespace LezExplorerUiBackend::LezExplorerUiBackend() { setDefaultConfig(QString::fromUtf8(kDefaultConfig)); } LezExplorerUiBackend::~LezExplorerUiBackend() { // Defensive: ensure no poll fires while the backend is being torn down. if (m_pollTimer) { m_pollTimer->stop(); } } void LezExplorerUiBackend::onContextReady() { setConnectionStatus(QStringLiteral("Connecting")); // Reload + auto-start the previously-saved config, if any (set-once UX). const QString saved = readConfigFile(); if (!saved.isEmpty()) { setConfigText(saved); startIndexerFromFile(); } // The indexer pushes no events — poll the tip for live head updates. m_pollTimer = new QTimer(this); m_pollTimer->setInterval(kPollIntervalMs); connect(m_pollTimer, &QTimer::timeout, this, &LezExplorerUiBackend::pollTip); m_pollTimer->start(); // pollTip() makes a synchronous cross-process call into lez_indexer_module. // On shutdown the host SIGTERMs that dependency and only waits ~3s before // SIGKILL, while our IPC timeout is ~20s — so a poll landing mid-teardown // blocks on a dead peer and freezes the window. Stop polling the instant the // app starts quitting to close that window from our side. connect(qApp, &QCoreApplication::aboutToQuit, this, [this]() { if (m_pollTimer) { m_pollTimer->stop(); } }); // Kick an immediate poll so the view fills without waiting a full interval. pollTip(); } bool LezExplorerUiBackend::applyConfigJson(QString json) { const QString trimmed = json.trimmed(); if (trimmed.isEmpty()) { emit error(QStringLiteral("Config is empty.")); return false; } // Reject invalid JSON up front so we never start the indexer from garbage. QJsonParseError parseError; QJsonDocument::fromJson(trimmed.toUtf8(), &parseError); if (parseError.error != QJsonParseError::NoError) { emit error(QStringLiteral("Invalid JSON: %1").arg(parseError.errorString())); return false; } if (!writeConfigFile(json)) { emit error(QStringLiteral("Could not write config to %1").arg(configFilePath())); return false; } setConfigText(json); const bool ok = startIndexerFromFile(); // Re-baseline the poller; a (re)started indexer may ingest afresh. m_lastPolledTip = 0; m_polledOnce = false; refreshBlocks(); return ok; } void LezExplorerUiBackend::refreshBlocks() { const QString json = modules().lez_indexer_module.getBlocks(QString(), QStringLiteral("10")); m_recentBlocks = parseBlockArrayJson(json); m_newestLoadedId = 0; m_oldestLoadedId = 0; for (const QVariant& entry : std::as_const(m_recentBlocks)) { const quint64 id = entry.toMap().value(QStringLiteral("blockId")).toULongLong(); if (m_newestLoadedId == 0 || id > m_newestLoadedId) { m_newestLoadedId = id; } if (m_oldestLoadedId == 0 || id < m_oldestLoadedId) { m_oldestLoadedId = id; } } setRecentBlocks(m_recentBlocks); } void LezExplorerUiBackend::loadMoreBlocks() { if (m_oldestLoadedId <= 1) { return; // already at genesis } const QString json = modules().lez_indexer_module.getBlocks(QString::number(m_oldestLoadedId), QStringLiteral("10")); const QVariantList older = parseBlockArrayJson(json); if (older.isEmpty()) { return; } m_recentBlocks.append(older); for (const QVariant& entry : older) { const quint64 id = entry.toMap().value(QStringLiteral("blockId")).toULongLong(); if (id != 0 && (m_oldestLoadedId == 0 || id < m_oldestLoadedId)) { m_oldestLoadedId = id; } } setRecentBlocks(m_recentBlocks); } QVariantMap LezExplorerUiBackend::getBlockById(QString id) { return blockJsonToMap(modules().lez_indexer_module.getBlockById(id)); } QVariantMap LezExplorerUiBackend::getBlockByHash(QString hash) { return blockJsonToMap(modules().lez_indexer_module.getBlockByHash(hash)); } QVariantMap LezExplorerUiBackend::getTransaction(QString hash) { return txJsonToMap(modules().lez_indexer_module.getTransaction(hash)); } QVariantMap LezExplorerUiBackend::getAccount(QString accountId) { return accountJsonToMap(accountId, modules().lez_indexer_module.getAccount(accountId)); } QVariantList LezExplorerUiBackend::getTransactionsByAccount(QString accountId, int offset, int limit) { const QString json = modules().lez_indexer_module.getTransactionsByAccount( accountId, QString::number(offset), QString::number(limit)); if (json.isEmpty()) { return {}; } const QJsonDocument doc = QJsonDocument::fromJson(json.toUtf8()); if (!doc.isArray()) { return {}; } QVariantList result; const QJsonArray array = doc.array(); result.reserve(array.size()); for (const auto& item : array) { const QVariantMap tx = txObjectToMap(item.toObject()); if (!tx.isEmpty()) { result.append(tx); } } return result; } QVariantMap LezExplorerUiBackend::search(QString query) { QVariantList blocks; QVariantList transactions; QVariantList accounts; const QString trimmed = query.trimmed(); if (!trimmed.isEmpty()) { static const QRegularExpression hashRegex(QStringLiteral("^[0-9a-fA-F]{64}$")); if (hashRegex.match(trimmed).hasMatch()) { const QVariantMap block = getBlockByHash(trimmed); if (!block.isEmpty()) { blocks.append(block); } const QVariantMap tx = getTransaction(trimmed); if (!tx.isEmpty()) { transactions.append(tx); } const QVariantMap account = getAccount(trimmed); // getAccount always injects the id; treat "no payload fields" as miss. if (account.size() > 1) { accounts.append(account); } } bool ok = false; const quint64 blockId = trimmed.toULongLong(&ok); if (ok) { const QVariantMap block = getBlockById(QString::number(blockId)); if (!block.isEmpty()) { const bool duplicate = std::any_of(blocks.cbegin(), blocks.cend(), [blockId](const QVariant& e) { return e.toMap().value(QStringLiteral("blockId")).toULongLong() == blockId; }); if (!duplicate) { blocks.append(block); } } } } QVariantMap results; results.insert(QStringLiteral("blocks"), blocks); results.insert(QStringLiteral("transactions"), transactions); results.insert(QStringLiteral("accounts"), accounts); return results; } quint64 LezExplorerUiBackend::applySyncStatus(const QString& json) { // Empty JSON means the indexer isn't running (not configured / not started); // a real status always carries one of the core's states. QString state = QStringLiteral("stopped"); quint64 blockId = 0; QString errorMsg; if (!json.isEmpty()) { QJsonParseError parseError; const QJsonDocument doc = QJsonDocument::fromJson(json.toUtf8(), &parseError); if (parseError.error == QJsonParseError::NoError && doc.isObject()) { const QJsonObject obj = doc.object(); state = obj.value(QStringLiteral("state")).toString(QStringLiteral("starting")); blockId = static_cast(obj.value(QStringLiteral("indexedBlockId")).toDouble(0)); errorMsg = obj.value(QStringLiteral("lastError")).toString(); } } QVariantMap status; status.insert(QStringLiteral("state"), state); status.insert(QStringLiteral("blockId"), static_cast(blockId)); status.insert(QStringLiteral("error"), errorMsg); setSyncStatus(status); // Keep the legacy health indicator in step with the richer status. if (state == QLatin1String("error")) { setConnectionStatus(QStringLiteral("Error")); } else if (state == QLatin1String("syncing") || state == QLatin1String("caught_up")) { setConnectionStatus(QStringLiteral("Connected")); } else { setConnectionStatus(QStringLiteral("Connecting")); } return blockId; } void LezExplorerUiBackend::pollTip() { // One status call drives both the sync banner and the feed: indexedBlockId // is the L2 tip we page from. const quint64 tip = applySyncStatus(modules().lez_indexer_module.getStatus()); if (tip == 0) { // No finalized block yet (starting / syncing from genesis, or stopped); // syncStatus already conveys which, so just wait for the next poll. return; } setChainHeight(static_cast(tip)); // First successful poll: fill the feed and baseline without double-loading. if (!m_polledOnce) { m_polledOnce = true; m_lastPolledTip = tip; refreshBlocks(); return; } if (tip <= m_lastPolledTip) { return; } // Large jump / gap: rebuild the recent list from the backend. if (tip - m_lastPolledTip > kGapRebuildThreshold) { m_lastPolledTip = tip; refreshBlocks(); return; } // Contiguous growth: fetch the new heads and prepend (newest first). for (quint64 id = m_lastPolledTip + 1; id <= tip; ++id) { const QVariantMap block = fetchBlock(id); if (!block.isEmpty()) { m_recentBlocks.prepend(block); if (id > m_newestLoadedId) { m_newestLoadedId = id; } if (m_oldestLoadedId == 0) { m_oldestLoadedId = id; } } } m_lastPolledTip = tip; setRecentBlocks(m_recentBlocks); } QVariantMap LezExplorerUiBackend::fetchBlock(quint64 blockId) { return blockJsonToMap(modules().lez_indexer_module.getBlockById(QString::number(blockId))); } QVariantList LezExplorerUiBackend::parseBlockArrayJson(const QString& json) const { if (json.isEmpty()) { return {}; } const QJsonDocument doc = QJsonDocument::fromJson(json.toUtf8()); if (!doc.isArray()) { return {}; } QVariantList blocks; const QJsonArray array = doc.array(); blocks.reserve(array.size()); for (const auto& item : array) { const QVariantMap block = blockObjectToMap(item.toObject()); if (!block.isEmpty()) { blocks.append(block); } } return blocks; } namespace { // The writable path where the edited config JSON is persisted and the indexer // is started from (so the UI never deals with paths). Same logic, shared by the // const and non-const accessors. QString resolveConfigFilePath() { QString dir = QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation); if (dir.isEmpty()) { dir = QDir::tempPath(); } return QDir(dir).filePath(QStringLiteral("indexer_config.json")); } } // namespace QString LezExplorerUiBackend::configFilePath() { if (m_configFilePath.isEmpty()) { m_configFilePath = resolveConfigFilePath(); } return m_configFilePath; } bool LezExplorerUiBackend::writeConfigFile(const QString& json) { const QString path = configFilePath(); const QDir dir = QFileInfo(path).dir(); if (!dir.exists() && !dir.mkpath(QStringLiteral("."))) { return false; } QFile file(path); if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) { return false; } const QByteArray bytes = json.toUtf8(); const bool ok = file.write(bytes) == bytes.size(); file.close(); return ok; } QString LezExplorerUiBackend::readConfigFile() const { QFile file(m_configFilePath.isEmpty() ? resolveConfigFilePath() : m_configFilePath); if (!file.exists() || !file.open(QIODevice::ReadOnly | QIODevice::Text)) { return {}; } const QString content = QString::fromUtf8(file.readAll()); file.close(); return content; } bool LezExplorerUiBackend::startIndexerFromFile() { // start_indexer is idempotent and won't restart a running indexer, so stop // any current one first — this is what lets a re-saved config (e.g. a new // bedrock addr) actually take effect. No-op on the first start. modules().lez_indexer_module.stop_indexer(); const qlonglong code = modules().lez_indexer_module.start_indexer(configFilePath()); if (code != 0) { emit error(QStringLiteral("start_indexer failed (code %1)").arg(code)); return false; } return true; }