From a1a17acbc5aab0df2ff43bb716e892181a4a7200 Mon Sep 17 00:00:00 2001 From: Dario Gabriel Lipicar Date: Wed, 26 Aug 2026 14:51:11 -0300 Subject: [PATCH] feat: wire the heartbeat's health signal, and add fetchFinalizedRoot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fields — head.blockNumber, head.updatedAt and heartbeatFailures — were read by statusSnapshot() and never assigned, and State::Degraded appeared only in stateName(). The heartbeat was fire-and-forget with a comment pointing at a pollHeartbeat() that does not exist, so nothing ever observed its outcome: status().head stayed "" for the life of the process and a proxy whose sync had died still reported "running". CallSlot now carries a Kind, so the trampoline can tell a user call from a heartbeat or a head probe. Three consecutive heartbeat failures degrade the proxy and one success clears it; head is refreshed by a separate eth_blockNumber probe every fifth beat, since eth_syncing answers a hardcoded `false` and cannot report it. live() joins Running and Degraded for callers making lifecycle decisions, leaving running() strict for health. fetchFinalizedRoot() is new, and lives here rather than in the panel because Basecamp sandboxes ui_qml plugins away from the network: an XMLHttpRequest from a view is refused outright. It is a convenience, not a trust anchor, and says so. Adds libcurl, used for that one request and nothing else. Tests spin on the condition rather than sleeping a fixed interval — the first draft was green on an idle machine and red under a parallel build. Co-Authored-By: Claude Opus 5 --- CMakeLists.txt | 8 +++ README.md | 13 ++++- metadata.json | 73 ++++++++++++------------- src/beacon_client.cpp | 102 +++++++++++++++++++++++++++++++++++ src/beacon_client.h | 41 ++++++++++++++ src/proxy_runtime.cpp | 84 +++++++++++++++++++++++++++++ src/proxy_runtime.h | 22 ++++++++ src/verified_proxy_impl.cpp | 25 +++++++++ src/verified_proxy_impl.h | 16 ++++++ tests/CMakeLists.txt | 5 +- tests/test_beacon_client.cpp | 73 +++++++++++++++++++++++++ tests/test_proxy_runtime.cpp | 95 ++++++++++++++++++++++++++++++++ 12 files changed, 519 insertions(+), 38 deletions(-) create mode 100644 src/beacon_client.cpp create mode 100644 src/beacon_client.h create mode 100644 tests/test_beacon_client.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 9fd9d36..8efabee 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -24,6 +24,8 @@ logos_module( src/proxy_runtime.cpp src/rpc_http_server.h src/rpc_http_server.cpp + src/beacon_client.h + src/beacon_client.cpp EXTERNAL_LIBS verifproxy INCLUDE_DIRS @@ -38,6 +40,12 @@ find_package(PkgConfig REQUIRED) pkg_check_modules(MHD REQUIRED IMPORTED_TARGET libmicrohttpd) target_link_libraries(verified_proxy_module_module_plugin PRIVATE PkgConfig::MHD) +# Outbound HTTP for fetchFinalizedRoot(). Only ever used for that one request: +# all verified traffic goes through the library's own chronos-based clients, so +# curl is not on any hot path. +pkg_check_modules(CURL REQUIRED IMPORTED_TARGET libcurl) +target_link_libraries(verified_proxy_module_module_plugin PRIVATE PkgConfig::CURL) + # Inject the module version from metadata.json so moduleVersion() reports it # without parsing JSON at runtime. file(READ "${CMAKE_CURRENT_SOURCE_DIR}/metadata.json" _vp_metadata_json) diff --git a/README.md b/README.md index 7a6a149..62fbb96 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ impossible without it. (Infura notably does not.) | `start()` | Blocks until the light client initialises, bounded by `startTimeoutMs`. | | `stop()` | Drains, then releases. See the note on `drainTimeoutMs` below — it is not a tight bound. | | `ok()` / `status()` | Health probe and full state. `status()` never blocks on the proxy thread. | +| `fetchFinalizedRoot(beaconUrl)` | Convenience: asks a beacon node for its current finalized root. **Not** a trust anchor — see below. | | `rpc(method, params)` | Any method the proxy supports. `params` is a JSON-RPC array. | | `ethBlockNumber()`, `ethGetBalance(...)`, `ethCall(...)`, … | Typed wrappers over the same path. | @@ -230,7 +231,17 @@ single `eth_blockNumber` in that run took **12.6 s**, against a 30 s default value reaches a `quit()` before that point. * Sync observability is limited: there is no exported getter for the finalized/optimistic slot. `status().state == "degraded"` means "up, but - heartbeats are failing", inferred from their error strings. + heartbeats are failing", inferred from their error strings — three + consecutive failures degrade, one success clears it. `status().head` is + refreshed by a separate `eth_blockNumber` probe every fifth heartbeat, + because `eth_syncing` answers a hardcoded `false` and cannot report it. +* `fetchFinalizedRoot()` exists because Basecamp sandboxes `ui_qml` plugins + away from the network entirely — an `XMLHttpRequest` from a panel is refused + with *"sandboxed ui_qml modules may not use the network"* — so a UI that + wants to offer "fetch me a root" has to route it through a core module. + It is a convenience for getting started, **not** part of the trust model: a + root taken from the same endpoint you are about to verify against anchors + nothing. For anything holding real value, obtain the root independently. ## Development diff --git a/metadata.json b/metadata.json index 7182c30..e94b7ac 100644 --- a/metadata.json +++ b/metadata.json @@ -1,40 +1,41 @@ { - "name": "verified_proxy_module", - "display_name": "Verified Proxy", - "version": "0.1.0", - "description": "Light-client-verified Ethereum JSON-RPC, wrapping nimbus libverifproxy", - "author": "Logos Core Team", - "type": "core", - "interface": "universal", - "concurrency": "multi", - "category": "wallet", - "main": "verified_proxy_module_plugin", - "codegen": { - "impl_header": "verified_proxy_impl.h", - "impl_class": "VerifiedProxyImpl" + "name": "verified_proxy_module", + "display_name": "Verified Proxy", + "version": "0.1.0", + "description": "Light-client-verified Ethereum JSON-RPC, wrapping nimbus libverifproxy", + "author": "Logos Core Team", + "type": "core", + "interface": "universal", + "concurrency": "multi", + "category": "wallet", + "main": "verified_proxy_module_plugin", + "codegen": { + "impl_header": "verified_proxy_impl.h", + "impl_class": "VerifiedProxyImpl" + }, + "dependencies": [], + "include": [], + "capabilities": [], + "nix": { + "packages": { + "build": [ + "pkg-config" + ], + "runtime": [ + "nlohmann_json", + "libmicrohttpd", + "curl" + ] }, - "dependencies": [], - "include": [], - "capabilities": [], - "nix": { - "packages": { - "build": [ - "pkg-config" - ], - "runtime": [ - "nlohmann_json", - "libmicrohttpd" - ] - }, - "external_libraries": [ - { - "name": "verifproxy" - } - ], - "cmake": { - "extra_include_dirs": [ - "lib" - ] - } + "external_libraries": [ + { + "name": "verifproxy" + } + ], + "cmake": { + "extra_include_dirs": [ + "lib" + ] } + } } diff --git a/src/beacon_client.cpp b/src/beacon_client.cpp new file mode 100644 index 0000000..f0d4405 --- /dev/null +++ b/src/beacon_client.cpp @@ -0,0 +1,102 @@ +#include "beacon_client.h" + +#include +#include + +using json = nlohmann::json; + +namespace beacon_client { + +std::string trim(const std::string& s) { + const auto b = s.find_first_not_of(" \t\r\n"); + if (b == std::string::npos) return {}; + const auto e = s.find_last_not_of(" \t\r\n"); + return s.substr(b, e - b + 1); +} + +bool isHttpUrl(const std::string& url) { + return url.rfind("http://", 0) == 0 || url.rfind("https://", 0) == 0; +} + +std::string finalizedHeaderUrl(const std::string& base) { + std::string b = base; + while (!b.empty() && b.back() == '/') b.pop_back(); + return b + "/eth/v1/beacon/headers/finalized"; +} + +namespace { + +size_t appendBody(char* ptr, size_t size, size_t nmemb, void* userdata) { + const size_t n = size * nmemb; + static_cast(userdata)->append(ptr, n); + return n; +} + +// curl_global_init is not thread-safe, and every curl_easy_init after the +// first would otherwise race it. One init per process, on first use. +void ensureGlobalInit() { + static const bool once = [] { + curl_global_init(CURL_GLOBAL_DEFAULT); + return true; + }(); + (void)once; +} + +} // namespace + +HttpResponse httpGet(const std::string& url, long timeoutSeconds) { + ensureGlobalInit(); + + HttpResponse out; + CURL* curl = curl_easy_init(); + if (!curl) { + out.error = "could not initialise HTTP client"; + return out; + } + + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, appendBody); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &out.body); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, timeoutSeconds); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); + // A beacon node redirecting us to a different scheme is not a redirect we + // want to follow silently. + curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS_STR, "http,https"); + curl_easy_setopt(curl, CURLOPT_ACCEPT_ENCODING, ""); + curl_easy_setopt(curl, CURLOPT_USERAGENT, "logos-verified-proxy-module"); + + const CURLcode rc = curl_easy_perform(curl); + if (rc != CURLE_OK) { + out.error = curl_easy_strerror(rc); + } else { + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &out.status); + } + curl_easy_cleanup(curl); + return out; +} + +std::string parseFinalizedRoot(const std::string& body, std::string& slotOut) { + slotOut.clear(); + json doc = json::parse(body, nullptr, /*allow_exceptions=*/false); + if (doc.is_discarded() || !doc.is_object()) return {}; + + const auto data = doc.find("data"); + if (data == doc.end() || !data->is_object()) return {}; + + const auto root = data->find("root"); + if (root == data->end() || !root->is_string()) return {}; + + // The slot is nested two levels further down and is purely informational, + // so a missing one is not a failure. + const auto header = data->find("header"); + if (header != data->end() && header->is_object()) { + const auto message = header->find("message"); + if (message != header->end() && message->is_object()) { + const auto slot = message->find("slot"); + if (slot != message->end() && slot->is_string()) slotOut = slot->get(); + } + } + return root->get(); +} + +} // namespace beacon_client diff --git a/src/beacon_client.h b/src/beacon_client.h new file mode 100644 index 0000000..582ab4a --- /dev/null +++ b/src/beacon_client.h @@ -0,0 +1,41 @@ +#pragma once + +#include + +// A single-purpose HTTP client for one job: asking a beacon node for its +// current finalized block root, so an operator has a trusted-root candidate to +// paste into configure(). +// +// This lives in the module rather than in the UI because Basecamp sandboxes +// ui_qml plugins away from the network — a core module is the only component +// permitted to make the request. See fetchFinalizedRoot() in the impl header +// for why this is a convenience and not part of the trust model. +namespace beacon_client { + +// Strip surrounding whitespace. +std::string trim(const std::string& s); + +// True when `url` is http:// or https://. Deliberately stricter than the +// config's own URL check, which also admits ws:// and wss:// — those are valid +// beacon transports for the library but cannot serve a REST GET. +bool isHttpUrl(const std::string& url); + +// Join `base` with the finalized-headers path, tolerating a trailing slash. +// Pure and side-effect free so the URL shape is unit-testable without a server. +std::string finalizedHeaderUrl(const std::string& base); + +struct HttpResponse { + long status = 0; // 0 when the request never completed + std::string body; + std::string error; // transport-level failure, empty on success +}; + +// Blocking GET. Called from the module's own dispatch thread, never from the +// proxy thread — a stalled beacon node must not be able to hold up the pump. +HttpResponse httpGet(const std::string& url, long timeoutSeconds = 15); + +// Pull `data.root` out of a beacon headers response. Returns "" when the +// payload is not the expected shape. +std::string parseFinalizedRoot(const std::string& body, std::string& slotOut); + +} // namespace beacon_client diff --git a/src/proxy_runtime.cpp b/src/proxy_runtime.cpp index 7644c63..3019b15 100644 --- a/src/proxy_runtime.cpp +++ b/src/proxy_runtime.cpp @@ -4,6 +4,8 @@ #include #include #include +#include +#include #include extern "C" { @@ -211,6 +213,15 @@ void ProxyRuntime::threadMain() { if (m_inFlight.load() == 0 && keepAliveEnabled() && steady_clock::now() >= nextKeepAlive) { + // Every Nth beat, ask for the head as well. The heartbeat itself + // cannot report it — eth_syncing answers a hardcoded `false` — and + // probing on every beat would multiply execution-backend traffic + // for a number that only has to be roughly current. + static constexpr int64_t kHeadEveryBeats = 5; + if (m_beatsSinceHead.fetch_add(1, std::memory_order_relaxed) + 1 >= kHeadEveryBeats) { + m_beatsSinceHead.store(0, std::memory_order_relaxed); + issueHeadProbe(); + } issueKeepAlive(); nextKeepAlive = steady_clock::now() + milliseconds(m_cfg.keepAliveIntervalMs); } @@ -366,6 +377,11 @@ void ProxyRuntime::callbackTrampoline(Context*, int status, char* result, void* } slot->cv.notify_all(); } + switch (slot->kind) { + case CallSlot::Kind::Heartbeat: box->rt->noteHeartbeat(*slot); break; + case CallSlot::Kind::HeadProbe: box->rt->noteHeadProbe(*slot); break; + case CallSlot::Kind::User: break; + } box->rt->noteFinished(slot->id, status == RET_SUCCESS); } catch (...) { // Never propagate into Nim. @@ -425,6 +441,7 @@ void ProxyRuntime::issueKeepAlive() { slot->id = m_nextId.fetch_add(1, std::memory_order_relaxed); slot->method = "eth_syncing"; slot->params = "[]"; + slot->kind = CallSlot::Kind::Heartbeat; auto* box = new CallBox{ slot, this }; m_inFlight.fetch_add(1, std::memory_order_acq_rel); @@ -437,6 +454,73 @@ void ProxyRuntime::issueKeepAlive() { m_pending.push_back(slot); } +// The heartbeat's own return value is a hardcoded `false` and tells us nothing +// about the head, so a separate, less frequent probe asks for the block number +// outright. Same fire-and-forget shape; observed in noteHeadProbe(). +void ProxyRuntime::issueHeadProbe() { + assert(std::this_thread::get_id() == m_threadId); + + auto slot = std::make_shared(); + slot->id = m_nextId.fetch_add(1, std::memory_order_relaxed); + slot->method = "eth_blockNumber"; + slot->params = "[]"; + slot->kind = CallSlot::Kind::HeadProbe; + + auto* box = new CallBox{ slot, this }; + m_inFlight.fetch_add(1, std::memory_order_acq_rel); + ::proxyCall(m_ctx, slot->method.data(), slot->params.data(), + &ProxyRuntime::callbackTrampoline, box); + + std::lock_guard lk(m_mu); + m_pending.push_back(slot); +} + +// Consecutive heartbeat failures are the only sync-health signal the C ABI +// offers: the error STRING is machine-readable ("UnavailableDataError: trusted +// block root not set", "VerificationError: unviable fork"), the return value is +// not. Three in a row is deliberately more than one blip and less than a long +// outage. +void ProxyRuntime::noteHeartbeat(const CallSlot& slot) { + static constexpr int64_t kDegradeAfter = 3; + + if (slot.status == RET_SUCCESS) { + m_heartbeatStreak.store(0, std::memory_order_relaxed); + // Only climb back out of Degraded — never overwrite Draining/Stopped, + // which a concurrent stop() may have just set. + if (m_state.load() == State::Degraded) setState(State::Running); + return; + } + + m_heartbeatFailures.fetch_add(1, std::memory_order_relaxed); + const int64_t streak = m_heartbeatStreak.fetch_add(1, std::memory_order_relaxed) + 1; + if (streak >= kDegradeAfter && m_state.load() == State::Running) + setState(State::Degraded, errorMessage(slot.status, slot.result)); +} + +void ProxyRuntime::noteHeadProbe(const CallSlot& slot) { + if (slot.status != RET_SUCCESS) return; + + bool wasJson = false; + const json v = decodePayload(slot.result, wasJson); + + // Upstream answers eth_blockNumber with a bare JSON NUMBER, not the hex + // string a JSON-RPC client would expect. Normalise to the documented + // "0x…" shape here so status() has one form. + std::string hex; + if (v.is_number_unsigned()) { + std::ostringstream o; + o << "0x" << std::hex << v.get(); + hex = o.str(); + } else if (v.is_string()) { + hex = v.get(); + } + if (hex.empty()) return; + + std::lock_guard lk(m_errMu); + m_headBlockNumber = hex; + m_headUpdatedAt = nowSeconds(); +} + // --------------------------------------------------------------------------- // Status // --------------------------------------------------------------------------- diff --git a/src/proxy_runtime.h b/src/proxy_runtime.h index 3f9665c..4f44b32 100644 --- a/src/proxy_runtime.h +++ b/src/proxy_runtime.h @@ -37,6 +37,13 @@ struct CallSlot { // the Nim side copies its cstring arguments before its first await. std::string method; std::string params; + + // What the completion means. A User call has a waiter blocked on `cv`; + // the other two are fire-and-forget and are the ONLY way the runtime + // learns anything about proxy health, since the library exposes no getter + // for light-client progress. + enum class Kind { User, Heartbeat, HeadProbe }; + Kind kind = Kind::User; }; class ProxyRuntime { @@ -61,6 +68,14 @@ public: bool running() const { return m_state.load() == State::Running; } + /// Running OR degraded. Degraded means the proxy is up but its heartbeat + /// is failing, so it is still a stoppable, live process — lifecycle + /// decisions want this, health checks want running(). + bool live() const { + const State s = m_state.load(); + return s == State::Running || s == State::Degraded; + } + /// THE call path. Everything — the ~60 typed wrappers and the generic /// rpc() — funnels through `proxyCall`, which is a string `case` over the /// same exported procs the typed C entry points call. @@ -98,6 +113,9 @@ private: /// C callback. Runs on the proxy thread; must never let an exception /// escape into Nim frames. static void callbackTrampoline(Context* ctx, int status, char* result, void* userData); + void issueHeadProbe(); + void noteHeartbeat(const CallSlot& slot); + void noteHeadProbe(const CallSlot& slot); void noteFinished(uint64_t id, bool ok); void recordPump(int64_t ms, bool busy); @@ -117,6 +135,10 @@ private: std::atomic m_callsTotal{0}; std::atomic m_callsFailed{0}; std::atomic m_heartbeatFailures{0}; + // Consecutive failures, not the lifetime total: one blip must not latch + // the proxy into degraded forever. + std::atomic m_heartbeatStreak{0}; + std::atomic m_beatsSinceHead{0}; std::atomic m_pumpCalls{0}; std::atomic m_pumpMaxMs{0}; std::atomic m_pumpIdle[kPumpBuckets]{}; diff --git a/src/verified_proxy_impl.cpp b/src/verified_proxy_impl.cpp index 3ffbf7b..065a9dc 100644 --- a/src/verified_proxy_impl.cpp +++ b/src/verified_proxy_impl.cpp @@ -6,6 +6,7 @@ #include #include +#include "beacon_client.h" #include "proxy_config.h" #include "proxy_runtime.h" #include "rpc_http_server.h" @@ -188,6 +189,30 @@ LogosMap VerifiedProxyImpl::status() { std::string VerifiedProxyImpl::moduleVersion() { return VERIFIED_PROXY_MODULE_VERSION; } std::string VerifiedProxyImpl::libraryVersion() { return VERIFIED_PROXY_NIMBUS_REV; } +StdLogosResult VerifiedProxyImpl::fetchFinalizedRoot(const std::string& beaconUrl) { + const std::string base = beacon_client::trim(beaconUrl); + if (base.empty()) return { false, {}, "beacon URL is required" }; + if (!beacon_client::isHttpUrl(base)) + return { false, {}, "beacon URL must be http(s): " + base }; + + const std::string url = beacon_client::finalizedHeaderUrl(base); + const beacon_client::HttpResponse res = beacon_client::httpGet(url); + if (!res.error.empty()) return { false, {}, "beacon request failed: " + res.error }; + if (res.status != 200) + return { false, {}, "beacon returned HTTP " + std::to_string(res.status) }; + + std::string slot; + const std::string root = beacon_client::parseFinalizedRoot(res.body, slot); + if (root.empty()) + return { false, {}, "beacon response did not contain data.root" }; + + LogosMap out; + out["root"] = root; + out["slot"] = slot; + out["source"] = url; + return { true, out }; +} + // ── Verified JSON-RPC ─────────────────────────────────────────────────────── // // Every one of these is three lines over the same dispatch path: the library's diff --git a/src/verified_proxy_impl.h b/src/verified_proxy_impl.h index 1735abe..66f4a16 100644 --- a/src/verified_proxy_impl.h +++ b/src/verified_proxy_impl.h @@ -128,6 +128,22 @@ public: /// path the typed methods use. std::string localEndpoint(); + /// Fetch the current finalized beacon block root from `beaconUrl`. + /// + /// A convenience for operators who have no root to hand: it queries + /// `/eth/v1/beacon/headers/finalized` and returns + /// `{"root": "0x…", "slot": "…"}`. Takes the URL as an argument rather + /// than reading the stored config so it is usable before configure(). + /// + /// This is deliberately NOT part of the trust model. A root fetched from + /// the same endpoint you are about to distrust anchors nothing — it is a + /// starting point for testing, and an operator running against real value + /// should take the root from a source they independently trust and paste + /// it in. The method lives here rather than in a UI because Basecamp + /// sandboxes `ui_qml` plugins away from the network entirely; a core + /// module is the only component allowed to make the request. + StdLogosResult fetchFinalizedRoot(const std::string& beaconUrl); + // ── Verified JSON-RPC ──────────────────────────────────────────────── /// Any method the proxy supports, dispatched through the library's own diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 68437e6..a478106 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -5,6 +5,7 @@ include(LogosTest) find_package(PkgConfig REQUIRED) pkg_check_modules(MHD REQUIRED IMPORTED_TARGET libmicrohttpd) +pkg_check_modules(CURL REQUIRED IMPORTED_TARGET libcurl) # Mirror the module build: inject the version from metadata.json. file(READ "${CMAKE_CURRENT_SOURCE_DIR}/../metadata.json" _vp_metadata_json) @@ -22,11 +23,13 @@ logos_test( ../src/proxy_runtime.cpp ../src/verified_proxy_impl.cpp ../src/rpc_http_server.cpp + ../src/beacon_client.cpp TEST_SOURCES main.cpp test_config_validation.cpp test_proxy_runtime.cpp test_rpc_http_server.cpp + test_beacon_client.cpp verified_proxy_events_test.cpp MOCK_C_SOURCES mocks/mock_libverifproxy.cpp @@ -34,6 +37,6 @@ logos_test( stubs . ) -target_link_libraries(verified_proxy_module_tests PRIVATE PkgConfig::MHD) +target_link_libraries(verified_proxy_module_tests PRIVATE PkgConfig::MHD PkgConfig::CURL) target_compile_definitions(verified_proxy_module_tests PRIVATE VERIFIED_PROXY_MODULE_VERSION="${VERIFIED_PROXY_MODULE_VERSION}") diff --git a/tests/test_beacon_client.cpp b/tests/test_beacon_client.cpp new file mode 100644 index 0000000..6edeb74 --- /dev/null +++ b/tests/test_beacon_client.cpp @@ -0,0 +1,73 @@ +// URL construction and response parsing for the finalized-root helper. +// +// Both halves are pure, so the whole shape of the request and the whole shape +// of what we accept back are testable without a beacon node or a socket. + +#include + +#include + +#include "beacon_client.h" + +namespace bc = beacon_client; + +LOGOS_TEST(beacon_url_appends_the_finalized_headers_path) { + LOGOS_ASSERT_EQ(bc::finalizedHeaderUrl("https://example.org"), + "https://example.org/eth/v1/beacon/headers/finalized"); +} + +LOGOS_TEST(beacon_url_tolerates_trailing_slashes) { + // A URL pasted from a browser very often carries one. + LOGOS_ASSERT_EQ(bc::finalizedHeaderUrl("https://example.org///"), + "https://example.org/eth/v1/beacon/headers/finalized"); +} + +LOGOS_TEST(beacon_url_keeps_a_path_prefix) { + LOGOS_ASSERT_EQ(bc::finalizedHeaderUrl("https://example.org/beacon"), + "https://example.org/beacon/eth/v1/beacon/headers/finalized"); +} + +LOGOS_TEST(beacon_accepts_only_http_schemes) { + // ws:// and wss:// are valid beacon transports for the library itself, but + // cannot serve the REST GET this helper makes. + LOGOS_ASSERT_TRUE(bc::isHttpUrl("http://example.org")); + LOGOS_ASSERT_TRUE(bc::isHttpUrl("https://example.org")); + LOGOS_ASSERT_FALSE(bc::isHttpUrl("wss://example.org")); + LOGOS_ASSERT_FALSE(bc::isHttpUrl("ws://example.org")); + LOGOS_ASSERT_FALSE(bc::isHttpUrl("file:///etc/passwd")); + LOGOS_ASSERT_FALSE(bc::isHttpUrl("")); +} + +LOGOS_TEST(beacon_trims_surrounding_whitespace) { + LOGOS_ASSERT_EQ(bc::trim(" https://example.org \n"), "https://example.org"); + LOGOS_ASSERT_EQ(bc::trim(" "), ""); +} + +LOGOS_TEST(beacon_parses_root_and_slot) { + const std::string body = R"({ + "data": { + "root": "0xabc", + "header": { "message": { "slot": "12345" } } + } + })"; + std::string slot; + LOGOS_ASSERT_EQ(bc::parseFinalizedRoot(body, slot), "0xabc"); + LOGOS_ASSERT_EQ(slot, "12345"); +} + +LOGOS_TEST(beacon_parse_tolerates_a_missing_slot) { + // The slot is informational; only the root is load-bearing. + std::string slot = "stale"; + LOGOS_ASSERT_EQ(bc::parseFinalizedRoot(R"({"data":{"root":"0xdef"}})", slot), "0xdef"); + LOGOS_ASSERT_EQ(slot, ""); +} + +LOGOS_TEST(beacon_parse_rejects_malformed_payloads) { + std::string slot; + LOGOS_ASSERT_EQ(bc::parseFinalizedRoot("not json", slot), ""); + LOGOS_ASSERT_EQ(bc::parseFinalizedRoot("[]", slot), ""); + LOGOS_ASSERT_EQ(bc::parseFinalizedRoot("{}", slot), ""); + LOGOS_ASSERT_EQ(bc::parseFinalizedRoot(R"({"data":{}})", slot), ""); + // A non-string root is the shape an error page could plausibly take. + LOGOS_ASSERT_EQ(bc::parseFinalizedRoot(R"({"data":{"root":42}})", slot), ""); +} diff --git a/tests/test_proxy_runtime.cpp b/tests/test_proxy_runtime.cpp index 9958962..9de53ca 100644 --- a/tests/test_proxy_runtime.cpp +++ b/tests/test_proxy_runtime.cpp @@ -49,6 +49,20 @@ int lastIndexOf(const std::vector& v, const std::string& s) { return -1; } +/// Spin until `pred` holds or `budgetMs` elapses. Sleeping a fixed interval and +/// hoping N heartbeats fit inside it makes a test that is green on an idle +/// machine and red under a parallel nix build; this makes a loaded builder +/// slower rather than flaky. +template +bool spinUntil(Pred pred, int budgetMs = 8000) { + const auto deadline = steady_clock::now() + milliseconds(budgetMs); + while (steady_clock::now() < deadline) { + if (pred()) return true; + std::this_thread::sleep_for(milliseconds(5)); + } + return pred(); +} + } // namespace LOGOS_TEST(runtime_start_and_stop_round_trip) { @@ -383,3 +397,84 @@ LOGOS_TEST(runtime_heartbeat_issues_eth_syncing_only_when_enabled) { LOGOS_ASSERT_EQ(t.cFunctionCallCount("proxyCall:eth_syncing"), 0); } } + +LOGOS_TEST(runtime_head_probe_records_the_block_number) { + // The heartbeat cannot report the head — upstream's eth_syncing answers a + // hardcoded `false` — so a separate eth_blockNumber probe populates it. + // Before this was wired, status().head.blockNumber was a field that was + // read and never assigned, so it stayed "" for the life of the process. + auto t = LogosTestContext("verified_proxy_module"); + mockReset(); + t.mockCFunction("proxyCall").returns("11572348"); // a bare JSON number + + ProxyConfig cfg = testConfig(); + cfg.keepAlive = "interval"; + cfg.keepAliveIntervalMs = 20; + + ProxyRuntime rt(nullptr); + LOGOS_ASSERT_TRUE(rt.start(cfg).success); + const bool got = spinUntil([&] { + return !rt.statusSnapshot()["head"]["blockNumber"].get().empty(); + }); + const json s = rt.statusSnapshot(); + rt.stop(); + LOGOS_ASSERT_TRUE(got); + + LOGOS_ASSERT_GT(t.cFunctionCallCount("proxyCall:eth_blockNumber"), 0); + // Normalised to the "0x…" form status() documents, not the bare number + // upstream returns. 11572348 == 0xb0947c. + LOGOS_ASSERT_EQ(s["head"]["blockNumber"].get(), std::string("0xb0947c")); + LOGOS_ASSERT_GT(s["head"]["updatedAt"].get(), 0); +} + +LOGOS_TEST(runtime_consecutive_heartbeat_failures_degrade_the_proxy) { + // The error string of a failing heartbeat is the only machine-readable + // sync-health signal the C ABI exposes. Three in a row is the threshold — + // more than a blip, less than an outage. + auto t = LogosTestContext("verified_proxy_module"); + mockReset(); + t.mockCFunction("proxyCall_status").returns(RET_ERROR); + + ProxyConfig cfg = testConfig(); + cfg.keepAlive = "interval"; + cfg.keepAliveIntervalMs = 20; + + ProxyRuntime rt(nullptr); + LOGOS_ASSERT_TRUE(rt.start(cfg).success); + const bool degraded = spinUntil([&] { + return rt.statusSnapshot()["state"].get() == "degraded"; + }); + const json s = rt.statusSnapshot(); + + LOGOS_ASSERT_TRUE(degraded); + LOGOS_ASSERT_EQ(s["state"].get(), std::string("degraded")); + LOGOS_ASSERT_GE(s["counters"]["heartbeatFailures"].get(), 3); + // Degraded is not running — ok() must report unhealthy... + LOGOS_ASSERT_FALSE(rt.running()); + // ...but the proxy is still a live, stoppable process. + LOGOS_ASSERT_TRUE(rt.live()); + rt.stop(); +} + +LOGOS_TEST(runtime_a_healthy_heartbeat_leaves_state_running) { + // The mirror of the test above: the streak must not latch. A proxy whose + // heartbeats succeed stays Running no matter how many beats elapse. + auto t = LogosTestContext("verified_proxy_module"); + mockReset(); + + ProxyConfig cfg = testConfig(); + cfg.keepAlive = "interval"; + cfg.keepAliveIntervalMs = 20; + + ProxyRuntime rt(nullptr); + LOGOS_ASSERT_TRUE(rt.start(cfg).success); + // Wait for real beats rather than a fixed nap, so "still running" is a + // statement about many successful heartbeats and not about a short sleep. + const bool beat = spinUntil([&] { return t.cFunctionCallCount("proxyCall:eth_syncing") >= 5; }); + const json s = rt.statusSnapshot(); + rt.stop(); + + LOGOS_ASSERT_TRUE(beat); + LOGOS_ASSERT_EQ(s["state"].get(), std::string("running")); + LOGOS_ASSERT_EQ(s["counters"]["heartbeatFailures"].get(), 0); +}