mirror of
https://github.com/logos-co/logos-verified-proxy-module.git
synced 2026-08-27 13:01:09 +00:00
feat: serve a verified JSON-RPC endpoint over HTTP
libverifproxy 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
binary, and nm on the archive we link finds zero of its symbols. So the endpoint
is ours.
Opt-in via `httpServer: { enabled, host, port }`, bound to loopback by default
and refusing anything but POST — this answers state queries, so an accidental
0.0.0.0 bind is a different order of mistake than it is for a metrics port.
Uses libmicrohttpd, matching openmetrics-module. Every request is forwarded
through the same proxyCall path the typed methods use, so there is one
verification path and one error shape rather than a second implementation.
Two adaptations are what make "point ethers at it" true rather than nearly true,
and both are measured rather than assumed:
* eth_call / eth_estimateGas / eth_createAccessList (and the op_ twins) take a
THIRD positional parameter upstream, optimisticStateFetch, which the spec does
not have. Stock clients send two and the library answers "parameters missing".
The endpoint appends the default and leaves an explicit third alone. Verified
against sepolia: a 2-param eth_call now reaches the engine and comes back with
a *verification* error, not a parameter one.
* eth_blockNumber answers a bare JSON number where every client expects a hex
QUANTITY (chainId and gasPrice do return hex — upstream is not uniform).
Promoted to hex here only; rpc() and the typed methods still return what the
library produced, so this cannot hide an upstream change from a direct caller.
handleBody() is pure apart from its dispatch callback, so the whole protocol
surface is unit-tested without binding a socket: envelopes, id-type
preservation, batches, notifications, the reserved error codes, and both
adaptations. 44 tests pass.
Verified end to end against sepolia: the endpoint answers
{"id":1,"jsonrpc":"2.0","result":"0xb02f64"} where the upstream provider says
"0xb02f65" — same shape, one block behind, which is what a verifying light
client should look like.
This also re-opens the eth_rpc_module integration the plan had ruled out on the
grounds that no port existed: ChainConfig.endpoint can now point here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
7702f060a7
commit
8400bd7d36
@@ -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)
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
+13
-5
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<std::string>();
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
#include "rpc_http_server.h"
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <netinet/in.h>
|
||||
#include <sys/socket.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <set>
|
||||
#include <string>
|
||||
|
||||
#include <microhttpd.h>
|
||||
|
||||
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<std::string> 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<unsigned long long>(v.get<uint64_t>()));
|
||||
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<std::string>();
|
||||
|
||||
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<char*>(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<RpcHttpServer::Impl*>(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<ConnState*>(*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<ConnState*>(*con_cls);
|
||||
*con_cls = nullptr;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
RpcHttpServer::RpcHttpServer(Dispatch dispatch)
|
||||
: m_impl(std::make_unique<Impl>(std::move(dispatch))) {}
|
||||
|
||||
RpcHttpServer::~RpcHttpServer() { stop(); }
|
||||
|
||||
bool RpcHttpServer::start(const std::string& host, uint16_t port, std::string& err) {
|
||||
std::lock_guard<std::mutex> 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<sockaddr*>(&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<std::mutex> 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<std::mutex> lk(m_impl->mu);
|
||||
return m_impl->daemon != nullptr;
|
||||
}
|
||||
|
||||
std::string RpcHttpServer::endpoint() const {
|
||||
std::lock_guard<std::mutex> lk(m_impl->mu);
|
||||
if (!m_impl->daemon) return {};
|
||||
return "http://" + m_impl->host + ":" + std::to_string(m_impl->port);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <logos_result.h>
|
||||
|
||||
/// 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<StdLogosResult(const std::string& method,
|
||||
const nlohmann::json& params)>;
|
||||
|
||||
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<Impl> m_impl;
|
||||
};
|
||||
@@ -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<RpcHttpServer>(
|
||||
[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<uint16_t>(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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ProxyConfig> m_cfg;
|
||||
std::unique_ptr<ProxyRuntime> m_rt;
|
||||
std::unique_ptr<RpcHttpServer> m_http;
|
||||
bool m_configured = false;
|
||||
};
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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 <string>
|
||||
#include <vector>
|
||||
|
||||
#include <logos_test.h>
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include "rpc_http_server.h"
|
||||
|
||||
using json = nlohmann::json;
|
||||
|
||||
namespace {
|
||||
|
||||
/// Records what reached the proxy, and answers a canned value.
|
||||
struct Recorder {
|
||||
std::vector<std::pair<std::string, json>> 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>(), std::string("2.0"));
|
||||
LOGOS_ASSERT_EQ(resp["id"].get<int>(), 7);
|
||||
LOGOS_ASSERT_EQ(resp["result"].get<std::string>(), std::string("0x1"));
|
||||
LOGOS_ASSERT_FALSE(resp.contains("error"));
|
||||
LOGOS_ASSERT_EQ(r.seen.size(), static_cast<size_t>(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<size_t>(1));
|
||||
const json& p = r.seen[0].second;
|
||||
LOGOS_ASSERT_EQ(p.size(), static_cast<size_t>(3));
|
||||
LOGOS_ASSERT_TRUE(p[2].is_boolean());
|
||||
LOGOS_ASSERT_FALSE(p[2].get<bool>());
|
||||
}
|
||||
}
|
||||
|
||||
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<size_t>(3));
|
||||
LOGOS_ASSERT_TRUE(p[2].get<bool>());
|
||||
}
|
||||
|
||||
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<size_t>(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>(), 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>(), 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>(), 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<int>(), -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<int>(), -32602);
|
||||
LOGOS_ASSERT_CONTAINS(resp["error"]["message"].get<std::string>(), "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<int>(), -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<int>(), -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<int>(), -32000);
|
||||
LOGOS_ASSERT_CONTAINS(resp["error"]["message"].get<std::string>(), "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<size_t>(2));
|
||||
LOGOS_ASSERT_EQ(resp[0]["id"].get<int>(), 1);
|
||||
LOGOS_ASSERT_EQ(resp[1]["id"].get<int>(), 2);
|
||||
LOGOS_ASSERT_EQ(r.seen.size(), static_cast<size_t>(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<size_t>(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<size_t>(2)); // both dispatched
|
||||
LOGOS_ASSERT_EQ(resp.size(), static_cast<size_t>(1)); // one answered
|
||||
LOGOS_ASSERT_EQ(resp[0]["id"].get<int>(), 9);
|
||||
}
|
||||
|
||||
LOGOS_TEST(http_rejects_an_empty_batch) {
|
||||
Recorder r;
|
||||
LOGOS_ASSERT_EQ(call("[]", r)["error"]["code"].get<int>(), -32600);
|
||||
}
|
||||
Reference in New Issue
Block a user