Files
logos-verified-proxy-module/src/proxy_config.cpp
T
Dario Gabriel LipicarandClaude Opus 5 421624641a feat: wrap nimbus libverifproxy as a Logos module
Adds `verified_proxy_module`, a universal C++ core module over status-im's
`libverifproxy` — the C library form of nimbus_verified_proxy. Where
`eth_rpc_module` forwards JSON-RPC to a provider and trusts the answer, this
verifies every response against the beacon-chain light client's attested
execution state, so a lying provider produces an error rather than a wrong
value.

Nobody had packaged libverifproxy with Nix before: upstream's flake builds the
verified-proxy *binary* but not the library, and a global code search for
`libverifproxy` in nix files returns nothing. Rather than write a derivation,
flake.nix re-targets upstream's own — `.override { targets = ["libverifproxy"]; }`
composes because callPackage's makeOverridable merges previously-applied args,
so their pinned Nim survives — and then fixes the three things that break:

  * installPhase installs only `-type f -executable` into $out/bin, so a .a and
    a .h yield an EMPTY $out (and installCheckPhase then runs the literal
    string "$out/bin/* --version");
  * env.NIMFLAGS is ASSIGNED, not appended, so ours have to extend it;
  * preBuild builds vendored RocksDB unconditionally although `make
    libverifproxy` never reaches that target. `nm -u` on the result confirms
    zero rocksdb references, so it is dropped rather than swapped for
    dynamicRocksDB (which on Windows would demand a *cross* RocksDB).

Three NIMFLAGS additions are load-bearing rather than tuning:

  * `-d:noSignalHandler` — library/nim.cfg omits it, so NimMain() would install
    Nim's SIGINT/SIGSEGV/SIGABRT handlers over the HOST's. Verified by dlopen'ing
    a probe and comparing sigaction before/after: the host's handler survives.
  * `--passC:-fPIC` — Nim only adds it when optGenDynLib is set, which
    --app:staticlib does not; upstream's dist script adds it for linux-arm64
    only. The archive is linked into a SHARED plugin.
  * `-d:release --debugger:off` — upstream ships debug info, which dominates
    the artifact (~99MB uncompressed in the release tarballs vs 31MB here).

The library can also take the host process down, which a plugin cannot tolerate,
so ProxyConfig whitelists the two fields that reach a Nim `quit()`: an
unrecognised `eth2Network` reaches getMetadataForNetwork's `fatal` + `quit 1`,
and any `logLevel` Nim's updateLogLevel rejects reaches setupLogging's `quit 1`.
Neither is validated upstream. Everything else (bad JSON, missing
trustedBlockRoot, malformed URL) is already caught and turned into a NULL
return, so validating it only improves the message.

ProxyRuntime owns the one thread that may touch the C ABI at all: the library
spawns none, startVerifProxy blocks through an unbounded prologue, and
setupForeignThreadGc/tearDownForeignThreadGc are bound to start/stop. Notable
consequences encoded here:

  * processVerifProxyTasks only poll()s while pendingCalls > 0, so an IDLE PROXY
    DOES NOT ADVANCE ITS LIGHT CLIENT. The heartbeat is
    proxyCall("eth_syncing","[]"), which drives beaconSync() and touches no
    execution backend. Its return value is a hardcoded `false` and useless; its
    error string is the only machine-readable sync-health signal the ABI has.
  * Drain BEFORE stopVerifProxy: it sets ctx.stop, which processVerifProxyTasks
    checks before polling, so afterwards no callback can ever fire.
  * Call slots use joint ownership (waiter + heap CallBox) rather than
    storage-module's `abandoned` flag, so a late callback after a timeout is
    safe by construction. There is no per-call cancel in the C API.
  * concurrency:"multi" spawns a QThread per call rather than using a bounded
    pool, so admission control is mandatory, not a nicety.

All ~60 eth_*/op_* entry points can route through one FFI path, because
proxyCall is a string `case` over the same procs the typed C exports call.
This commit lands 8 representative methods covering every wire type; the rest
are mechanical.

Verified on aarch64-darwin: the archive links into a .dylib; NimMain initialises
under dlopen; a bad config returns NULL rather than quitting; the plugin builds
at 15MB with the archive absorbed (hence `include: []`); and 28/28 unit tests
pass against a mocked C library that — unlike mock_libstorage — queues
completions and drains them only from the pump, so the cross-thread design is
actually exercised.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 22:54:33 -03:00

310 lines
13 KiB
C++

#include "proxy_config.h"
#include <algorithm>
#include <cctype>
#include <set>
#include <sstream>
using json = nlohmann::json;
namespace {
// Upstream's `getMetadataForNetwork` only has mainnet, hoodi and sepolia
// compiled in; anything else falls through to `fatal` + `quit 1`.
const std::set<std::string>& kNetworks() {
static const std::set<std::string> v{ "mainnet", "sepolia", "hoodi" };
return v;
}
// Nim's `updateLogLevel` raises ValueError on anything else, and setupLogging
// turns that into `quit 1`.
const std::set<std::string>& kLogLevels() {
static const std::set<std::string> v{
"TRACE", "DEBUG", "INFO", "NOTICE", "WARN", "ERROR", "FATAL", "NONE" };
return v;
}
const std::set<std::string>& kLogFormats() {
static const std::set<std::string> v{ "Colors", "NoColors", "Json", "Auto", "None" };
return v;
}
const std::set<std::string>& kKeepAliveModes() {
static const std::set<std::string> v{ "off", "interval", "continuous" };
return v;
}
std::string join(const std::set<std::string>& s) {
std::string out;
for (const auto& v : s) { if (!out.empty()) out += ", "; out += v; }
return out;
}
bool isHex(char c) {
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
}
/// Upstream's `parseCmdArg(UrlList, ...)` rejects any scheme outside this set.
bool schemeOk(const std::string& url) {
static const char* kSchemes[] = { "http://", "https://", "ws://", "wss://" };
for (const char* s : kSchemes)
if (url.rfind(s, 0) == 0) return true;
return false;
}
bool readStringList(const json& in, const char* key,
std::vector<std::string>& out, std::string& err) {
if (!in.contains(key) || in[key].is_null()) return true;
const json& v = in[key];
// Accept a bare string too — upstream's own format is comma-separated, so
// a caller pasting that shape should not be punished for it.
if (v.is_string()) {
std::stringstream ss(v.get<std::string>());
std::string item;
while (std::getline(ss, item, ',')) if (!item.empty()) out.push_back(item);
return true;
}
if (!v.is_array()) {
err = std::string("'") + key + "' must be an array of URL strings";
return false;
}
for (const auto& e : v) {
if (!e.is_string()) {
err = std::string("'") + key + "' must contain only strings";
return false;
}
out.push_back(e.get<std::string>());
}
return true;
}
bool validateUrls(const std::vector<std::string>& urls, const char* key,
bool required, std::string& err) {
if (required && urls.empty()) {
err = std::string("'") + key + "' is required and must contain at least one URL";
return false;
}
for (const auto& u : urls) {
if (!schemeOk(u)) {
err = std::string("'") + key + "' entry '" + u
+ "' must use one of the http, https, ws or wss schemes";
return false;
}
// A comma inside a single entry would silently split into two URLs when
// we join for upstream, so reject it where the caller can still see it.
if (u.find(',') != std::string::npos) {
err = std::string("'") + key + "' entry '" + u
+ "' must not contain a comma (the upstream format is comma-separated)";
return false;
}
}
return true;
}
bool readInt(const json& in, const char* key, int64_t& out, std::string& err) {
if (!in.contains(key) || in[key].is_null()) return true;
if (!in[key].is_number_integer()) {
err = std::string("'") + key + "' must be an integer";
return false;
}
out = in[key].get<int64_t>();
return true;
}
bool readBool(const json& in, const char* key, bool& out, std::string& err) {
if (!in.contains(key) || in[key].is_null()) return true;
if (!in[key].is_boolean()) {
err = std::string("'") + key + "' must be a boolean";
return false;
}
out = in[key].get<bool>();
return true;
}
bool readEnum(const json& in, const char* key, const std::set<std::string>& allowed,
std::string& out, std::string& err) {
if (!in.contains(key) || in[key].is_null()) return true;
if (!in[key].is_string()) {
err = std::string("'") + key + "' must be a string";
return false;
}
const std::string v = in[key].get<std::string>();
if (!allowed.count(v)) {
err = std::string("'") + key + "' must be one of: " + join(allowed)
+ " (got '" + v + "')";
return false;
}
out = v;
return true;
}
std::string joinCsv(const std::vector<std::string>& v) {
std::string out;
for (const auto& s : v) { if (!out.empty()) out += ","; out += s; }
return out;
}
/// Strip userinfo and query, and keep only the first path segment. Provider
/// URLs commonly carry the API key as the last path segment or in the query.
std::string redactUrl(const std::string& url) {
const auto schemeEnd = url.find("://");
if (schemeEnd == std::string::npos) return "<redacted>";
const std::string scheme = url.substr(0, schemeEnd + 3);
std::string rest = url.substr(schemeEnd + 3);
if (const auto at = rest.find('@'); at != std::string::npos)
rest = rest.substr(at + 1); // drop user:password@
if (const auto q = rest.find('?'); q != std::string::npos)
rest = rest.substr(0, q) + "?<redacted>";
const auto slash = rest.find('/');
if (slash == std::string::npos) return scheme + rest;
return scheme + rest.substr(0, slash) + "/<redacted>";
}
json urlsRedacted(const std::vector<std::string>& v) {
json out = json::array();
for (const auto& u : v) out.push_back(redactUrl(u));
return out;
}
} // namespace
bool ProxyConfig::fromJson(const json& in, ProxyConfig& out, std::string& err) {
err.clear();
out = ProxyConfig{};
if (!in.is_object()) { err = "config must be a JSON object"; return false; }
// --- the two fields that can kill the host -------------------------------
if (!readEnum(in, "network", kNetworks(), out.network, err)) return false;
if (!readEnum(in, "logLevel", kLogLevels(), out.logLevel, err)) return false;
if (!readEnum(in, "logFormat", kLogFormats(), out.logFormat, err)) return false;
// --- trustedBlockRoot ----------------------------------------------------
if (!in.contains("trustedBlockRoot") || !in["trustedBlockRoot"].is_string()) {
err = "'trustedBlockRoot' is required and must be a 0x-prefixed 32-byte hex string";
return false;
}
out.trustedBlockRoot = in["trustedBlockRoot"].get<std::string>();
if (out.trustedBlockRoot.rfind("0x", 0) != 0 || out.trustedBlockRoot.size() != 66
|| !std::all_of(out.trustedBlockRoot.begin() + 2, out.trustedBlockRoot.end(), isHex)) {
err = "'trustedBlockRoot' must be 0x followed by exactly 64 hex digits (got '"
+ out.trustedBlockRoot + "')";
return false;
}
// --- backends ------------------------------------------------------------
if (!readStringList(in, "executionApiUrls", out.executionApiUrls, err)) return false;
if (!readStringList(in, "beaconApiUrls", out.beaconApiUrls, err)) return false;
if (!readStringList(in, "opExecutionApiUrls", out.opExecutionApiUrls, err)) return false;
if (!readStringList(in, "privateTxUrls", out.privateTxUrls, err)) return false;
if (!readStringList(in, "archiveUrls", out.archiveUrls, err)) return false;
if (!validateUrls(out.executionApiUrls, "executionApiUrls", true, err)) return false;
if (!validateUrls(out.beaconApiUrls, "beaconApiUrls", true, err)) return false;
if (!validateUrls(out.opExecutionApiUrls, "opExecutionApiUrls", false, err)) return false;
if (!validateUrls(out.privateTxUrls, "privateTxUrls", false, err)) return false;
if (!validateUrls(out.archiveUrls, "archiveUrls", false, err)) return false;
// --- upstream tuning -----------------------------------------------------
const json tuning = in.value("tuning", json::object());
if (!tuning.is_object()) { err = "'tuning' must be an object"; return false; }
if (!readInt(tuning, "maxBlockWalk", out.maxBlockWalk, err)) return false;
if (!readInt(tuning, "maxWindowJumps", out.maxWindowJumps, err)) return false;
if (!readInt(tuning, "parallelBlockDownloads", out.parallelBlockDownloads, err)) return false;
if (!readInt(tuning, "maxLightClientUpdates", out.maxLightClientUpdates, err)) return false;
if (!readInt(tuning, "headerStoreLen", out.headerStoreLen, err)) return false;
if (!readInt(tuning, "storageCacheLen", out.storageCacheLen, err)) return false;
if (!readInt(tuning, "codeCacheLen", out.codeCacheLen, err)) return false;
if (!readInt(tuning, "accountCacheLen", out.accountCacheLen, err)) return false;
if (!readInt(tuning, "freezeAtSlot", out.freezeAtSlot, err)) return false;
if (!readBool(tuning, "syncHeaderStore", out.syncHeaderStore, err)) return false;
// --- module knobs --------------------------------------------------------
if (!readInt(in, "callTimeoutMs", out.callTimeoutMs, err)) return false;
if (!readInt(in, "startTimeoutMs", out.startTimeoutMs, err)) return false;
if (!readInt(in, "drainTimeoutMs", out.drainTimeoutMs, err)) return false;
if (!readInt(in, "pumpIntervalMs", out.pumpIntervalMs, err)) return false;
if (!readInt(in, "maxInFlight", out.maxInFlight, err)) return false;
if (!readInt(in, "keepAliveIntervalMs", out.keepAliveIntervalMs, err)) return false;
if (!readBool(in, "autoStart", out.autoStart, err)) return false;
if (!readEnum(in, "keepAlive", kKeepAliveModes(), out.keepAlive, err)) 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; }
if (out.pumpIntervalMs <= 0) { err = "'pumpIntervalMs' must be positive"; return false; }
return true;
}
std::string ProxyConfig::toUpstreamJson() const {
json j;
j["eth2Network"] = network;
j["trustedBlockRoot"] = trustedBlockRoot;
// Comma-separated STRINGS, not arrays — this is upstream's UrlList format.
j["executionApiUrls"] = joinCsv(executionApiUrls);
j["beaconApiUrls"] = joinCsv(beaconApiUrls);
if (!opExecutionApiUrls.empty()) j["opExecutionApiUrls"] = joinCsv(opExecutionApiUrls);
if (!privateTxUrls.empty()) j["privateTxUrls"] = joinCsv(privateTxUrls);
if (!archiveUrls.empty()) j["archiveUrls"] = joinCsv(archiveUrls);
j["logLevel"] = logLevel;
j["logFormat"] = logFormat;
j["maxBlockWalk"] = maxBlockWalk;
j["maxWindowJumps"] = maxWindowJumps;
j["parallelBlockDownloads"] = parallelBlockDownloads;
j["maxLightClientUpdates"] = maxLightClientUpdates;
j["headerStoreLen"] = headerStoreLen;
j["storageCacheLen"] = storageCacheLen;
j["codeCacheLen"] = codeCacheLen;
j["accountCacheLen"] = accountCacheLen;
j["syncHeaderStore"] = syncHeaderStore;
j["freezeAtSlot"] = freezeAtSlot;
return j.dump();
}
json ProxyConfig::redacted() const {
json j;
j["network"] = network;
j["trustedBlockRoot"] = trustedBlockRoot;
j["chainId"] = expectedChainId();
j["executionApiUrls"] = urlsRedacted(executionApiUrls);
j["beaconApiUrls"] = urlsRedacted(beaconApiUrls);
j["opExecutionApiUrls"] = urlsRedacted(opExecutionApiUrls);
j["privateTxUrls"] = urlsRedacted(privateTxUrls);
j["archiveUrls"] = urlsRedacted(archiveUrls);
j["logLevel"] = logLevel;
j["logFormat"] = logFormat;
j["tuning"] = {
{ "maxBlockWalk", maxBlockWalk },
{ "maxWindowJumps", maxWindowJumps },
{ "parallelBlockDownloads", parallelBlockDownloads },
{ "maxLightClientUpdates", maxLightClientUpdates },
{ "headerStoreLen", headerStoreLen },
{ "storageCacheLen", storageCacheLen },
{ "codeCacheLen", codeCacheLen },
{ "accountCacheLen", accountCacheLen },
{ "syncHeaderStore", syncHeaderStore },
{ "freezeAtSlot", freezeAtSlot },
};
j["callTimeoutMs"] = callTimeoutMs;
j["startTimeoutMs"] = startTimeoutMs;
j["drainTimeoutMs"] = drainTimeoutMs;
j["pumpIntervalMs"] = pumpIntervalMs;
j["maxInFlight"] = maxInFlight;
j["keepAlive"] = keepAlive;
j["keepAliveIntervalMs"] = keepAliveIntervalMs;
j["autoStart"] = autoStart;
return j;
}
int64_t ProxyConfig::expectedChainId() const {
if (network == "mainnet") return 1;
if (network == "sepolia") return 11155111;
if (network == "hoodi") return 560048;
return 0;
}