diff --git a/CMakeLists.txt b/CMakeLists.txt index 4b609f1..9ef6cf1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -177,6 +177,7 @@ set_target_properties(${PLUGIN_TARGET} PROPERTIES target_sources(${PLUGIN_TARGET} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/lez_indexer_module.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/src/lez_ffi_marshalling.cpp ) set_property(TARGET ${PLUGIN_TARGET} PROPERTY PUBLIC_HEADER diff --git a/src/i_lez_indexer_module.h b/src/i_lez_indexer_module.h index eb716fc..8e66025 100644 --- a/src/i_lez_indexer_module.h +++ b/src/i_lez_indexer_module.h @@ -17,9 +17,34 @@ public: // ModuleProxy invokes by exact meta-type match and delivers caller arguments // as QString (the module-viewer reads every parameter as text); a uint16_t // parameter would never match. It is parsed to a port number internally. - virtual int start_indexer( - const QString& config_path, - const QString& port + virtual int start_indexer(const QString& config_path, const QString& port) = 0; + + // Indexer Queries + // + // Every parameter is a QString for the same QtRO exact-type-match reason as + // `start_indexer` above: callers (consumer modules, the module-viewer) deliver + // arguments as QString, so numeric/hash params are passed as text and parsed + // internally. Each method returns a compact JSON string (the Logos protocol + // data model is JSON-in-strings); an empty string means "not found" or a + // failed query (the FFI does not log, so the body qWarns the status code). + // + // The JSON shape is tailored to what the LEZ explorer's Block/Transaction/ + // Account models consume (counts/sizes rather than raw arrays). Large 64-bit + // values (ids, timestamps, validity windows) are emitted as decimal STRINGS + // to avoid the double-precision loss of JSON numbers; counts/sizes are numbers. + virtual QString getAccount(const QString& account_id) = 0; + virtual QString getBlockById(const QString& block_id) = 0; + virtual QString getBlockByHash(const QString& hash) = 0; + virtual QString getTransaction(const QString& hash) = 0; + // `before` is the optional pagination cursor: a block id to page back from, + // or an empty string to start from the tip. `limit` caps the result count. + virtual QString getBlocks(const QString& before, const QString& limit) = 0; + virtual QString getLastFinalizedBlockId() = 0; + // `offset` and `limit` are both required (the paging window). + virtual QString getTransactionsByAccount( + const QString& account_id, + const QString& offset, + const QString& limit ) = 0; }; diff --git a/src/lez_ffi_marshalling.cpp b/src/lez_ffi_marshalling.cpp new file mode 100644 index 0000000..ef28fe6 --- /dev/null +++ b/src/lez_ffi_marshalling.cpp @@ -0,0 +1,190 @@ +#include "lez_ffi_marshalling.h" + +#include +#include +#include +#include + +namespace marshalling { + + // Lower-case hex of `length` raw bytes (used for 32-byte hashes/ids/keys and + // 64-byte signatures). Qt's toHex() avoids a hand-rolled nibble loop. + QString bytesToHex(const uint8_t* data, const size_t length) { + return QString::fromLatin1(QByteArray(reinterpret_cast(data), static_cast(length)).toHex()); + } + + // FfiU128 is a 16-byte little-endian integer (balances, nonces). C++ has no + // native u128, so build the decimal string via __uint128_t (GCC/Clang, 64-bit). + QString u128LeToDecimal(const uint8_t data[16]) { +#if defined(__SIZEOF_INT128__) && __SIZEOF_INT128__ >= 16 + __uint128_t v = 0; + for (int i = 0; i < 16; ++i) { + v |= static_cast<__uint128_t>(data[i]) << (i * 8); + } + if (v == 0) { + return QStringLiteral("0"); + } + char buf[40]; + int n = 0; + while (v) { + buf[n++] = static_cast('0' + static_cast(v % 10)); + v /= 10; + } + std::reverse(buf, buf + n); + return QString::fromLatin1(buf, n); +#else +#error "u128LeToDecimal requires __uint128_t; build with GCC or Clang on 64-bit" +#endif + } + + // 64-bit values are emitted as decimal STRINGS, not JSON numbers: QJsonValue + // stores numbers as double, which silently loses precision above 2^53. + QString u64ToString(uint64_t v) { + return QString::number(static_cast(v)); + } + + // Parse a hex string (optionally 0x-prefixed) into a fixed 32-byte FfiBytes32. + // Returns false unless it decodes to exactly 32 bytes. + bool hexToBytes32(const QString& hex, FfiBytes32* out) { + QString trimmed = hex.trimmed(); + if (trimmed.startsWith(QLatin1String("0x")) || trimmed.startsWith(QLatin1String("0X"))) { + trimmed = trimmed.mid(2); + } + const QByteArray raw = QByteArray::fromHex(trimmed.toLatin1()); + if (raw.size() != 32) { + return false; + } + std::memcpy(out->data, raw.constData(), 32); + return true; + } + + QJsonObject ffiAccountToJson(const FfiAccount& account) { + QJsonObject obj; + obj[QStringLiteral("program_owner")] = + bytesToHex(reinterpret_cast(account.program_owner.data), 32); + obj[QStringLiteral("balance")] = u128LeToDecimal(account.balance.data); + obj[QStringLiteral("nonce")] = u128LeToDecimal(account.nonce.data); + obj[QStringLiteral("data_size")] = static_cast(account.data_len); + return obj; + } + + QJsonObject ffiTransactionToJson(const FfiTransaction& tx) { + QJsonObject obj; + + switch (tx.kind) { + case Public: { + const FfiPublicTransactionBody* body = tx.body.public_body; + if (!body) { + break; + } + obj[QStringLiteral("type")] = QStringLiteral("Public"); + obj[QStringLiteral("hash")] = bytesToHex(body->hash.data, 32); + obj[QStringLiteral("program_id")] = + bytesToHex(reinterpret_cast(body->message.program_id.data), 32); + + QJsonArray accounts; + const FfiAccountIdList& ids = body->message.account_ids; + const FfiNonceList& nonces = body->message.nonces; + for (uintptr_t i = 0; i < ids.len; ++i) { + QJsonObject ref; + ref[QStringLiteral("account_id")] = bytesToHex(ids.entries[i].data, 32); + ref[QStringLiteral("nonce")] = + i < nonces.len ? u128LeToDecimal(nonces.entries[i].data) : QStringLiteral("0"); + accounts.append(ref); + } + obj[QStringLiteral("accounts")] = accounts; + + QJsonArray instructionData; + const FfiInstructionDataList& instr = body->message.instruction_data; + for (uintptr_t i = 0; i < instr.len; ++i) { + instructionData.append(static_cast(instr.entries[i])); + } + obj[QStringLiteral("instruction_data")] = instructionData; + obj[QStringLiteral("signature_count")] = static_cast(body->witness_set.len); + break; + } + case Private: { + const FfiPrivateTransactionBody* body = tx.body.private_body; + if (!body) { + break; + } + obj[QStringLiteral("type")] = QStringLiteral("PrivacyPreserving"); + obj[QStringLiteral("hash")] = bytesToHex(body->hash.data, 32); + + QJsonArray accounts; + const FfiAccountIdList& ids = body->message.public_account_ids; + const FfiNonceList& nonces = body->message.nonces; + for (uintptr_t i = 0; i < ids.len; ++i) { + QJsonObject ref; + ref[QStringLiteral("account_id")] = bytesToHex(ids.entries[i].data, 32); + ref[QStringLiteral("nonce")] = + i < nonces.len ? u128LeToDecimal(nonces.entries[i].data) : QStringLiteral("0"); + accounts.append(ref); + } + obj[QStringLiteral("accounts")] = accounts; + + obj[QStringLiteral("new_commitments_count")] = static_cast(body->message.new_commitments.len); + obj[QStringLiteral("nullifiers_count")] = static_cast(body->message.new_nullifiers.len); + obj[QStringLiteral("encrypted_states_count")] = + static_cast(body->message.encrypted_private_post_states.len); + obj[QStringLiteral("validity_window_start")] = u64ToString(body->message.block_validity_window[0]); + obj[QStringLiteral("validity_window_end")] = u64ToString(body->message.block_validity_window[1]); + obj[QStringLiteral("signature_count")] = static_cast(body->witness_set.len); + obj[QStringLiteral("proof_size")] = static_cast(body->proof.len); + break; + } + case ProgramDeploy: { + const FfiProgramDeploymentTransactionBody* body = tx.body.program_deployment_body; + if (!body) { + break; + } + obj[QStringLiteral("type")] = QStringLiteral("ProgramDeployment"); + obj[QStringLiteral("hash")] = bytesToHex(body->hash.data, 32); + obj[QStringLiteral("bytecode_size")] = static_cast(body->message.len); + break; + } + } + + return obj; + } + + namespace { + QString bedrockStatusToString(FfiBedrockStatus status) { + switch (status) { + case Safe: + return QStringLiteral("Safe"); + case Finalized: + return QStringLiteral("Finalized"); + case Pending: + default: + return QStringLiteral("Pending"); + } + } + } // namespace + + QJsonObject ffiBlockToJson(const FfiBlock& block) { + QJsonObject obj; + obj[QStringLiteral("block_id")] = u64ToString(block.header.block_id); + obj[QStringLiteral("hash")] = bytesToHex(block.header.hash.data, 32); + obj[QStringLiteral("prev_block_hash")] = bytesToHex(block.header.prev_block_hash.data, 32); + obj[QStringLiteral("timestamp")] = u64ToString(block.header.timestamp); + obj[QStringLiteral("signature")] = bytesToHex(block.header.signature.data, 64); + obj[QStringLiteral("bedrock_status")] = bedrockStatusToString(block.bedrock_status); + + QJsonArray transactions; + for (uintptr_t i = 0; i < block.body.len; ++i) { + transactions.append(ffiTransactionToJson(block.body.entries[i])); + } + obj[QStringLiteral("transactions")] = transactions; + return obj; + } + + QString jsonToCompactString(const QJsonObject& obj) { + return QString::fromUtf8(QJsonDocument(obj).toJson(QJsonDocument::Compact)); + } + + QString jsonToCompactString(const QJsonArray& arr) { + return QString::fromUtf8(QJsonDocument(arr).toJson(QJsonDocument::Compact)); + } + +} // namespace marshalling diff --git a/src/lez_ffi_marshalling.h b/src/lez_ffi_marshalling.h new file mode 100644 index 0000000..53b76ac --- /dev/null +++ b/src/lez_ffi_marshalling.h @@ -0,0 +1,48 @@ +#pragma once + +#include "lez_indexer_ffi.h" + +#include +#include +#include + +// FFI marshalling helpers +// +// Bridge the raw C structs returned by the indexer FFI (indexer_ffi.h) to the +// compact JSON the LEZ explorer's Block/Transaction/Account models consume, and +// parse the QString inputs back into FFI types. Kept in their own translation +// unit so lez_indexer_module.cpp stays focused on the query flow and the JSON +// data model lives in one place — which is also what makes it easy to share +// with other modules later. +// +// These never take ownership of FFI memory: the caller frees it with the +// matching free_ffi_* function AFTER marshalling. +// +// Large 64-bit values (ids, timestamps, validity windows) and 128-bit values +// (balances, nonces) are emitted as decimal STRINGS to avoid the double- +// precision loss of JSON numbers; counts/sizes are emitted as numbers. +namespace marshalling { + + // Lower-case hex of `length` raw bytes (32-byte hashes/ids/keys, 64-byte + // signatures). + QString bytesToHex(const uint8_t* data, size_t length); + + // FfiU128 is a 16-byte little-endian integer (balances, nonces); rendered + // as a decimal string. + QString u128LeToDecimal(const uint8_t data[16]); + + // 64-bit value as a decimal string. + QString u64ToString(uint64_t v); + + // Parse a hex string (optionally 0x-prefixed) into a fixed 32-byte buffer. + // Returns false unless it decodes to exactly 32 bytes. + bool hexToBytes32(const QString& hex, FfiBytes32* out); + + QJsonObject ffiAccountToJson(const FfiAccount& account); + QJsonObject ffiTransactionToJson(const FfiTransaction& tx); + QJsonObject ffiBlockToJson(const FfiBlock& block); + + QString jsonToCompactString(const QJsonObject& obj); + QString jsonToCompactString(const QJsonArray& arr); + +} // namespace marshalling diff --git a/src/lez_indexer_ffi.h b/src/lez_indexer_ffi.h new file mode 100644 index 0000000..ee3b247 --- /dev/null +++ b/src/lez_indexer_ffi.h @@ -0,0 +1,14 @@ +#pragma once + +// The generated indexer_ffi.h carries no include guard, so including it from +// more than one header in the same translation unit redefines every type. +// Wrap it once here (behind #pragma once) and include THIS header instead of +// directly, so the FFI types can be shared by both +// lez_indexer_module.h and lez_ffi_marshalling.h. +#ifdef __cplusplus +extern "C" { +#endif +#include +#ifdef __cplusplus +} +#endif diff --git a/src/lez_indexer_module.cpp b/src/lez_indexer_module.cpp index 1a5a33c..13a5616 100644 --- a/src/lez_indexer_module.cpp +++ b/src/lez_indexer_module.cpp @@ -1,12 +1,15 @@ #include "lez_indexer_module.h" -#include +#include "lez_ffi_marshalling.h" + +#include #include #include -#include -#include +#include #include +using namespace marshalling; + LezIndexerModule::LezIndexerModule() = default; LezIndexerModule::~LezIndexerModule() { @@ -66,3 +69,220 @@ int LezIndexerModule::start_indexer(const QString& config_path, const QString& p return 0; } + +// === Indexer Queries === +// +// Each method calls the matching query_* FFI function on the handle we already +// hold (no Runtime needed — the pinned FFI carries its own runtime inside the +// handle), marshals the returned C struct into compact JSON, then frees the +// FFI's deep allocations with the matching free_ffi_* function. +// +// Known leak: query_* returns its payload in a `Box::into_raw` *outer* box +// (PointerResult.value), and the free_ffi_* functions free only the inner +// boxes/vectors (they take the struct by value). The pinned FFI provides no +// free for the outer box, and libc free() would be UB if Rust's global +// allocator differs from the system one, so we deliberately leak the small +// (~8-32 byte) outer wrapper per query. Tracked for an upstream fix (add a +// free_*_result, or switch the query API to out-params). + +QString LezIndexerModule::getAccount(const QString& account_id) { + if (!indexer_service_ffi) { + qWarning() << "getAccount: indexer not started"; + return {}; + } + + FfiAccountId id; + if (!hexToBytes32(account_id, &id)) { + qWarning() << "getAccount: invalid account id (need 32-byte hex):" << account_id; + return {}; + } + + PointerResult_FfiAccount__OperationStatus res = ::query_account(indexer_service_ffi, id); + if (is_error(&res.error) || !res.value) { + qWarning() << "getAccount: indexer FFI error:" << res.error; + return {}; + } + + const QString out = jsonToCompactString(ffiAccountToJson(*res.value)); + ::free_ffi_account(*res.value); + return out; +} + +QString LezIndexerModule::getBlockById(const QString& block_id) { + if (!indexer_service_ffi) { + qWarning() << "getBlockById: indexer not started"; + return {}; + } + + bool ok = false; + const uint64_t id = block_id.toULongLong(&ok); + if (!ok) { + qWarning() << "getBlockById: invalid block id:" << block_id; + return {}; + } + + PointerResult_FfiBlockOpt__OperationStatus res = ::query_block(indexer_service_ffi, id); + if (is_error(&res.error) || !res.value) { + qWarning() << "getBlockById: indexer FFI error:" << res.error; + return {}; + } + + QString out; + if (res.value->is_some && res.value->value) { + out = jsonToCompactString(ffiBlockToJson(*res.value->value)); + } + ::free_ffi_block_opt(*res.value); + return out; +} + +QString LezIndexerModule::getBlockByHash(const QString& hash) { + if (!indexer_service_ffi) { + qWarning() << "getBlockByHash: indexer not started"; + return {}; + } + + FfiHashType h; + if (!hexToBytes32(hash, &h)) { + qWarning() << "getBlockByHash: invalid hash (need 32-byte hex):" << hash; + return {}; + } + + PointerResult_FfiBlockOpt__OperationStatus res = ::query_block_by_hash(indexer_service_ffi, h); + if (is_error(&res.error) || !res.value) { + qWarning() << "getBlockByHash: indexer FFI error:" << res.error; + return {}; + } + + QString out; + if (res.value->is_some && res.value->value) { + out = jsonToCompactString(ffiBlockToJson(*res.value->value)); + } + ::free_ffi_block_opt(*res.value); + return out; +} + +QString LezIndexerModule::getTransaction(const QString& hash) { + if (!indexer_service_ffi) { + qWarning() << "getTransaction: indexer not started"; + return {}; + } + + FfiHashType h; + if (!hexToBytes32(hash, &h)) { + qWarning() << "getTransaction: invalid hash (need 32-byte hex):" << hash; + return {}; + } + + PointerResult_FfiOption_FfiTransaction_____OperationStatus res = ::query_transaction(indexer_service_ffi, h); + if (is_error(&res.error) || !res.value) { + qWarning() << "getTransaction: indexer FFI error:" << res.error; + return {}; + } + + QString out; + if (res.value->is_some && res.value->value) { + out = jsonToCompactString(ffiTransactionToJson(*res.value->value)); + } + ::free_ffi_transaction_opt(*res.value); + return out; +} + +QString LezIndexerModule::getBlocks(const QString& before, const QString& limit) { + if (!indexer_service_ffi) { + qWarning() << "getBlocks: indexer not started"; + return {}; + } + + bool limitOk = false; + const uint64_t limitNum = limit.toULongLong(&limitOk); + if (!limitOk) { + qWarning() << "getBlocks: invalid limit:" << limit; + return {}; + } + + // `before` is the optional pagination cursor: an empty string means "from + // the tip", a non-empty value that fails to parse is an error. `beforeVal` + // must outlive the call since FfiOption_u64 borrows its address. + uint64_t beforeVal = 0; + bool hasBefore = false; + if (!before.isEmpty()) { + beforeVal = before.toULongLong(&hasBefore); + if (!hasBefore) { + qWarning() << "getBlocks: invalid before:" << before; + return {}; + } + } + FfiOption_u64 beforeOpt; + beforeOpt.is_some = hasBefore; + beforeOpt.value = hasBefore ? &beforeVal : nullptr; + + PointerResult_FfiVec_FfiBlock_____OperationStatus res = ::query_block_vec(indexer_service_ffi, beforeOpt, limitNum); + if (is_error(&res.error) || !res.value) { + qWarning() << "getBlocks: indexer FFI error:" << res.error; + return {}; + } + + QJsonArray arr; + for (uintptr_t i = 0; i < res.value->len; ++i) { + arr.append(ffiBlockToJson(res.value->entries[i])); + } + ::free_ffi_block_vec(*res.value); + return jsonToCompactString(arr); +} + +QString LezIndexerModule::getLastFinalizedBlockId() { + if (!indexer_service_ffi) { + qWarning() << "getLastFinalizedBlockId: indexer not started"; + return {}; + } + + PointerResult_u64__OperationStatus res = ::query_last_block(indexer_service_ffi); + if (is_error(&res.error) || !res.value) { + qWarning() << "getLastFinalizedBlockId: indexer FFI error:" << res.error; + return {}; + } + + // Bare decimal string; the FFI provides no free for the boxed u64 (leaked, + // see the note above). + return u64ToString(*res.value); +} + +QString LezIndexerModule::getTransactionsByAccount( + const QString& account_id, + const QString& offset, + const QString& limit +) { + if (!indexer_service_ffi) { + qWarning() << "getTransactionsByAccount: indexer not started"; + return {}; + } + + FfiAccountId id; + if (!hexToBytes32(account_id, &id)) { + qWarning() << "getTransactionsByAccount: invalid account id (need 32-byte hex):" << account_id; + return {}; + } + + bool offsetOk = false; + bool limitOk = false; + const uint64_t offsetNum = offset.toULongLong(&offsetOk); + const uint64_t limitNum = limit.toULongLong(&limitOk); + if (!offsetOk || !limitOk) { + qWarning() << "getTransactionsByAccount: invalid offset/limit:" << offset << limit; + return {}; + } + + PointerResult_FfiVec_FfiTransaction_____OperationStatus res = + ::query_transactions_by_account(indexer_service_ffi, id, offsetNum, limitNum); + if (is_error(&res.error) || !res.value) { + qWarning() << "getTransactionsByAccount: indexer FFI error:" << res.error; + return {}; + } + + QJsonArray arr; + for (uintptr_t i = 0; i < res.value->len; ++i) { + arr.append(ffiTransactionToJson(res.value->entries[i])); + } + ::free_ffi_transaction_vec(*res.value); + return jsonToCompactString(arr); +} diff --git a/src/lez_indexer_module.h b/src/lez_indexer_module.h index 23dde79..3fcb228 100644 --- a/src/lez_indexer_module.h +++ b/src/lez_indexer_module.h @@ -1,14 +1,7 @@ #pragma once #include "i_lez_indexer_module.h" - -#ifdef __cplusplus -extern "C" { -#endif -#include -#ifdef __cplusplus -} -#endif +#include "lez_indexer_ffi.h" #include #include @@ -41,6 +34,29 @@ public: // Indexer Lifecycle Q_INVOKABLE int start_indexer(const QString& config_path, const QString& port) override; + // Indexer Queries (return compact JSON strings; see i_lez_indexer_module.h) + Q_INVOKABLE QString getAccount(const QString& account_id) override; + Q_INVOKABLE QString getBlockById(const QString& block_id) override; + Q_INVOKABLE QString getBlockByHash(const QString& hash) override; + Q_INVOKABLE QString getTransaction(const QString& hash) override; + Q_INVOKABLE QString getBlocks(const QString& before, const QString& limit) override; + Q_INVOKABLE QString getLastFinalizedBlockId() override; + Q_INVOKABLE QString + getTransactionsByAccount(const QString& account_id, const QString& offset, const QString& limit) override; + + // Indexer Logging (opt-in) + // + // Installs the indexer FFI's logger (env_logger) so the indexer's Rust `log` + // output surfaces in the host process. + // + // Commented out because the underlying `::init_logger()` export does not yet + // exist in the pinned logos-execution-zone FFI + // + // Uncomment this single block once the export lands upstream + // and the pin is bumped to a rev that includes it. + // + // Q_INVOKABLE void init_logger() { ::init_logger(); } + signals: void eventResponse(const QString& eventName, const QVariantList& data); };