diff --git a/CMakeLists.txt b/CMakeLists.txt index 24f242d..9fd9d36 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -22,12 +22,22 @@ logos_module( src/proxy_config.cpp src/proxy_runtime.h src/proxy_runtime.cpp + src/rpc_http_server.h + src/rpc_http_server.cpp EXTERNAL_LIBS verifproxy INCLUDE_DIRS lib ) +# Embedded JSON-RPC endpoint: libmicrohttpd, the same server openmetrics-module +# uses. libverifproxy deliberately ships none — the HTTP/WS frontend lives only +# in the standalone nimbus_verified_proxy binary and its symbols are absent from +# the archive we link — so the endpoint is ours. +find_package(PkgConfig REQUIRED) +pkg_check_modules(MHD REQUIRED IMPORTED_TARGET libmicrohttpd) +target_link_libraries(verified_proxy_module_module_plugin PRIVATE PkgConfig::MHD) + # 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 448c4a1..7a6a149 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,54 @@ positional parameter**, `optimisticStateFetch` (a bool) — an upstream extensio to the standard JSON-RPC signature. The typed wrappers supply it; anything calling `rpc()` with a hand-built params array must too. +## The JSON-RPC endpoint + +`libverifproxy` deliberately ships **no** server — `library/verifproxy.nim` +imports `json_rpc_backend` (the client it calls providers with) and the +in-process `engine/rpc_frontend`, but never `json_rpc_frontend`; the HTTP/WS +server exists only in the standalone `nimbus_verified_proxy` binary, and its +symbols are absent from the archive we link. So this module provides one. + +Off by default — a module should not open a listening socket unless asked: + +```json +{ "httpServer": { "enabled": true, "host": "127.0.0.1", "port": 8545 } } +``` + +`localEndpoint()` returns the URL (or `""`), and `status().httpServer` reports +it. Every request is forwarded through the **same** verified `proxyCall` path +the typed methods use — one verification path, one error shape. + +```bash +curl -s -X POST -H 'content-type: application/json' --data '{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}' http://127.0.0.1:8545 +``` + +Point ethers, viem, cast or `eth_rpc_module`'s `ChainConfig.endpoint` at that +URL and their reads become light-client-verified without any of them knowing +this module exists. + +Two adaptations make that actually true, rather than nearly true: + +* **`eth_call`, `eth_estimateGas` and `eth_createAccessList` (and their `op_` + twins) take a third positional parameter upstream**, `optimisticStateFetch`, + which the JSON-RPC spec does not have. Every stock client sends two and the + library answers `parameters missing`. The endpoint appends the default, and + leaves an explicitly-supplied third parameter alone. +* **Bare-number results are rendered as hex quantities.** Upstream's encoding is + not uniform: `eth_chainId` and `eth_gasPrice` answer hex strings but + `eth_blockNumber` answers a JSON number, which no client expects. Confined to + this layer — `rpc()` and the typed methods still return exactly what the + library produced. + +Supported: batches, notifications (dispatched, no response), and the reserved +error codes — `-32700` parse, `-32600` invalid request, `-32601` method not +found, `-32602` invalid params, `-32000` verification/backend failure. + +**It binds loopback by default and refuses anything but POST.** This endpoint +answers *state* queries, so exposing it beyond `127.0.0.1` is a deliberate act. +There is no authentication: treat a non-loopback bind as publishing an open RPC +node. + ## Configuration Required: `trustedBlockRoot` (`0x` + 64 hex), `executionApiUrls`, @@ -89,7 +137,7 @@ the library's JSON config — that is a CLI-only option on the standalone binary Module-side knobs: `callTimeoutMs` (30000), `startTimeoutMs` (120000), `drainTimeoutMs` (2000 — a polling bound, see below), `pumpIntervalMs` (50), -`maxInFlight` (64), +`maxInFlight` (64), `httpServer` (see above), `keepAlive` (`off` | `interval` | `continuous`), `keepAliveIntervalMs` (1000), `autoStart` (false). Upstream tuning lives under `tuning`. diff --git a/metadata.json b/metadata.json index c89807b..7182c30 100644 --- a/metadata.json +++ b/metadata.json @@ -16,17 +16,25 @@ "dependencies": [], "include": [], "capabilities": [], - "nix": { "packages": { - "build": [], - "runtime": ["nlohmann_json"] + "build": [ + "pkg-config" + ], + "runtime": [ + "nlohmann_json", + "libmicrohttpd" + ] }, "external_libraries": [ - { "name": "verifproxy" } + { + "name": "verifproxy" + } ], "cmake": { - "extra_include_dirs": ["lib"] + "extra_include_dirs": [ + "lib" + ] } } } diff --git a/src/proxy_config.cpp b/src/proxy_config.cpp index 3ad546e..074f4d3 100644 --- a/src/proxy_config.cpp +++ b/src/proxy_config.cpp @@ -231,6 +231,19 @@ bool ProxyConfig::fromJson(const json& in, ProxyConfig& out, std::string& err) { if (!readBool(in, "autoStart", out.autoStart, err)) return false; if (!readEnum(in, "keepAlive", kKeepAliveModes(), out.keepAlive, err)) return false; + const json http = in.value("httpServer", json::object()); + if (!http.is_object()) { err = "'httpServer' must be an object"; return false; } + if (!readBool(http, "enabled", out.httpEnabled, err)) return false; + if (!readInt(http, "port", out.httpPort, err)) return false; + if (http.contains("host")) { + if (!http["host"].is_string()) { err = "'httpServer.host' must be a string"; return false; } + out.httpHost = http["host"].get(); + } + if (out.httpEnabled && (out.httpPort < 1 || out.httpPort > 65535)) { + err = "'httpServer.port' must be in 1..65535 (got " + std::to_string(out.httpPort) + ")"; + return false; + } + if (out.callTimeoutMs <= 0) { err = "'callTimeoutMs' must be positive"; return false; } if (out.startTimeoutMs <= 0) { err = "'startTimeoutMs' must be positive"; return false; } if (out.maxInFlight <= 0) { err = "'maxInFlight' must be positive"; return false; } @@ -298,6 +311,7 @@ json ProxyConfig::redacted() const { j["keepAlive"] = keepAlive; j["keepAliveIntervalMs"] = keepAliveIntervalMs; j["autoStart"] = autoStart; + j["httpServer"] = { { "enabled", httpEnabled }, { "host", httpHost }, { "port", httpPort } }; return j; } diff --git a/src/proxy_config.h b/src/proxy_config.h index fc15c7b..1d7771a 100644 --- a/src/proxy_config.h +++ b/src/proxy_config.h @@ -77,6 +77,15 @@ struct ProxyConfig { int64_t keepAliveIntervalMs = 1000; bool autoStart = false; + /// Optional JSON-RPC 2.0 endpoint in front of the proxy. + /// + /// Off by default: a module should not open a listening socket unless + /// asked. Defaults to loopback when enabled — this endpoint serves chain + /// state, so binding it to 0.0.0.0 is a deliberate act, not a default. + bool httpEnabled = false; + std::string httpHost = "127.0.0.1"; + int64_t httpPort = 8545; + /// Parse and validate. Returns false and fills `err` with a specific, /// actionable message on the first problem found. static bool fromJson(const nlohmann::json& in, ProxyConfig& out, std::string& err); diff --git a/src/rpc_http_server.cpp b/src/rpc_http_server.cpp new file mode 100644 index 0000000..f8e64aa --- /dev/null +++ b/src/rpc_http_server.cpp @@ -0,0 +1,283 @@ +#include "rpc_http_server.h" + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include + +using json = nlohmann::json; + +namespace { + +// JSON-RPC 2.0 reserved codes. +constexpr int kParseError = -32700; +constexpr int kInvalidRequest = -32600; +constexpr int kInvalidParams = -32602; +constexpr int kInternalError = -32603; +// Application range, for a verification/backend failure. -32000 is the +// conventional "server error" slot and is what other Ethereum endpoints use. +constexpr int kServerError = -32000; + +/// Methods whose upstream signature carries ONE MORE positional parameter than +/// the JSON-RPC spec: `optimisticStateFetch`. +/// +/// This is the single adaptation without which no standard client works. +/// ethers, viem, cast and `eth_rpc_module` all send `eth_call` with two params; +/// the library requires three and answers "parameters missing" otherwise. We +/// append the default rather than reject, so a stock client Just Works while a +/// caller who knows about the flag can still pass it explicitly. +bool takesOptimisticStateFetch(const std::string& m) { + static const std::set v{ + "eth_call", "eth_estimateGas", "eth_createAccessList", + "op_call", "op_estimateGas", "op_createAccessList", + }; + return v.count(m) != 0; +} + +/// Render a JSON-RPC QUANTITY. +/// +/// Upstream's encoding is not uniform: `eth_chainId` and `eth_gasPrice` answer +/// hex strings, but `eth_blockNumber` answers a bare JSON number — which no +/// client expects, because the spec says every QUANTITY is a hex string. Since +/// no standard eth_/op_ method legitimately returns a bare number, promoting +/// one to hex here is unambiguous and makes the endpoint spec-conformant. +/// +/// Deliberately confined to this layer. The module's own `rpc()` and typed +/// methods keep returning exactly what the library produced — documented as +/// such — so this translation cannot hide an upstream change from a caller who +/// is talking to the module directly rather than over HTTP. +json normalizeResult(json v) { + if (v.is_number_unsigned()) { + char buf[32]; + std::snprintf(buf, sizeof(buf), "0x%llx", + static_cast(v.get())); + return json(buf); + } + return v; +} + +json errorObject(int code, const std::string& message) { + return json{ { "code", code }, { "message", message } }; +} + +json responseEnvelope(const json& id, json result) { + return json{ { "jsonrpc", "2.0" }, { "id", id }, { "result", std::move(result) } }; +} + +json errorEnvelope(const json& id, int code, const std::string& message) { + return json{ { "jsonrpc", "2.0" }, { "id", id }, { "error", errorObject(code, message) } }; +} + +/// One request object -> one response object, or nothing for a notification. +/// Returns false when the request was a notification (no `id`). +bool handleOne(const json& req, const RpcHttpServer::Dispatch& dispatch, json& out) { + const bool isNotification = !req.is_object() || !req.contains("id") || req["id"].is_null(); + const json id = (req.is_object() && req.contains("id")) ? req["id"] : json(nullptr); + + auto fail = [&](int code, const std::string& msg) { + if (isNotification) return false; + out = errorEnvelope(id, code, msg); + return true; + }; + + if (!req.is_object()) + return fail(kInvalidRequest, "request must be a JSON object"); + if (!req.contains("method") || !req["method"].is_string()) + return fail(kInvalidRequest, "'method' is required and must be a string"); + + const std::string method = req["method"].get(); + + json params = json::array(); + if (req.contains("params") && !req["params"].is_null()) { + if (!req["params"].is_array()) + // The proxy's own dispatcher does parseJson(params).getElems, which + // yields an empty list for a non-array and then reports the + // unhelpful "parameters missing". Say what is actually wrong. + return fail(kInvalidParams, "'params' must be an array (named parameters are not supported)"); + params = req["params"]; + } + + if (takesOptimisticStateFetch(method) && params.size() == 2) + params.push_back(false); + + const StdLogosResult r = dispatch(method, params); + if (isNotification) return false; + + if (!r.success) { + // "unknown method" is the library's own wording for an unrecognised + // name; map it to the code clients special-case. + const bool unknown = r.error.find("unknown method") != std::string::npos; + out = errorEnvelope(id, unknown ? -32601 : kServerError, r.error); + return true; + } + out = responseEnvelope(id, normalizeResult(r.value)); + return true; +} + +} // namespace + +std::string RpcHttpServer::handleBody(const std::string& body, const Dispatch& dispatch) { + json req; + try { + req = json::parse(body); + } catch (const std::exception& e) { + return errorEnvelope(json(nullptr), kParseError, std::string("invalid JSON: ") + e.what()).dump(); + } + + if (req.is_array()) { + if (req.empty()) + return errorEnvelope(json(nullptr), kInvalidRequest, "empty batch").dump(); + json out = json::array(); + for (const auto& one : req) { + json resp; + if (handleOne(one, dispatch, resp)) out.push_back(std::move(resp)); + } + // A batch of nothing but notifications gets no response body at all. + return out.empty() ? std::string() : out.dump(); + } + + json resp; + if (!handleOne(req, dispatch, resp)) return std::string(); + return resp.dump(); +} + +// --------------------------------------------------------------------------- + +struct RpcHttpServer::Impl { + Dispatch dispatch; + MHD_Daemon* daemon = nullptr; + std::string host; + uint16_t port = 0; + mutable std::mutex mu; + + explicit Impl(Dispatch d) : dispatch(std::move(d)) {} +}; + +namespace { + +/// Per-connection body accumulator. MHD delivers a POST body in chunks, so the +/// first callback only establishes the connection. +struct ConnState { std::string body; }; + +MHD_Result sendResponse(MHD_Connection* c, unsigned status, const std::string& payload) { + MHD_Response* r = MHD_create_response_from_buffer( + payload.size(), const_cast(payload.data()), MHD_RESPMEM_MUST_COPY); + MHD_add_response_header(r, "Content-Type", "application/json"); + const MHD_Result ret = MHD_queue_response(c, status, r); + MHD_destroy_response(r); + return ret; +} + +MHD_Result onRequest(void* cls, MHD_Connection* connection, const char* /*url*/, + const char* method, const char* /*version*/, + const char* upload_data, size_t* upload_data_size, + void** con_cls) { + auto* impl = static_cast(cls); + + if (std::strcmp(method, "POST") != 0) { + const std::string body = + errorEnvelope(json(nullptr), kInvalidRequest, "JSON-RPC requires POST").dump(); + return sendResponse(connection, MHD_HTTP_METHOD_NOT_ALLOWED, body); + } + + if (*con_cls == nullptr) { // first call: no data yet + *con_cls = new ConnState(); + return MHD_YES; + } + + auto* state = static_cast(*con_cls); + if (*upload_data_size != 0) { // a chunk of body + state->body.append(upload_data, *upload_data_size); + *upload_data_size = 0; + return MHD_YES; + } + + // Body complete. This blocks until the verified call returns — MHD's + // internal thread pool is what makes that acceptable, and ProxyRuntime's + // maxInFlight is what bounds it. + std::string out; + try { + out = RpcHttpServer::handleBody(state->body, impl->dispatch); + } catch (const std::exception& e) { + out = errorEnvelope(json(nullptr), kInternalError, e.what()).dump(); + } catch (...) { + out = errorEnvelope(json(nullptr), kInternalError, "unknown error").dump(); + } + + // Notifications produce no body; 204 is the honest status for that. + const unsigned status = out.empty() ? MHD_HTTP_NO_CONTENT : MHD_HTTP_OK; + return sendResponse(connection, status, out); +} + +void onRequestCompleted(void* /*cls*/, MHD_Connection* /*c*/, void** con_cls, + enum MHD_RequestTerminationCode /*toe*/) { + delete static_cast(*con_cls); + *con_cls = nullptr; +} + +} // namespace + +RpcHttpServer::RpcHttpServer(Dispatch dispatch) + : m_impl(std::make_unique(std::move(dispatch))) {} + +RpcHttpServer::~RpcHttpServer() { stop(); } + +bool RpcHttpServer::start(const std::string& host, uint16_t port, std::string& err) { + std::lock_guard lk(m_impl->mu); + if (m_impl->daemon) { err = "http server already running"; return false; } + + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_port = htons(port); + if (::inet_pton(AF_INET, host.c_str(), &addr.sin_addr) != 1) { + err = "httpServer.host must be a literal IPv4 address (got '" + host + "')"; + return false; + } + + // MHD_OPTION_SOCK_ADDR rather than letting MHD pick: without it the daemon + // binds every interface, which for an endpoint serving chain state is a + // different class of mistake than it is for a metrics port. + MHD_Daemon* d = MHD_start_daemon( + MHD_USE_INTERNAL_POLLING_THREAD | MHD_USE_THREAD_PER_CONNECTION, + port, nullptr, nullptr, + &onRequest, m_impl.get(), + MHD_OPTION_SOCK_ADDR, reinterpret_cast(&addr), + MHD_OPTION_NOTIFY_COMPLETED, &onRequestCompleted, nullptr, + MHD_OPTION_END); + if (!d) { + err = "failed to bind " + host + ":" + std::to_string(port) + + " (port in use, or not permitted)"; + return false; + } + + m_impl->daemon = d; + m_impl->host = host; + m_impl->port = port; + return true; +} + +void RpcHttpServer::stop() { + std::lock_guard lk(m_impl->mu); + if (!m_impl->daemon) return; + MHD_stop_daemon(m_impl->daemon); // joins its threads; in-flight requests finish + m_impl->daemon = nullptr; + m_impl->port = 0; +} + +bool RpcHttpServer::running() const { + std::lock_guard lk(m_impl->mu); + return m_impl->daemon != nullptr; +} + +std::string RpcHttpServer::endpoint() const { + std::lock_guard lk(m_impl->mu); + if (!m_impl->daemon) return {}; + return "http://" + m_impl->host + ":" + std::to_string(m_impl->port); +} diff --git a/src/rpc_http_server.h b/src/rpc_http_server.h new file mode 100644 index 0000000..19f618d --- /dev/null +++ b/src/rpc_http_server.h @@ -0,0 +1,61 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include +#include + +/// A JSON-RPC 2.0 endpoint in front of the verified proxy. +/// +/// `libverifproxy` deliberately ships NO server: `library/verifproxy.nim` +/// imports `json_rpc_backend` (the client it calls providers with) and the +/// in-process `engine/rpc_frontend`, but never `json_rpc_frontend` — the +/// HTTP/WS server lives only in the standalone `nimbus_verified_proxy` binary, +/// and its symbols are absent from the archive we link. So the endpoint has to +/// be ours. +/// +/// This is a thin adapter, not a second implementation: every request is +/// forwarded to the same `proxyCall` path the typed module methods use, so +/// there is exactly one verification path and one place errors are shaped. +/// +/// Binds to loopback by default. This endpoint answers *state* queries, so an +/// accidental 0.0.0.0 bind is a different order of mistake than it would be for +/// a metrics port. +class RpcHttpServer { +public: + /// Forwards one verified call. Returns the bare result value, exactly as + /// the library produces it — this class owns the JSON-RPC framing. + using Dispatch = std::function; + + explicit RpcHttpServer(Dispatch dispatch); + ~RpcHttpServer(); + + RpcHttpServer(const RpcHttpServer&) = delete; + RpcHttpServer& operator=(const RpcHttpServer&) = delete; + + /// Bind and serve. Returns false with a reason on failure (port in use, + /// bad host, libmicrohttpd refused). + bool start(const std::string& host, uint16_t port, std::string& err); + void stop(); + + bool running() const; + /// e.g. "http://127.0.0.1:8545", or "" when not running. + std::string endpoint() const; + + /// Handle one request body (one object, or a batch array) and produce the + /// response body. Exposed for unit tests: it is pure apart from `dispatch`, + /// so the whole JSON-RPC surface is testable without binding a socket. + static std::string handleBody(const std::string& body, const Dispatch& dispatch); + + /// Public only because libmicrohttpd's callbacks are free functions and + /// receive this as their `cls`. Defined in the .cpp; opaque to callers. + struct Impl; + +private: + std::unique_ptr m_impl; +}; diff --git a/src/verified_proxy_impl.cpp b/src/verified_proxy_impl.cpp index 151348a..3ffbf7b 100644 --- a/src/verified_proxy_impl.cpp +++ b/src/verified_proxy_impl.cpp @@ -8,6 +8,7 @@ #include "proxy_config.h" #include "proxy_runtime.h" +#include "rpc_http_server.h" // Generated at build time. Only needed where modules() is used; included here // so the impl header the generator parses stays free of codegen types. @@ -130,10 +131,44 @@ LogosMap VerifiedProxyImpl::getConfig() { StdLogosResult VerifiedProxyImpl::start() { if (!m_configured) return { false, {}, "not configured — call configure() first" }; - return m_rt->start(*m_cfg); + + auto r = m_rt->start(*m_cfg); + if (!r.success || !m_cfg->httpEnabled) return r; + + // Forward every HTTP request through the SAME verified path the typed + // methods use, so there is one verification path and one error shape. + m_http = std::make_unique( + [this](const std::string& method, const nlohmann::json& params) { + return m_rt->call(method, params); + }); + + std::string httpErr; + if (!m_http->start(m_cfg->httpHost, + static_cast(m_cfg->httpPort), httpErr)) { + // Fail the whole start rather than leave a half-started module: a + // caller that asked for an endpoint and silently did not get one would + // point a wallet at a dead port. + m_http.reset(); + m_rt->stop(); + return { false, {}, "proxy started but the JSON-RPC endpoint could not: " + httpErr }; + } + + r.value = nlohmann::json{ { "chainId", m_cfg->expectedChainId() }, + { "endpoint", m_http->endpoint() } }; + return r; } -StdLogosResult VerifiedProxyImpl::stop() { return m_rt->stop(); } +StdLogosResult VerifiedProxyImpl::stop() { + // Stop accepting HTTP first: otherwise a request in flight would reach a + // runtime that is already draining and get "proxy shutting down" for no + // reason the caller can act on. + if (m_http) { m_http->stop(); m_http.reset(); } + return m_rt->stop(); +} + +std::string VerifiedProxyImpl::localEndpoint() { + return m_http ? m_http->endpoint() : std::string(); +} bool VerifiedProxyImpl::ok() { return m_rt->running(); } @@ -143,6 +178,10 @@ LogosMap VerifiedProxyImpl::status() { else if (s["state"] == "uninitialized") s["state"] = "configured"; s["moduleVersion"] = VERIFIED_PROXY_MODULE_VERSION; s["libraryVersion"] = VERIFIED_PROXY_NIMBUS_REV; + s["httpServer"] = json{ + { "running", m_http && m_http->running() }, + { "endpoint", m_http ? m_http->endpoint() : std::string() }, + }; return s; } diff --git a/src/verified_proxy_impl.h b/src/verified_proxy_impl.h index 06142b1..1735abe 100644 --- a/src/verified_proxy_impl.h +++ b/src/verified_proxy_impl.h @@ -10,6 +10,7 @@ struct ProxyConfig; class ProxyRuntime; +class RpcHttpServer; /// Light-client-verified Ethereum JSON-RPC. /// @@ -54,6 +55,7 @@ public: /// "tuning": { "maxBlockWalk": 1000, "headerStoreLen": 256 }, /// "callTimeoutMs": 30000, "startTimeoutMs": 120000, /// "keepAlive": "interval", "keepAliveIntervalMs": 1000, // do not use "off" + /// "httpServer": { "enabled": false, "host": "127.0.0.1", "port": 8545 }, /// "maxInFlight": 64, "autoStart": false /// } /// @endcode @@ -112,6 +114,20 @@ public: /// The nimbus-eth1 revision this module was built against. std::string libraryVersion(); + /// URL of this module's own JSON-RPC endpoint, or "" when it is not + /// running (`httpServer.enabled` defaults to false). + /// + /// This is the integration seam for anything that speaks plain JSON-RPC: + /// hand it to `eth_rpc_module`'s `ChainConfig.endpoint`, or to ethers / + /// viem / cast, and their reads become light-client-verified without any + /// of them knowing this module exists. + /// + /// Note the library itself ships no server — `libverifproxy` never imports + /// `json_rpc_frontend`, and its symbols are absent from the archive. This + /// endpoint is the module's, forwarding to the same verified `proxyCall` + /// path the typed methods use. + std::string localEndpoint(); + // ── Verified JSON-RPC ──────────────────────────────────────────────── /// Any method the proxy supports, dispatched through the library's own @@ -173,5 +189,6 @@ private: // to derive the module's contract — stays free of the FFI and config types. std::unique_ptr m_cfg; std::unique_ptr m_rt; + std::unique_ptr m_http; bool m_configured = false; }; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e59c6b1..68437e6 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -3,6 +3,9 @@ project(VerifiedProxyModuleTests LANGUAGES CXX) include(LogosTest) +find_package(PkgConfig REQUIRED) +pkg_check_modules(MHD REQUIRED IMPORTED_TARGET libmicrohttpd) + # Mirror the module build: inject the version from metadata.json. file(READ "${CMAKE_CURRENT_SOURCE_DIR}/../metadata.json" _vp_metadata_json) string(JSON VERIFIED_PROXY_MODULE_VERSION GET "${_vp_metadata_json}" version) @@ -18,10 +21,12 @@ logos_test( ../src/proxy_config.cpp ../src/proxy_runtime.cpp ../src/verified_proxy_impl.cpp + ../src/rpc_http_server.cpp TEST_SOURCES main.cpp test_config_validation.cpp test_proxy_runtime.cpp + test_rpc_http_server.cpp verified_proxy_events_test.cpp MOCK_C_SOURCES mocks/mock_libverifproxy.cpp @@ -29,5 +34,6 @@ logos_test( stubs . ) +target_link_libraries(verified_proxy_module_tests PRIVATE PkgConfig::MHD) target_compile_definitions(verified_proxy_module_tests PRIVATE VERIFIED_PROXY_MODULE_VERSION="${VERIFIED_PROXY_MODULE_VERSION}") diff --git a/tests/test_rpc_http_server.cpp b/tests/test_rpc_http_server.cpp new file mode 100644 index 0000000..7fdeed9 --- /dev/null +++ b/tests/test_rpc_http_server.cpp @@ -0,0 +1,201 @@ +// The JSON-RPC framing, tested without binding a socket. +// +// handleBody() is pure apart from its dispatch callback, so the whole protocol +// surface — envelopes, batches, notifications, error codes, and the one +// signature adaptation stock clients depend on — is exercised here. + +#include +#include + +#include +#include + +#include "rpc_http_server.h" + +using json = nlohmann::json; + +namespace { + +/// Records what reached the proxy, and answers a canned value. +struct Recorder { + std::vector> seen; + StdLogosResult next{ true, json("0x1"), "" }; + + RpcHttpServer::Dispatch fn() { + return [this](const std::string& m, const json& p) { + seen.emplace_back(m, p); + return next; + }; + } +}; + +json call(const std::string& body, Recorder& r) { + const std::string out = RpcHttpServer::handleBody(body, r.fn()); + return out.empty() ? json() : json::parse(out); +} + +} // namespace + +LOGOS_TEST(http_wraps_a_bare_result_in_a_jsonrpc_envelope) { + // The library answers Json.encode(value) with no envelope, so the server is + // what makes this a JSON-RPC endpoint at all. + Recorder r; + const json resp = call(R"({"jsonrpc":"2.0","id":7,"method":"eth_blockNumber","params":[]})", r); + + LOGOS_ASSERT_EQ(resp["jsonrpc"].get(), std::string("2.0")); + LOGOS_ASSERT_EQ(resp["id"].get(), 7); + LOGOS_ASSERT_EQ(resp["result"].get(), std::string("0x1")); + LOGOS_ASSERT_FALSE(resp.contains("error")); + LOGOS_ASSERT_EQ(r.seen.size(), static_cast(1)); + LOGOS_ASSERT_EQ(r.seen[0].first, std::string("eth_blockNumber")); +} + +LOGOS_TEST(http_preserves_the_id_type) { + // Clients use strings, numbers and null; echoing the wrong type breaks + // correlation in a batch. + Recorder r; + LOGOS_ASSERT_TRUE(call(R"({"jsonrpc":"2.0","id":"abc","method":"eth_chainId"})", r)["id"].is_string()); + LOGOS_ASSERT_TRUE(call(R"({"jsonrpc":"2.0","id":42,"method":"eth_chainId"})", r)["id"].is_number()); +} + +// --- the adaptation stock clients depend on --------------------------------- + +LOGOS_TEST(http_appends_optimisticStateFetch_for_the_three_methods_that_need_it) { + // eth_call/estimateGas/createAccessList carry a THIRD positional parameter + // upstream that the JSON-RPC spec does not have. ethers, viem, cast and + // eth_rpc_module all send two, and the library answers "parameters + // missing". Appending the default is what makes a stock client work. + for (const char* m : { "eth_call", "eth_estimateGas", "eth_createAccessList", + "op_call", "op_estimateGas", "op_createAccessList" }) { + Recorder r; + const std::string body = + std::string(R"({"jsonrpc":"2.0","id":1,"method":")") + m + + R"(","params":[{"to":"0xabc","data":"0x"},"latest"]})"; + call(body, r); + + LOGOS_ASSERT_EQ(r.seen.size(), static_cast(1)); + const json& p = r.seen[0].second; + LOGOS_ASSERT_EQ(p.size(), static_cast(3)); + LOGOS_ASSERT_TRUE(p[2].is_boolean()); + LOGOS_ASSERT_FALSE(p[2].get()); + } +} + +LOGOS_TEST(http_does_not_override_an_explicit_optimisticStateFetch) { + Recorder r; + call(R"({"jsonrpc":"2.0","id":1,"method":"eth_call", + "params":[{"to":"0xabc"},"latest",true]})", r); + const json& p = r.seen[0].second; + LOGOS_ASSERT_EQ(p.size(), static_cast(3)); + LOGOS_ASSERT_TRUE(p[2].get()); +} + +LOGOS_TEST(http_leaves_other_methods_params_untouched) { + Recorder r; + call(R"({"jsonrpc":"2.0","id":1,"method":"eth_getBalance","params":["0xabc","latest"]})", r); + LOGOS_ASSERT_EQ(r.seen[0].second.size(), static_cast(2)); +} + +LOGOS_TEST(http_renders_a_bare_number_result_as_a_hex_quantity) { + // Upstream is not uniform: eth_chainId and eth_gasPrice answer hex strings, + // but eth_blockNumber answers a bare JSON number — which no client expects, + // since every QUANTITY in the spec is a hex string. Measured against + // sepolia, not assumed. + Recorder r; + r.next = { true, json(11546453u), "" }; + const json resp = call(R"({"jsonrpc":"2.0","id":1,"method":"eth_blockNumber"})", r); + LOGOS_ASSERT_TRUE(resp["result"].is_string()); + LOGOS_ASSERT_EQ(resp["result"].get(), std::string("0xb02f55")); +} + +LOGOS_TEST(http_leaves_strings_and_objects_alone) { + Recorder r; + r.next = { true, json("0xaa36a7"), "" }; + LOGOS_ASSERT_EQ(call(R"({"jsonrpc":"2.0","id":1,"method":"eth_chainId"})", r)["result"] + .get(), std::string("0xaa36a7")); + + r.next = { true, json{ { "baseFeePerGas", "0x3e03d63d" } }, "" }; + const json blk = call(R"({"jsonrpc":"2.0","id":1,"method":"eth_getBlockByNumber"})", r); + LOGOS_ASSERT_TRUE(blk["result"].is_object()); + LOGOS_ASSERT_EQ(blk["result"]["baseFeePerGas"].get(), std::string("0x3e03d63d")); +} + +// --- errors ------------------------------------------------------------------ + +LOGOS_TEST(http_reports_a_parse_error_with_the_reserved_code) { + Recorder r; + const json resp = call("{not json", r); + LOGOS_ASSERT_EQ(resp["error"]["code"].get(), -32700); + LOGOS_ASSERT_TRUE(resp["id"].is_null()); + LOGOS_ASSERT_TRUE(r.seen.empty()); +} + +LOGOS_TEST(http_rejects_named_params_clearly) { + // The proxy's own dispatcher would silently see an empty list and answer + // "parameters missing", which sends the caller looking in the wrong place. + Recorder r; + const json resp = call(R"({"jsonrpc":"2.0","id":1,"method":"eth_call","params":{"to":"0x"}})", r); + LOGOS_ASSERT_EQ(resp["error"]["code"].get(), -32602); + LOGOS_ASSERT_CONTAINS(resp["error"]["message"].get(), "array"); + LOGOS_ASSERT_TRUE(r.seen.empty()); +} + +LOGOS_TEST(http_requires_a_method_field) { + Recorder r; + LOGOS_ASSERT_EQ(call(R"({"jsonrpc":"2.0","id":1})", r)["error"]["code"].get(), -32600); +} + +LOGOS_TEST(http_maps_an_unknown_method_to_method_not_found) { + Recorder r; + r.next = { false, {}, "bad request: unknown method" }; + const json resp = call(R"({"jsonrpc":"2.0","id":1,"method":"eth_nope"})", r); + LOGOS_ASSERT_EQ(resp["error"]["code"].get(), -32601); +} + +LOGOS_TEST(http_maps_a_verification_failure_to_a_server_error) { + Recorder r; + r.next = { false, {}, "VerificationError: unviable fork" }; + const json resp = call(R"({"jsonrpc":"2.0","id":1,"method":"eth_getBalance","params":["0x","latest"]})", r); + LOGOS_ASSERT_EQ(resp["error"]["code"].get(), -32000); + LOGOS_ASSERT_CONTAINS(resp["error"]["message"].get(), "unviable fork"); +} + +// --- batches and notifications ---------------------------------------------- + +LOGOS_TEST(http_answers_a_batch_in_order) { + Recorder r; + const json resp = call(R"([ + {"jsonrpc":"2.0","id":1,"method":"eth_blockNumber"}, + {"jsonrpc":"2.0","id":2,"method":"eth_chainId"} + ])", r); + LOGOS_ASSERT_TRUE(resp.is_array()); + LOGOS_ASSERT_EQ(resp.size(), static_cast(2)); + LOGOS_ASSERT_EQ(resp[0]["id"].get(), 1); + LOGOS_ASSERT_EQ(resp[1]["id"].get(), 2); + LOGOS_ASSERT_EQ(r.seen.size(), static_cast(2)); +} + +LOGOS_TEST(http_still_dispatches_a_notification_but_answers_nothing) { + // No id means no response — but the call must still happen. + Recorder r; + const std::string out = + RpcHttpServer::handleBody(R"({"jsonrpc":"2.0","method":"eth_blockNumber"})", r.fn()); + LOGOS_ASSERT_TRUE(out.empty()); + LOGOS_ASSERT_EQ(r.seen.size(), static_cast(1)); +} + +LOGOS_TEST(http_drops_notifications_from_a_batch_response) { + Recorder r; + const json resp = call(R"([ + {"jsonrpc":"2.0","method":"eth_blockNumber"}, + {"jsonrpc":"2.0","id":9,"method":"eth_chainId"} + ])", r); + LOGOS_ASSERT_EQ(r.seen.size(), static_cast(2)); // both dispatched + LOGOS_ASSERT_EQ(resp.size(), static_cast(1)); // one answered + LOGOS_ASSERT_EQ(resp[0]["id"].get(), 9); +} + +LOGOS_TEST(http_rejects_an_empty_batch) { + Recorder r; + LOGOS_ASSERT_EQ(call("[]", r)["error"]["code"].get(), -32600); +}