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); +}