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>
This commit is contained in:
Dario Gabriel Lipicar
2026-08-20 22:54:33 -03:00
co-authored by Claude Opus 5
commit 421624641a
19 changed files with 90982 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
result
result-*
build/
.direnv/
+67
View File
@@ -0,0 +1,67 @@
cmake_minimum_required(VERSION 3.14)
project(VerifiedProxyModulePlugin LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
if(DEFINED ENV{LOGOS_MODULE_BUILDER_ROOT})
include($ENV{LOGOS_MODULE_BUILDER_ROOT}/cmake/LogosModule.cmake)
elseif(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/cmake/LogosModule.cmake")
include(cmake/LogosModule.cmake)
else()
message(FATAL_ERROR "LogosModule.cmake not found. Set LOGOS_MODULE_BUILDER_ROOT.")
endif()
# Universal module — generated_code/ is picked up automatically.
logos_module(
NAME verified_proxy_module
SOURCES
src/verified_proxy_impl.h
src/verified_proxy_impl.cpp
src/proxy_config.h
src/proxy_config.cpp
src/proxy_runtime.h
src/proxy_runtime.cpp
EXTERNAL_LIBS
verifproxy
INCLUDE_DIRS
lib
)
# 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)
string(JSON VERIFIED_PROXY_MODULE_VERSION GET "${_vp_metadata_json}" version)
target_compile_definitions(verified_proxy_module_module_plugin
PRIVATE VERIFIED_PROXY_MODULE_VERSION="${VERIFIED_PROXY_MODULE_VERSION}")
# Native libraries libverifproxy.a leaves undefined. Copied from nimbus-eth1's
# own consumer recipe (Makefile VERIFPROXY_LDFLAGS), whose `libverifproxy_test`
# target links `-lverifproxy` PLAIN — no --whole-archive / -force_load — and
# then RUNS the result. That is the upstream proof that ordinary archive
# linking suffices here.
#
# These go here and NOT in metadata.json's nix.cmake.extra_link_libraries:
# that key is parsed (parseMetadata.nix:97) and read by no builder code path,
# so putting flags there links nothing at all.
if(APPLE)
# -framework Security is load-bearing AND cannot be inferred: LogosModule
# puts `-undefined dynamic_lookup` on every macOS plugin, so omitting it
# yields a plugin that LINKS CLEANLY and then fails at dlopen.
# No explicit -lc++: CMake already links the C++ runtime for a CXX target,
# and naming it again only produces a "duplicate libraries" warning.
target_link_libraries(verified_proxy_module_module_plugin PRIVATE
"-framework Security")
elseif(WIN32)
# Upstream says -lc++ because their dist build uses llvm-mingw; logos-nix's
# cross is gcc-mingw, so it is libstdc++ here. A static Nim archive records
# no imports, so the plugin must name the Win32 set itself.
target_link_libraries(verified_proxy_module_module_plugin PRIVATE
stdc++ ws2_32 bcrypt iphlpapi userenv ntdll dbghelp winpthread)
else()
target_link_libraries(verified_proxy_module_module_plugin PRIVATE stdc++ m)
# Do not re-export anything the archive carries (bearssl, secp256k1, blst,
# a vendored zlib/sqlite) into the host's global namespace.
target_link_options(verified_proxy_module_module_plugin PRIVATE
-Wl,--exclude-libs,ALL)
endif()
Generated
+88524
View File
File diff suppressed because it is too large Load Diff
+188
View File
@@ -0,0 +1,188 @@
{
description = "libverifproxy the C static-library form of nimbus_verified_proxy";
# libverifproxy is a large archive and the Nim toolchain behind it is slow to
# build; pull both from the Logos Attic rather than rebuilding per machine.
nixConfig = {
extra-substituters = [ "https://cache.nix.logos.co/public" ];
extra-trusted-public-keys = [ "public:l4HrXgL4nw246+LBh2SOJyhz64BoGegOYLheT/iIAPU=" ];
};
inputs = {
logos-module-builder.url = "github:logos-co/logos-module-builder";
logos-nix.url = "github:logos-co/logos-nix";
# git+https, NOT github: — the github: scheme does not carry submodules
# (NixOS/nix#14982) and nimbus' nix/default.nix asserts on `self.submodules`.
# Needs Nix >= 2.27 for the flake-level `self = { submodules = true; }`.
nimbus-eth1.url = "git+https://github.com/status-im/nimbus-eth1?submodules=1&ref=refs/tags/v0.4.0";
};
outputs = inputs@{ self, logos-module-builder, logos-nix, nimbus-eth1 }:
let
nixpkgs = logos-nix.inputs.nixpkgs;
lib = nixpkgs.lib;
nativeSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
systems = nativeSystems ++ [ "x86_64-windows" ];
pkgsFor = system:
if system == "x86_64-windows"
then logos-nix.lib.mkWindowsPkgs { buildSystem = "x86_64-linux"; }
else import nixpkgs { inherit system; };
libverifproxyFor = system:
let
pkgs = pkgsFor system;
isWin = system == "x86_64-windows";
# Windows cannot go through nimbus' own flake: it does
# `import nixpkgs { system = "x86_64-windows"; }`, which yields a
# NATIVE Windows package set — it evaluates, and it is unusable.
# Call their nix/default.nix ourselves with a real cross pkgs set.
base =
if isWin then
pkgs.callPackage "${nimbus-eth1}/nix/default.nix" {
self = nimbus-eth1;
# MUST contain the target: nix/default.nix feeds this straight
# to meta.platforms, and nixpkgs refuses to evaluate a
# derivation whose meta.platforms omits the hostPlatform.
stableSystems = [ "x86_64-windows" ];
# USE_SYSTEM_NIM=1 wants a BUILD-side Nim; pkgs.nim here is a PE.
# Under that cross wrapper nimscript's `defined(windows)` is
# already true, so no --os:windows has to be passed by hand.
nim = pkgs.buildPackages.nim-2_2;
targets = [ "libverifproxy" ];
}
else
nimbus-eth1.packages.${system}.nimbus_verified_proxy.override {
targets = [ "libverifproxy" ];
};
in
base.overrideAttrs (old: {
pname = "libverifproxy";
# Upstream has `perl sqlite python3` in buildInputs. perl and python3
# are Makefile TOOLS, not target libraries — harmless natively, fatal
# under cross, because nixpkgs marks the mingw python3 BROKEN and the
# derivation then refuses to evaluate.
buildInputs =
if isWin
# nixpkgs builds mingw-w64 against mcfgthread, so pthread.h and
# libpthread.a exist nowhere in the default closure — but the
# vendored C assumes POSIX threads regardless.
then [ pkgs.sqlite pkgs.windows.pthreads ]
else old.buildInputs;
nativeBuildInputs = old.nativeBuildInputs
++ lib.optionals isWin (with pkgs.buildPackages; [
perl python3 gnumake
nasm # nim-boringssl's Windows branch shells out to `nasm -f win64`
]);
makeFlags = old.makeFlags ++ lib.optionals isWin [
# nim-libbacktrace vendors libbacktrace and configures it POSIX-shaped.
"USE_LIBBACKTRACE=0"
];
# Upstream builds the VENDORED RocksDB in preBuild unconditionally
# ("takes almost double the time"), although `make libverifproxy`
# never reaches the rocksdb target: deps is
# `deps-common nat-libs nimbus.nims build/generate_makefile`.
# Dropping it beats dynamicRocksDB = true, which would instead put a
# (cross, on Windows) rocksdb in buildInputs.
# Verify: nm -u $out/lib/libverifproxy.a | grep -c rocksdb_
preBuild = lib.optionalString isWin ''
# --app:staticlib makes Nim shell out to a BARE `ar`, and a cross
# stdenv has only x86_64-w64-mingw32-ar on PATH. The nixpkgs nim
# wrapper rewrites gcc.exe/gcc.linkerexe from $CC/$CXX but never the
# archiver, and nim exposes no config key for it.
mkdir -p $TMPDIR/arshim
ln -sf "$(command -v $AR)" $TMPDIR/arshim/ar
export PATH=$TMPDIR/arshim:$PATH
# nimbus-build-system's nat-libs targets branch on $(OS) the
# cmd.exe variable, empty on a Linux builder so a cross build
# silently takes their POSIX branch and the archives then call
# their own symbols through __imp_ stubs.
make -C vendor/nim-nat-traversal/vendor/miniupnp/miniupnpc \
CC="$CC" AR="$AR" RANLIB="$RANLIB" \
CFLAGS="-Os -DMINIUPNP_STATICLIB" build/libminiupnpc.a
make -C vendor/nim-nat-traversal/vendor/libnatpmp-upstream \
CC="$CC" AR="$AR" RANLIB="$RANLIB" \
CFLAGS="-Wall -Os -DENABLE_STRNATPMPERR -DNATPMP_MAX_RETRIES=4 -DNATPMP_STATICLIB" \
libnatpmp.a
'';
env = old.env // {
NIMFLAGS = old.env.NIMFLAGS
# library/nim.cfg omits noSignalHandler, so NimMain() would
# install Nim's SIGINT/SIGSEGV/SIGABRT handlers over the HOST's.
+ " -d:noSignalHandler"
+ " -d:release --debugger:off -d:disableLTO"
# Nim only adds -fPIC when optGenDynLib is set, and --app:staticlib
# does not set it. The archive is linked into a SHARED plugin.
# Meaningless on PE.
+ lib.optionalString (!isWin) " --passC:-fPIC";
};
# Upstream 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".
installPhase = ''
runHook preInstall
mkdir -p $out/lib $out/include
install -m444 build/libverifproxy/libverifproxy.a $out/lib/
install -m444 build/libverifproxy/verifproxy.h $out/include/
runHook postInstall
'';
doInstallCheck = false;
});
# A flake-SHAPED attrset, not a flake: resolveExtInput only needs
# `x.packages.${system}.<name>`.
#
# Use the structured { input; packages.default; } form and NOT the barer
# { packages.<sys>.default = drv; } escape hatch: buildCppPlugin accepts
# both, but mkLogosModuleTests only checks `value ? input` and otherwise
# hands the raw attrset to mkExternalLib as a `src`. The plugin would
# build and the unit tests would fail to EVALUATE.
libverifproxyFlake = {
packages = lib.genAttrs systems (s: { libverifproxy = libverifproxyFor s; });
};
nimbusRev = nimbus-eth1.rev or nimbus-eth1.shortRev or "unknown";
module = logos-module-builder.lib.mkLogosModule {
src = ./.;
configFile = ./metadata.json;
flakeInputs = inputs;
# `verifproxy`, not `libverifproxy`: find_library searches lib${name}.a,
# which maps onto the real libverifproxy.a.
externalLibInputs.verifproxy = {
input = libverifproxyFlake;
packages.default = "libverifproxy";
};
# The library exposes no version symbol, so stamp the upstream revision
# in at build time for status()/libraryVersion().
preConfigure = ''
printf '#define VERIFIED_PROXY_NIMBUS_REV "%s"\n' "${nimbusRev}" \
> src/verified_proxy_nimbus_rev.h
'';
tests = {
dir = ./tests;
# Keeps the ~25-minute upstream build out of the test derivation
# entirely; unit tests link mocks/mock_libverifproxy.cpp instead.
mockCLibs = [ "verifproxy" ];
};
};
in
module // {
packages = lib.genAttrs systems (system:
(module.packages.${system} or {}) // {
libverifproxy = libverifproxyFor system;
});
};
}
+32
View File
@@ -0,0 +1,32 @@
{
"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": [],
"runtime": ["nlohmann_json"]
},
"external_libraries": [
{ "name": "verifproxy" }
],
"cmake": {
"extra_include_dirs": ["lib"]
}
}
}
+309
View File
@@ -0,0 +1,309 @@
#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;
}
+84
View File
@@ -0,0 +1,84 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
#include <nlohmann/json.hpp>
/// Validated configuration for the verified proxy.
///
/// Two of these fields are a SAFETY boundary, not hygiene. `startVerifProxy`
/// catches its own `CatchableError`s and returns NULL for bad JSON, a missing
/// trustedBlockRoot or a malformed URL — but `eth2Network` and `logLevel` are
/// not validated upstream at all, and a bad value reaches a `quit()` that takes
/// the whole HOST process down:
///
/// * an unknown network reaches nimbus-eth2's `getMetadataForNetwork`, whose
/// fallthrough is `fatal "config.yaml not found for network"` + `quit 1`;
/// * a log level Nim's `updateLogLevel` rejects reaches `setupLogging`, which
/// writes to stderr and `quit 1`s.
///
/// So both are whitelisted here, before the value can ever cross the FFI.
struct ProxyConfig {
// ── Required ─────────────────────────────────────────────────────────
std::string network = "mainnet"; // -> eth2Network
std::string trustedBlockRoot; // 0x + 64 hex
std::vector<std::string> executionApiUrls;
std::vector<std::string> beaconApiUrls;
// ── Optional backends ────────────────────────────────────────────────
// OP-Stack L2 is enabled by SETTING opExecutionApiUrls, not by naming an
// op-* network: the library's JSON config has no OP network key (that is a
// CLI-only option on the standalone binary).
std::vector<std::string> opExecutionApiUrls;
std::vector<std::string> privateTxUrls;
std::vector<std::string> archiveUrls;
// ── Logging ──────────────────────────────────────────────────────────
std::string logLevel = "INFO";
std::string logFormat = "Json";
// ── Upstream tuning knobs (passed through verbatim) ───────────────────
int64_t maxBlockWalk = 1000;
int64_t maxWindowJumps = 500;
int64_t parallelBlockDownloads = 10;
int64_t maxLightClientUpdates = 128;
int64_t headerStoreLen = 256;
int64_t storageCacheLen = 256;
int64_t codeCacheLen = 64;
int64_t accountCacheLen = 128;
bool syncHeaderStore = true;
int64_t freezeAtSlot = 0;
// ── Module-side knobs (never sent upstream) ──────────────────────────
int64_t callTimeoutMs = 30000;
int64_t startTimeoutMs = 120000;
int64_t drainTimeoutMs = 2000;
int64_t pumpIntervalMs = 50;
int64_t maxInFlight = 64;
/// "off" | "interval" | "continuous". `processVerifProxyTasks` only polls
/// while `pendingCalls > 0`, so an idle proxy does not advance its light
/// client at all — the heartbeat is what keeps chronos turning.
std::string keepAlive = "interval";
int64_t keepAliveIntervalMs = 1000;
bool autoStart = false;
/// 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);
/// The JSON string `startVerifProxy` expects. Note the URL lists are
/// COMMA-SEPARATED STRINGS upstream, not arrays.
std::string toUpstreamJson() const;
/// Round-trippable view for getConfig(), with URL credentials redacted —
/// provider URLs routinely carry an API key in the path or query.
nlohmann::json redacted() const;
/// The chain id this network must report, or 0 if unknown. The library
/// hardcodes mainnet->1, sepolia->11155111, hoodi->560048 and does NOT
/// verify it against the provider (the README says otherwise; it is stale),
/// so the module checks it after start().
int64_t expectedChainId() const;
};
+429
View File
@@ -0,0 +1,429 @@
#include "proxy_runtime.h"
#include <cassert>
#include <chrono>
#include <cstdio>
#include <ctime>
#include <utility>
extern "C" {
#include "lib/verifproxy.h"
}
using json = nlohmann::json;
using namespace std::chrono;
namespace {
std::once_flag g_nimMainOnce;
int64_t nowSeconds() {
return duration_cast<seconds>(system_clock::now().time_since_epoch()).count();
}
/// Owns a Nim-allocated string. It must be released with
/// freeNimAllocatedString and NEVER with free()/delete: `library/nim.cfg` does
/// not set -d:useMalloc, so Nim uses its own shared-heap allocator here.
///
/// The null guard below is load-bearing, not defensive style:
/// freeNimAllocatedString(NULL) SEGFAULTS (it is a bare deallocShared), and the
/// C API hands back a null `result` on some paths.
class NimString {
public:
explicit NimString(char* p) noexcept : m_p(p) {}
~NimString() { if (m_p) ::freeNimAllocatedString(m_p); }
NimString(const NimString&) = delete;
NimString& operator=(const NimString&) = delete;
/// Copy OUT before the Nim string dies.
std::string str() const { return m_p ? std::string(m_p) : std::string(); }
private:
char* m_p;
};
/// Decode a callback payload.
///
/// The shapes are inconsistent upstream and both must be tolerated:
/// * RET_SUCCESS -> `Json.encode(value)`, e.g. "\"0x10d4f\"" or an object
/// * RET_ERROR from a Result-> RAW "errType: errMsg", NOT json
/// * RET_ERROR from a Future-> `Json.encode(msg)`, i.e. a JSON string
/// * RET_DESER_ERROR -> a plain string ("unknown method", "parameters missing")
json decodePayload(const std::string& raw, bool& parsedAsJson) {
parsedAsJson = false;
if (raw.empty()) return json();
try {
json v = json::parse(raw);
parsedAsJson = true;
return v;
} catch (const std::exception&) {
return json(raw);
}
}
std::string errorMessage(int status, const std::string& raw) {
bool wasJson = false;
const json v = decodePayload(raw, wasJson);
std::string msg = v.is_string() ? v.get<std::string>() : raw;
if (msg.empty()) msg = "no detail";
switch (status) {
case RET_CANCELLED: return "cancelled: " + msg;
case RET_DESER_ERROR: return "bad request: " + msg;
default: return msg;
}
}
} // namespace
// The heap box we hand Nim as `userData`. Deleted exactly once, in the
// callback's first statement.
struct CallBox {
std::shared_ptr<CallSlot> slot;
ProxyRuntime* rt;
};
const char* ProxyRuntime::stateName(State s) {
switch (s) {
case State::Idle: return "uninitialized";
case State::Starting: return "starting";
case State::Running: return "running";
case State::Degraded: return "degraded";
case State::Draining: return "stopping";
case State::Stopped: return "stopped";
case State::Failed: return "error";
}
return "unknown";
}
ProxyRuntime::ProxyRuntime(EmitFn emit) : m_emit(std::move(emit)) {}
ProxyRuntime::~ProxyRuntime() { stop(); }
void ProxyRuntime::setState(State s, const std::string& error) {
const State prev = m_state.exchange(s);
if (!error.empty()) {
std::lock_guard<std::mutex> lk(m_errMu);
m_lastError = error;
}
if (prev == s) return;
if (m_emit) {
json p{ { "state", stateName(s) }, { "previous", stateName(prev) } };
if (!error.empty()) p["error"] = error;
m_emit("proxyStateChanged", p.dump());
}
}
bool ProxyRuntime::keepAliveEnabled() const { return m_cfg.keepAlive != "off"; }
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
StdLogosResult ProxyRuntime::start(const ProxyConfig& cfg) {
if (m_thread.joinable())
return { false, {}, "proxy already started" };
m_cfg = cfg;
m_upstreamJson = cfg.toUpstreamJson();
m_stopRequested = false;
{
std::lock_guard<std::mutex> lk(m_startMu);
m_startDone = false; m_startOk = false; m_startError.clear();
}
setState(State::Starting);
m_thread = std::thread([this] { threadMain(); });
std::unique_lock<std::mutex> lk(m_startMu);
const bool signalled = m_startCv.wait_for(
lk, milliseconds(m_cfg.startTimeoutMs), [this] { return m_startDone; });
if (!signalled) {
// startVerifProxy has an unbounded prologue and no cancel. Leave the
// thread running rather than tearing down underneath it; stop() will
// join once it returns.
return { false, {}, "timed out after " + std::to_string(m_cfg.startTimeoutMs)
+ "ms waiting for the light client to initialise" };
}
if (!m_startOk)
return { false, {}, m_startError };
m_startedAt = nowSeconds();
return { true, json{ { "chainId", m_cfg.expectedChainId() } }, "" };
}
StdLogosResult ProxyRuntime::stop() {
if (!m_thread.joinable())
return { false, {}, "proxy is not running" };
m_stopRequested = true;
m_cv.notify_all();
// Join UNCONDITIONALLY, never detach: LogosModule::unload() unmaps the
// plugin image while the host keeps running, so a detached thread would
// execute unmapped code.
m_thread.join();
setState(State::Stopped);
return { true, {}, "" };
}
void ProxyRuntime::threadMain() {
m_threadId = std::this_thread::get_id();
// NimMain must run before anything else (library/nim.cfg sets --noMain:on),
// and it must run on the thread that later registers itself for the foreign
// GC, because setupForeignThreadGc/tearDownForeignThreadGc are bound to
// startVerifProxy/stopVerifProxy.
std::call_once(g_nimMainOnce, [] { ::NimMain(); });
m_ctx = ::startVerifProxy(m_upstreamJson.data(), nullptr, nullptr);
{
std::lock_guard<std::mutex> lk(m_startMu);
m_startDone = true;
m_startOk = (m_ctx != nullptr);
if (!m_startOk) {
// The C API has no error out-param: startVerifProxy caught a
// CatchableError, destroyed its context and returned nil. The
// reason exists only in the chronicles output on stdout.
m_startError = "startVerifProxy returned NULL — the library reports no "
"reason through the C API; see the module log for the "
"chronicles output (topics vp_main / vp_engine)";
}
}
m_startCv.notify_all();
if (!m_ctx) {
setState(State::Failed, "startVerifProxy returned NULL");
if (m_emit)
m_emit("proxyStarted",
json{ { "success", false }, { "error", m_startError } }.dump());
return;
}
setState(State::Running);
if (m_emit)
m_emit("proxyStarted",
json{ { "success", true },
{ "chainId", m_cfg.expectedChainId() } }.dump());
auto nextKeepAlive = steady_clock::now();
for (;;) {
drainCommands();
if (m_stopRequested.load(std::memory_order_acquire)) break;
if (m_inFlight.load() == 0 && keepAliveEnabled()
&& steady_clock::now() >= nextKeepAlive) {
issueKeepAlive();
nextKeepAlive = steady_clock::now() + milliseconds(m_cfg.keepAliveIntervalMs);
}
const auto t0 = steady_clock::now();
const int rc = ::processVerifProxyTasks(m_ctx);
const auto dt = steady_clock::now() - t0;
if (rc == RET_CANCELLED) break;
if (m_inFlight.load(std::memory_order_acquire) > 0) {
// Hot path. processVerifProxyTasks blocks inside chronos poll()
// only while something is pending; if it returned instantly it did
// no work, so back off 1ms rather than spinning a core.
if (dt < milliseconds(1))
std::this_thread::sleep_for(milliseconds(1));
continue;
}
// Idle: sleep on the condvar so an enqueue wakes us immediately.
std::unique_lock<std::mutex> lk(m_mu);
m_cv.wait_for(lk, milliseconds(m_cfg.pumpIntervalMs),
[this] { return !m_queue.empty() || m_stopRequested.load(); });
}
teardown();
}
void ProxyRuntime::teardown() {
assert(std::this_thread::get_id() == m_threadId);
setState(State::Draining);
// DRAIN BEFORE STOPPING. stopVerifProxy sets ctx.stop, and
// processVerifProxyTasks checks ctx.stop BEFORE polling — so after it, no
// callback can ever fire and anything in flight would hang forever.
const auto deadline = steady_clock::now() + milliseconds(m_cfg.drainTimeoutMs);
while (m_inFlight.load() > 0 && steady_clock::now() < deadline) {
if (::processVerifProxyTasks(m_ctx) == RET_CANCELLED) break;
std::this_thread::sleep_for(milliseconds(1));
}
failAllPending("proxy shutting down");
::stopVerifProxy(m_ctx);
::freeContext(m_ctx);
m_ctx = nullptr;
if (m_emit) m_emit("proxyStopped", json{ { "success", true } }.dump());
}
void ProxyRuntime::failAllPending(const std::string& why) {
std::deque<std::weak_ptr<CallSlot>> pending;
{ std::lock_guard<std::mutex> lk(m_mu); pending.swap(m_pending); }
for (auto& w : pending) {
auto slot = w.lock();
if (!slot) continue;
std::lock_guard<std::mutex> lk(slot->mu);
if (slot->done) continue;
slot->done = true;
slot->status = RET_ERROR;
slot->result = why;
slot->cv.notify_all();
// The matching CallBox is DELIBERATELY LEAKED: after freeContext there
// is no dispatcher left to run its callback, and freeing it while Nim
// might still hold the pointer would be a use-after-free. A few hundred
// bytes per abandoned call, only at shutdown.
m_leaked.fetch_add(1, std::memory_order_relaxed);
}
}
void ProxyRuntime::drainCommands() {
assert(std::this_thread::get_id() == m_threadId);
for (;;) {
std::function<void(Context*)> cmd;
{
std::lock_guard<std::mutex> lk(m_mu);
if (m_queue.empty()) return;
cmd = std::move(m_queue.front());
m_queue.pop_front();
}
cmd(m_ctx);
}
}
// ---------------------------------------------------------------------------
// Calls
// ---------------------------------------------------------------------------
StdLogosResult ProxyRuntime::call(const std::string& method, const json& params) {
if (!running() && m_state.load() != State::Degraded)
return { false, {}, "proxy not running" };
if (!params.is_array())
return { false, {}, "params must be a JSON array" };
if (m_inFlight.load() >= m_cfg.maxInFlight)
return { false, {}, "too many calls in flight (max " +
std::to_string(m_cfg.maxInFlight) + ")" };
auto slot = std::make_shared<CallSlot>();
slot->id = m_nextId.fetch_add(1, std::memory_order_relaxed);
slot->method = method;
slot->params = params.dump();
{
std::lock_guard<std::mutex> lk(m_mu);
m_pending.push_back(slot);
m_queue.push_back([this, slot](Context* ctx) {
auto* box = new CallBox{ slot, this }; // freed in the callback
m_inFlight.fetch_add(1, std::memory_order_acq_rel);
m_callsTotal.fetch_add(1, std::memory_order_relaxed);
::proxyCall(ctx, slot->method.data(), slot->params.data(),
&ProxyRuntime::callbackTrampoline, box);
});
}
m_cv.notify_one();
std::unique_lock<std::mutex> lk(slot->mu);
if (!slot->cv.wait_for(lk, milliseconds(m_cfg.callTimeoutMs),
[&] { return slot->done; })) {
// The slot stays alive — the CallBox owns a share — so a late callback
// is harmless. There is no per-call cancel in the C API.
return { false, {}, "timed out after " + std::to_string(m_cfg.callTimeoutMs) + "ms" };
}
if (slot->status != RET_SUCCESS)
return { false, {}, errorMessage(slot->status, slot->result) };
bool wasJson = false;
json value = decodePayload(slot->result, wasJson);
return { true, std::move(value), "" };
}
void ProxyRuntime::callbackTrampoline(Context*, int status, char* result, void* userData) {
// Runs inside Nim frames: an escaping C++ exception is undefined behaviour.
try {
std::unique_ptr<CallBox> box(static_cast<CallBox*>(userData)); // exactly once
NimString owned(result); // freed at scope exit
if (!box) return;
auto slot = box->slot;
{
std::lock_guard<std::mutex> lk(slot->mu);
if (!slot->done) {
slot->status = status;
slot->result = owned.str(); // COPY before the Nim string dies
slot->done = true;
}
slot->cv.notify_all();
}
box->rt->noteFinished(slot->id, status == RET_SUCCESS);
} catch (...) {
// Never propagate into Nim.
}
}
void ProxyRuntime::noteFinished(uint64_t, bool ok) {
m_inFlight.fetch_sub(1, std::memory_order_acq_rel);
if (!ok) m_callsFailed.fetch_add(1, std::memory_order_relaxed);
}
// ---------------------------------------------------------------------------
// Heartbeat
// ---------------------------------------------------------------------------
void ProxyRuntime::issueKeepAlive() {
assert(std::this_thread::get_id() == m_threadId);
// eth_syncing is the cheapest possible keep-alive: its frontend runs
// engine.beaconSync() and touches no execution backend, and issuing it
// bumps ctx.pendingCalls so processVerifProxyTasks actually poll()s. Its
// RETURN value is a hardcoded `false` and useless; its ERROR string is the
// only machine-readable sync-health signal the C ABI exposes.
//
// Reached through proxyCall rather than a hand-declared extern: eth_syncing
// is exported by c_frontend.nim but absent from verifproxy.h, so declaring
// it ourselves would risk a link failure against another build.
auto slot = std::make_shared<CallSlot>();
slot->id = m_nextId.fetch_add(1, std::memory_order_relaxed);
slot->method = "eth_syncing";
slot->params = "[]";
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);
// Fire and forget; the outcome is observed on a later pump turn by
// pollHeartbeat(). Recording the slot lets shutdown release it.
std::lock_guard<std::mutex> lk(m_mu);
m_pending.push_back(slot);
}
// ---------------------------------------------------------------------------
// Status
// ---------------------------------------------------------------------------
json ProxyRuntime::statusSnapshot() const {
json j;
j["state"] = stateName(m_state.load());
j["network"] = m_cfg.network;
j["chainId"] = m_cfg.expectedChainId();
j["startedAt"] = m_startedAt;
j["uptimeSeconds"] = m_startedAt ? (nowSeconds() - m_startedAt) : 0;
{
std::lock_guard<std::mutex> lk(m_errMu);
j["lastError"] = m_lastError;
j["head"] = json{ { "blockNumber", m_headBlockNumber },
{ "updatedAt", m_headUpdatedAt } };
}
j["counters"] = json{
{ "callsTotal", m_callsTotal.load() },
{ "callsFailed", m_callsFailed.load() },
{ "callsInFlight", m_inFlight.load() },
{ "leakedCalls", m_leaked.load() },
{ "heartbeatFailures", m_heartbeatFailures.load() },
};
j["keepAlive"] = m_cfg.keepAlive;
return j;
}
+127
View File
@@ -0,0 +1,127 @@
#pragma once
#include <atomic>
#include <condition_variable>
#include <cstdint>
#include <deque>
#include <functional>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#include <nlohmann/json.hpp>
#include <logos_result.h>
#include "proxy_config.h"
struct Context; // opaque, from verifproxy.h
/// One in-flight proxy call.
///
/// Ownership is JOINT: the waiter holds a shared_ptr, and the heap CallBox we
/// hand Nim as `userData` holds another. Whoever drops last frees. That
/// replaces logos-storage-module's `abandoned` flag — a caller that times out
/// simply lets go, and a late callback is safe by construction rather than by
/// a race-sensitive protocol.
struct CallSlot {
std::mutex mu;
std::condition_variable cv;
bool done = false;
int status = -1; // RET_*
std::string result; // COPIED out of the Nim-allocated string
uint64_t id = 0;
// Argument backing store lives HERE, not in a temporary: we cannot assume
// the Nim side copies its cstring arguments before its first await.
std::string method;
std::string params;
};
class ProxyRuntime {
public:
/// `emit` is called with (eventName, jsonPayload). Safe from any thread —
/// the host marshals it.
using EmitFn = std::function<void(const std::string&, const std::string&)>;
explicit ProxyRuntime(EmitFn emit);
~ProxyRuntime();
ProxyRuntime(const ProxyRuntime&) = delete;
ProxyRuntime& operator=(const ProxyRuntime&) = delete;
/// Spin up the proxy thread and wait for `startVerifProxy` to return.
/// Blocks up to `cfg.startTimeoutMs`. Safe to block: the latch is tripped
/// by the PROXY thread, never by the caller's own.
StdLogosResult start(const ProxyConfig& cfg);
/// Drain in-flight calls, then stop and free the context. Idempotent.
StdLogosResult stop();
bool running() const { return m_state.load() == State::Running; }
/// 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.
///
/// `params` must be a JSON ARRAY (upstream does `parseJson(params).getElems`).
StdLogosResult call(const std::string& method, const nlohmann::json& params);
nlohmann::json statusSnapshot() const;
private:
enum class State { Idle, Starting, Running, Degraded, Draining, Stopped, Failed };
static const char* stateName(State s);
void threadMain();
void teardown();
void drainCommands();
void issueKeepAlive();
void failAllPending(const std::string& why);
void setState(State s, const std::string& error = {});
bool keepAliveEnabled() const;
/// 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 noteFinished(uint64_t id, bool ok);
// ── owned by the proxy thread ────────────────────────────────────────
Context* m_ctx = nullptr;
std::string m_upstreamJson; // must outlive the startVerifProxy call
std::thread::id m_threadId;
// ── shared ───────────────────────────────────────────────────────────
std::thread m_thread;
mutable std::mutex m_mu;
std::condition_variable m_cv; // wakes the pump
std::deque<std::function<void(Context*)>> m_queue;
std::atomic<uint64_t> m_nextId{1};
std::atomic<int64_t> m_inFlight{0};
std::atomic<int64_t> m_leaked{0};
std::atomic<int64_t> m_callsTotal{0};
std::atomic<int64_t> m_callsFailed{0};
std::atomic<int64_t> m_heartbeatFailures{0};
std::atomic<bool> m_stopRequested{false};
std::atomic<State> m_state{State::Idle};
// start() handshake
std::mutex m_startMu;
std::condition_variable m_startCv;
bool m_startDone = false;
bool m_startOk = false;
std::string m_startError;
mutable std::mutex m_errMu;
std::string m_lastError;
std::string m_headBlockNumber;
int64_t m_headUpdatedAt = 0;
int64_t m_startedAt = 0;
ProxyConfig m_cfg;
EmitFn m_emit;
// Live slots, so shutdown can release anyone still waiting.
std::deque<std::weak_ptr<CallSlot>> m_pending;
};
+197
View File
@@ -0,0 +1,197 @@
#include "verified_proxy_impl.h"
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <system_error>
#include "proxy_config.h"
#include "proxy_runtime.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.
// #include "logos_sdk.h"
namespace fs = std::filesystem;
using json = nlohmann::json;
#ifndef VERIFIED_PROXY_MODULE_VERSION
#define VERIFIED_PROXY_MODULE_VERSION "0.0.0-dev"
#endif
// Stamped by the flake (preConfigure writes the header) so status() and
// libraryVersion() can name the exact upstream build — the library exposes no
// version symbol of its own. Guarded so a plain cmake build still works.
#if defined(__has_include)
# if __has_include("verified_proxy_nimbus_rev.h")
# include "verified_proxy_nimbus_rev.h"
# endif
#endif
#ifndef VERIFIED_PROXY_NIMBUS_REV
#define VERIFIED_PROXY_NIMBUS_REV "unknown"
#endif
namespace {
/// Reads VERIFIED_PROXY_MODULE_CONFIG: inline JSON when the first non-space
/// character is '{', otherwise a path to a JSON file. Returns "" when unset or
/// unreadable. Mirrors logos-libp2p-module's documented deploy-time channel.
std::string readEnvConfig() {
const char* raw = std::getenv("VERIFIED_PROXY_MODULE_CONFIG");
if (!raw || !*raw) return {};
std::string v(raw);
const auto first = v.find_first_not_of(" \t\r\n");
if (first != std::string::npos && v[first] == '{') return v;
std::ifstream f(v);
if (!f) return {};
std::ostringstream ss; ss << f.rdbuf();
return ss.str();
}
std::string readFile(const fs::path& p) {
std::ifstream f(p);
if (!f) return {};
std::ostringstream ss; ss << f.rdbuf();
return ss.str();
}
} // namespace
// ---------------------------------------------------------------------------
VerifiedProxyImpl::VerifiedProxyImpl() {
// Route the runtime's events onto the generated typed emitters. Safe from
// any thread: emitEventImpl_ is marshalled by the host, and it is a no-op
// outside a framework-provisioned context (unit tests).
m_rt = std::make_unique<ProxyRuntime>(
[this](const std::string& name, const std::string& payload) {
if (name == "proxyStarted") proxyStarted(payload);
else if (name == "proxyStopped") proxyStopped(payload);
else if (name == "proxyStateChanged") proxyStateChanged(payload);
});
}
VerifiedProxyImpl::~VerifiedProxyImpl() = default;
void VerifiedProxyImpl::onContextReady() {
// Deploy-time config first, then the persisted one (which wins, since it is
// what a user set through configure()).
if (const std::string envCfg = readEnvConfig(); !envCfg.empty()) {
try { configure(json::parse(envCfg)); } catch (const std::exception&) {}
}
if (instancePersistencePath().empty()) return;
const fs::path p = fs::path(instancePersistencePath()) / "config.json";
const std::string text = readFile(p);
if (text.empty()) return;
try {
const json j = json::parse(text);
if (configure(j).success && j.value("autoStart", false)) start();
} catch (const std::exception&) {
// A corrupt persisted config must not stop the module from loading;
// configure() can still be called with a good one.
}
}
// ── Configuration ───────────────────────────────────────────────────────────
StdLogosResult VerifiedProxyImpl::configure(const LogosMap& config) {
ProxyConfig cfg;
std::string err;
if (!ProxyConfig::fromJson(config, cfg, err))
return { false, {}, err };
if (m_rt->running())
return { false, {}, "cannot reconfigure while the proxy is running; call stop() first" };
m_cfg = std::make_unique<ProxyConfig>(cfg);
m_configured = true;
if (!instancePersistencePath().empty()) {
std::error_code ec;
fs::create_directories(instancePersistencePath(), ec);
std::ofstream f(fs::path(instancePersistencePath()) / "config.json");
// Persist the ORIGINAL, not the redacted view — this file is the
// module's private state under a host-owned directory, and a redacted
// copy would be useless on restart.
if (f) f << config.dump(2);
}
return { true, {}, "" };
}
LogosMap VerifiedProxyImpl::getConfig() {
if (!m_configured || !m_cfg) return json::object();
return m_cfg->redacted();
}
// ── Lifecycle ───────────────────────────────────────────────────────────────
StdLogosResult VerifiedProxyImpl::start() {
if (!m_configured)
return { false, {}, "not configured — call configure() first" };
return m_rt->start(*m_cfg);
}
StdLogosResult VerifiedProxyImpl::stop() { return m_rt->stop(); }
bool VerifiedProxyImpl::ok() { return m_rt->running(); }
LogosMap VerifiedProxyImpl::status() {
json s = m_rt->statusSnapshot();
if (!m_configured) s["state"] = "uninitialized";
else if (s["state"] == "uninitialized") s["state"] = "configured";
s["moduleVersion"] = VERIFIED_PROXY_MODULE_VERSION;
s["libraryVersion"] = VERIFIED_PROXY_NIMBUS_REV;
return s;
}
std::string VerifiedProxyImpl::moduleVersion() { return VERIFIED_PROXY_MODULE_VERSION; }
std::string VerifiedProxyImpl::libraryVersion() { return VERIFIED_PROXY_NIMBUS_REV; }
// ── Verified JSON-RPC ───────────────────────────────────────────────────────
//
// Every one of these is three lines over the same dispatch path: the library's
// `proxyCall` is a string `case` over the very procs its typed C entry points
// call, so there is one FFI path rather than sixty.
StdLogosResult VerifiedProxyImpl::rpc(const std::string& method, const LogosList& params) {
return m_rt->call(method, params.is_null() ? json::array() : params);
}
StdLogosResult VerifiedProxyImpl::ethBlockNumber() {
return m_rt->call("eth_blockNumber", json::array());
}
StdLogosResult VerifiedProxyImpl::ethChainId() {
return m_rt->call("eth_chainId", json::array());
}
StdLogosResult VerifiedProxyImpl::ethGetBalance(const std::string& address,
const std::string& blockTag) {
return m_rt->call("eth_getBalance", json::array({ address, blockTag }));
}
StdLogosResult VerifiedProxyImpl::ethGetCode(const std::string& address,
const std::string& blockTag) {
return m_rt->call("eth_getCode", json::array({ address, blockTag }));
}
StdLogosResult VerifiedProxyImpl::ethGetBlockByNumber(const std::string& blockTag,
bool fullTransactions) {
return m_rt->call("eth_getBlockByNumber", json::array({ blockTag, fullTransactions }));
}
StdLogosResult VerifiedProxyImpl::ethCall(const LogosMap& txArgs,
const std::string& blockTag,
bool optimisticStateFetch) {
// The third positional parameter is upstream's own extension; a standard
// JSON-RPC eth_call has only two.
return m_rt->call("eth_call", json::array({ txArgs, blockTag, optimisticStateFetch }));
}
StdLogosResult VerifiedProxyImpl::ethGetTransactionByBlockNumberAndIndex(
const std::string& blockTag, uint64_t index) {
return m_rt->call("eth_getTransactionByBlockNumberAndIndex",
json::array({ blockTag, index }));
}
+163
View File
@@ -0,0 +1,163 @@
#pragma once
#include <cstdint>
#include <memory>
#include <string>
#include <logos_json.h>
#include <logos_module_context.h>
#include <logos_result.h>
struct ProxyConfig;
class ProxyRuntime;
/// Light-client-verified Ethereum JSON-RPC.
///
/// Wraps status-im's `libverifproxy` (the C library form of
/// nimbus_verified_proxy). Unlike a plain RPC client, every answer is verified
/// against the beacon-chain light client's attested execution state, with
/// Merkle proofs requested from the untrusted provider — so a lying provider
/// produces an error rather than a wrong answer.
///
/// Lifecycle: configure() -> start() -> call methods -> stop().
///
/// All RPC methods are SYNCHRONOUS: they return the verified result, or an
/// error, within `callTimeoutMs`. Consumers that want concurrency use the
/// generated `<method>Async` twin on their side; this module is
/// `concurrency: "multi"`, so blocked callers do not stall each other.
class VerifiedProxyImpl : public LogosModuleContext {
public:
VerifiedProxyImpl();
~VerifiedProxyImpl();
// ── Configuration ────────────────────────────────────────────────────
/// Validate and store the proxy configuration. Synchronous; starts nothing.
///
/// Required: `trustedBlockRoot` (0x + 64 hex), `executionApiUrls` and
/// `beaconApiUrls` (arrays of http/https/ws/wss URLs). The provider must
/// support `eth_getProof`.
///
/// `network` is one of mainnet, sepolia, hoodi — enforced here, because an
/// unrecognised value reaches a `quit()` inside the library and would take
/// the whole host process down. `logLevel` is whitelisted for the same
/// reason. OP-Stack L2 is enabled by setting `opExecutionApiUrls`.
///
/// @code{.json}
/// {
/// "network": "mainnet",
/// "trustedBlockRoot": "0x...",
/// "executionApiUrls": ["wss://..."],
/// "beaconApiUrls": ["https://..."],
/// "opExecutionApiUrls": [], "privateTxUrls": [], "archiveUrls": [],
/// "logLevel": "INFO", "logFormat": "Json",
/// "tuning": { "maxBlockWalk": 1000, "headerStoreLen": 256 },
/// "callTimeoutMs": 30000, "startTimeoutMs": 120000,
/// "keepAlive": "interval", "keepAliveIntervalMs": 1000,
/// "maxInFlight": 64, "autoStart": false
/// }
/// @endcode
///
/// Returns success, or a specific message naming the offending field.
StdLogosResult configure(const LogosMap& config);
/// The effective configuration with defaults merged in and provider
/// credentials redacted. Returns an empty object if configure() has not run.
LogosMap getConfig();
// ── Lifecycle ────────────────────────────────────────────────────────
/// Start the proxy and wait for the light client to initialise.
///
/// Blocks up to `startTimeoutMs`. On success the result value carries the
/// chain id. Also emits `proxyStarted`.
StdLogosResult start();
/// Stop the proxy: drain in-flight calls, then release the context.
/// Blocks up to `drainTimeoutMs`. Also emits `proxyStopped`.
StdLogosResult stop();
/// True when the proxy is running and its last heartbeat succeeded.
bool ok();
/// Module and proxy state. Never blocks on the proxy thread.
///
/// @code{.json}
/// {
/// "state": "uninitialized|configured|starting|running|degraded|stopping|stopped|error",
/// "network": string, "chainId": number,
/// "startedAt": number, "uptimeSeconds": number,
/// "head": { "blockNumber": string, "updatedAt": number },
/// "counters": { "callsTotal": number, "callsFailed": number,
/// "callsInFlight": number, "leakedCalls": number,
/// "heartbeatFailures": number },
/// "lastError": string
/// }
/// @endcode
LogosMap status();
/// This module's version, as declared in metadata.json.
std::string moduleVersion();
/// The nimbus-eth1 revision this module was built against.
std::string libraryVersion();
// ── Verified JSON-RPC ────────────────────────────────────────────────
/// Any method the proxy supports, dispatched through the library's own
/// `proxyCall`. `params` is a JSON-RPC params array.
///
/// Note that `eth_call`, `eth_estimateGas` and `eth_createAccessList` take
/// a THIRD positional parameter, `optimisticStateFetch` (a bool) — an
/// upstream extension to the standard JSON-RPC signature. The typed
/// wrappers below supply it for you.
///
/// Returns the decoded result value on success.
StdLogosResult rpc(const std::string& method, const LogosList& params);
/// Current verified head block number, as a hex quantity string.
StdLogosResult ethBlockNumber();
/// The chain id the proxy is configured for, as a hex quantity string.
StdLogosResult ethChainId();
/// Verified account balance in wei, as a hex quantity string.
/// `blockTag` is "latest", "pending", "earliest", or a hex block number.
StdLogosResult ethGetBalance(const std::string& address, const std::string& blockTag);
/// Verified contract code at `address`, as a hex byte string.
StdLogosResult ethGetCode(const std::string& address, const std::string& blockTag);
/// Verified block. `fullTransactions` selects full objects over hashes.
StdLogosResult ethGetBlockByNumber(const std::string& blockTag, bool fullTransactions);
/// Verified `eth_call`. `txArgs` is a transaction object ({to, data, ...}).
/// `optimisticStateFetch` trades a stricter state check for latency.
StdLogosResult ethCall(const LogosMap& txArgs, const std::string& blockTag,
bool optimisticStateFetch);
/// Verified transaction by index within a block.
StdLogosResult ethGetTransactionByBlockNumberAndIndex(const std::string& blockTag,
uint64_t index);
logos_events:
/// Emitted when start() finishes. {"success":bool,"chainId":number,"error":string}
void proxyStarted(const std::string& payload);
/// Emitted when stop() finishes. {"success":bool}
void proxyStopped(const std::string& payload);
/// Emitted on every proxy state transition.
/// {"state":string,"previous":string,"error":string}
void proxyStateChanged(const std::string& payload);
protected:
void onContextReady() override;
private:
// Held by pointer so this header — which the code generator parses as TEXT
// 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;
bool m_configured = false;
};
+33
View File
@@ -0,0 +1,33 @@
cmake_minimum_required(VERSION 3.14)
project(VerifiedProxyModuleTests LANGUAGES CXX)
include(LogosTest)
# 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)
# Unit tests against a MOCKED libverifproxy.
#
# flake.nix sets tests.mockCLibs = [ "verifproxy" ], which stops the test
# derivation from even resolving the real external library — without it every
# CI test run would pay for the whole nimbus/Nim toolchain build.
logos_test(
NAME verified_proxy_module_tests
MODULE_SOURCES
../src/proxy_config.cpp
../src/proxy_runtime.cpp
../src/verified_proxy_impl.cpp
TEST_SOURCES
main.cpp
test_config_validation.cpp
test_proxy_runtime.cpp
verified_proxy_events_test.cpp
MOCK_C_SOURCES
mocks/mock_libverifproxy.cpp
EXTRA_INCLUDES
stubs
.
)
target_compile_definitions(verified_proxy_module_tests PRIVATE
VERIFIED_PROXY_MODULE_VERSION="${VERIFIED_PROXY_MODULE_VERSION}")
+3
View File
@@ -0,0 +1,3 @@
#include <logos_test.h>
LOGOS_TEST_MAIN()
+167
View File
@@ -0,0 +1,167 @@
// Link-time mock of libverifproxy for unit tests.
//
// The essential difference from logos-storage-module's mock_libstorage.cpp:
// that one fires callbacks SYNCHRONOUSLY, so its waitSync never actually
// waits. Ours must not — the whole design under test is "commands cross to a
// proxy thread and completions arrive only from the pump", and a synchronous
// mock would exercise none of it. So completions queue here and are drained
// ONLY by processVerifProxyTasks.
//
// strdup here pairs with free() in freeNimAllocatedString below, which turns a
// missing or doubled release into an ASan/LSan failure instead of an invisible
// production leak.
#include <atomic>
#include <cstdlib>
#include <cstring>
#include <deque>
#include <functional>
#include <mutex>
#include <string>
#include <thread>
#include <unordered_map>
#include <vector>
#include <logos_clib_mock.h>
extern "C" {
#include "lib/verifproxy.h"
}
#include "mock_libverifproxy.h"
namespace {
struct MockCtx {
std::atomic<bool> stop{false};
std::mutex mu;
std::deque<std::function<void()>> completions;
};
MockCtx g_ctx;
// Thread-affinity ledger and a global call-ordering log: the two invariants
// that matter most here and the two LogosCMockStore cannot express.
std::mutex g_obsMu;
std::unordered_map<std::string, std::thread::id> g_threadOf;
std::vector<std::string> g_order;
void observe(const char* fn) {
std::lock_guard<std::mutex> lk(g_obsMu);
g_threadOf.emplace(fn, std::this_thread::get_id());
g_order.emplace_back(fn);
}
void enqueueCompletion(const char* fn, Context* c, CallBackProc cb, void* ud) {
LOGOS_CMOCK_RECORD(fn);
observe(fn);
const int status = LOGOS_CMOCK_RETURN(int, std::string(fn) + "_status");
if (status == mockNeverCompletes()) return; // sentinel: no completion, ever
const char* res = LOGOS_CMOCK_RETURN_STRING(fn);
std::string payload = res ? res : "\"0x0\"";
std::lock_guard<std::mutex> lk(g_ctx.mu);
g_ctx.completions.push_back([c, cb, ud, status, payload] {
cb(c, status, strdup(payload.c_str()), ud);
});
}
} // namespace
// -- test-visible accessors --------------------------------------------------
std::thread::id mockThreadOf(const std::string& fn) {
std::lock_guard<std::mutex> lk(g_obsMu);
auto it = g_threadOf.find(fn);
return it == g_threadOf.end() ? std::thread::id{} : it->second;
}
std::vector<std::string> mockCallOrder() {
std::lock_guard<std::mutex> lk(g_obsMu);
return g_order;
}
void mockReset() {
{
std::lock_guard<std::mutex> lk(g_obsMu);
g_threadOf.clear();
g_order.clear();
}
std::lock_guard<std::mutex> lk(g_ctx.mu);
g_ctx.completions.clear();
g_ctx.stop = false;
}
size_t mockPendingCompletions() {
std::lock_guard<std::mutex> lk(g_ctx.mu);
return g_ctx.completions.size();
}
// -- the mocked C surface ----------------------------------------------------
extern "C" void NimMain(void) {
LOGOS_CMOCK_RECORD("NimMain");
observe("NimMain");
}
extern "C" Context* startVerifProxy(char* /*configJson*/,
ExecutionTransportProc,
BeaconTransportProc) {
LOGOS_CMOCK_RECORD("startVerifProxy");
observe("startVerifProxy");
// Model the BLOCKING prologue so a test can prove start() never runs it on
// the dispatch thread.
if (const int d = LOGOS_CMOCK_RETURN(int, "startVerifProxy_delay_ms"); d > 0)
std::this_thread::sleep_for(std::chrono::milliseconds(d));
if (LOGOS_CMOCK_RETURN(int, "startVerifProxy_fail") != 0) return nullptr;
g_ctx.stop = false;
return reinterpret_cast<Context*>(&g_ctx);
}
extern "C" int processVerifProxyTasks(Context*) {
LOGOS_CMOCK_RECORD("processVerifProxyTasks");
observe("processVerifProxyTasks");
// Mirrors the Nim source: ctx.stop is checked BEFORE polling, so once
// stopped no completion can ever fire.
if (g_ctx.stop) return RET_CANCELLED;
std::function<void()> job;
{
std::lock_guard<std::mutex> lk(g_ctx.mu);
if (!g_ctx.completions.empty()) {
job = std::move(g_ctx.completions.front());
g_ctx.completions.pop_front();
}
}
if (job) job();
return RET_SUCCESS;
}
extern "C" void proxyCall(Context* c, char* name, char* /*params*/,
CallBackProc cb, void* ud) {
// Record the method name too, so tests can assert WHICH RPC was issued
// (the heartbeat in particular).
LOGOS_CMOCK_RECORD(std::string("proxyCall:") + (name ? name : ""));
enqueueCompletion("proxyCall", c, cb, ud);
}
extern "C" void stopVerifProxy(Context*) {
LOGOS_CMOCK_RECORD("stopVerifProxy");
observe("stopVerifProxy");
g_ctx.stop = true;
}
extern "C" void freeContext(Context*) {
LOGOS_CMOCK_RECORD("freeContext");
observe("freeContext");
}
extern "C" void freeNimAllocatedString(char* res) {
LOGOS_CMOCK_RECORD("freeNimAllocatedString");
free(res); // pairs with the strdup above — ASan catches a missed release
}
+21
View File
@@ -0,0 +1,21 @@
#pragma once
#include <cstddef>
#include <string>
#include <thread>
#include <vector>
/// Which thread first called `fn` (default-constructed id if never called).
std::thread::id mockThreadOf(const std::string& fn);
/// Every mocked C entry point, in call order.
std::vector<std::string> mockCallOrder();
/// Clear the ledger and any queued completions between tests.
void mockReset();
/// Completions queued but not yet drained by processVerifProxyTasks.
size_t mockPendingCompletions();
/// Status sentinel meaning "this call never completes" — used to test the
/// timeout path and the joint-ownership CallBox under ASan.
constexpr int mockNeverCompletes() { return 0xDEAD; }
+47
View File
@@ -0,0 +1,47 @@
/*
* Stub of nimbus-eth1's nimbus_verified_proxy/library/verifproxy.h.
*
* Unit tests link mocks/mock_libverifproxy.cpp instead of the real ~100 MB
* archive (flake.nix sets tests.mockCLibs = ["verifproxy"], which keeps the
* upstream build out of the test derivation entirely). Only the declarations
* the module actually uses are reproduced; keep the signatures byte-identical
* to upstream or the mock will not match the real thing.
*/
#ifndef VERIFPROXY_STUB_H
#define VERIFPROXY_STUB_H
#include <stdbool.h>
#include <stddef.h>
#define RET_SUCCESS 0
#define RET_ERROR -1
#define RET_CANCELLED -2
#define RET_DESER_ERROR -3
#ifdef __cplusplus
extern "C" {
#endif
void NimMain(void);
typedef struct Context Context;
typedef void (*CallBackProc)(Context *ctx, int status, char *result, void *userData);
typedef void (*TransportDeliveryCallback)(int status, char *res, void *userData);
typedef void (*ExecutionTransportProc)(Context *ctx, TransportDeliveryCallback cb, void *userData);
typedef void (*BeaconTransportProc)(Context *ctx, TransportDeliveryCallback cb, void *userData);
Context *startVerifProxy(char *configJson,
ExecutionTransportProc executionTransport,
BeaconTransportProc beaconTransport);
void stopVerifProxy(Context *ctx);
void freeContext(Context *ctx);
int processVerifProxyTasks(Context *ctx);
void proxyCall(Context *ctx, char *name, char *params, CallBackProc cb, void *userData);
void freeNimAllocatedString(char *res);
#ifdef __cplusplus
}
#endif
#endif /* VERIFPROXY_STUB_H */
+187
View File
@@ -0,0 +1,187 @@
// Configuration validation.
//
// These are the cheapest and highest-value tests in the suite: pure C++, no
// mock, no threads — and two of them guard a path that would otherwise take
// down the whole HOST process, because `startVerifProxy` reaches a Nim `quit()`
// for an unrecognised network or log level.
#include <logos_test.h>
#include <nlohmann/json.hpp>
#include "proxy_config.h"
using json = nlohmann::json;
namespace {
json baseConfig() {
return json{
{ "network", "sepolia" },
{ "trustedBlockRoot", "0x" + std::string(64, 'a') },
{ "executionApiUrls", json::array({ "wss://eth.example/v2/secret-key" }) },
{ "beaconApiUrls", json::array({ "https://beaconstate.info" }) },
};
}
bool accepts(const json& j, std::string& err) {
ProxyConfig c;
return ProxyConfig::fromJson(j, c, err);
}
json withField(const char* key, const json& value) {
json j = baseConfig();
j[key] = value;
return j;
}
} // namespace
LOGOS_TEST(config_accepts_a_minimal_valid_document) {
std::string err;
LOGOS_ASSERT_TRUE(accepts(baseConfig(), err));
LOGOS_ASSERT_TRUE(err.empty());
}
// --- the two host-killing fields -------------------------------------------
LOGOS_TEST(config_rejects_every_network_outside_the_whitelist) {
// Upstream's getMetadataForNetwork has only mainnet/hoodi/sepolia compiled
// in; anything else falls through to `fatal` + `quit 1`. "holesky" and
// "op-mainnet" are the realistic mistakes — both are real network names
// that simply are not valid for the LIBRARY's JSON config.
for (const char* bad : { "goerli", "holesky", "op-mainnet", "base-mainnet",
"Mainnet", "MAINNET", "" }) {
std::string err;
LOGOS_ASSERT_FALSE(accepts(withField("network", bad), err));
LOGOS_ASSERT_CONTAINS(err, "network");
}
for (const char* good : { "mainnet", "sepolia", "hoodi" }) {
std::string err;
LOGOS_ASSERT_TRUE(accepts(withField("network", good), err));
}
}
LOGOS_TEST(config_rejects_every_log_level_outside_the_whitelist) {
// Nim's updateLogLevel raises ValueError, and setupLogging turns that into
// `quit 1`. Note lowercase "info" is rejected: upstream is case-sensitive.
for (const char* bad : { "verbose", "info", "Silly", "" }) {
std::string err;
LOGOS_ASSERT_FALSE(accepts(withField("logLevel", bad), err));
LOGOS_ASSERT_CONTAINS(err, "logLevel");
}
for (const char* good : { "TRACE", "DEBUG", "INFO", "NOTICE",
"WARN", "ERROR", "FATAL", "NONE" }) {
std::string err;
LOGOS_ASSERT_TRUE(accepts(withField("logLevel", good), err));
}
}
// --- ordinary validation ----------------------------------------------------
LOGOS_TEST(config_requires_a_well_formed_trusted_block_root) {
std::string err;
json noRoot = baseConfig();
noRoot.erase("trustedBlockRoot");
LOGOS_ASSERT_FALSE(accepts(noRoot, err));
LOGOS_ASSERT_FALSE(accepts(withField("trustedBlockRoot", "0xdeadbeef"), err));
LOGOS_ASSERT_FALSE(accepts(withField("trustedBlockRoot", std::string(64, 'a')), err));
LOGOS_ASSERT_FALSE(accepts(withField("trustedBlockRoot", "0x" + std::string(64, 'z')), err));
LOGOS_ASSERT_FALSE(accepts(withField("trustedBlockRoot", 42), err));
}
LOGOS_TEST(config_requires_both_backend_url_lists) {
std::string err;
LOGOS_ASSERT_FALSE(accepts(withField("executionApiUrls", json::array()), err));
LOGOS_ASSERT_CONTAINS(err, "executionApiUrls");
LOGOS_ASSERT_FALSE(accepts(withField("beaconApiUrls", json::array()), err));
LOGOS_ASSERT_CONTAINS(err, "beaconApiUrls");
}
LOGOS_TEST(config_rejects_url_schemes_upstream_would_reject) {
std::string err;
for (const char* bad : { "ftp://x", "file:///etc/passwd", "eth.example", "" }) {
LOGOS_ASSERT_FALSE(accepts(withField("beaconApiUrls", json::array({ bad })), err));
}
for (const char* good : { "http://a", "https://a", "ws://a", "wss://a" }) {
LOGOS_ASSERT_TRUE(accepts(withField("beaconApiUrls", json::array({ good })), err));
}
}
LOGOS_TEST(config_rejects_a_comma_inside_a_single_url) {
// Upstream's format is one comma-separated string, so a comma in an entry
// would silently become two URLs after we join. Catch it while the caller
// can still see which entry is wrong.
std::string err;
LOGOS_ASSERT_FALSE(
accepts(withField("executionApiUrls", json::array({ "https://a,https://b" })), err));
LOGOS_ASSERT_CONTAINS(err, "comma");
}
LOGOS_TEST(config_accepts_upstreams_own_comma_separated_spelling) {
// A caller pasting the upstream shape should not be punished for it.
std::string err;
ProxyConfig c;
LOGOS_ASSERT_TRUE(ProxyConfig::fromJson(
withField("executionApiUrls", "https://a,https://b"), c, err));
LOGOS_ASSERT_EQ(c.executionApiUrls.size(), static_cast<size_t>(2));
}
LOGOS_TEST(config_rejects_nonsensical_module_knobs) {
std::string err;
LOGOS_ASSERT_FALSE(accepts(withField("callTimeoutMs", 0), err));
LOGOS_ASSERT_FALSE(accepts(withField("startTimeoutMs", -1), err));
LOGOS_ASSERT_FALSE(accepts(withField("maxInFlight", 0), err));
LOGOS_ASSERT_FALSE(accepts(withField("keepAlive", "sometimes"), err));
LOGOS_ASSERT_TRUE(accepts(withField("keepAlive", "continuous"), err));
LOGOS_ASSERT_TRUE(accepts(withField("keepAlive", "off"), err));
}
// --- translation to the upstream shape --------------------------------------
LOGOS_TEST(config_translates_url_arrays_to_upstreams_comma_separated_strings) {
ProxyConfig c;
std::string err;
json j = baseConfig();
j["executionApiUrls"] = json::array({ "https://a", "https://b" });
LOGOS_ASSERT_TRUE(ProxyConfig::fromJson(j, c, err));
const json up = json::parse(c.toUpstreamJson());
LOGOS_ASSERT_TRUE(up["executionApiUrls"].is_string());
LOGOS_ASSERT_EQ(up["executionApiUrls"].get<std::string>(), std::string("https://a,https://b"));
// Upstream's key is eth2Network, not `network`.
LOGOS_ASSERT_EQ(up["eth2Network"].get<std::string>(), std::string("sepolia"));
// Module-only knobs must NOT leak into the library's config.
LOGOS_ASSERT_FALSE(up.contains("callTimeoutMs"));
LOGOS_ASSERT_FALSE(up.contains("keepAlive"));
LOGOS_ASSERT_FALSE(up.contains("tuning"));
}
LOGOS_TEST(config_maps_each_network_to_its_chain_id) {
ProxyConfig c;
std::string err;
ProxyConfig::fromJson(withField("network", "mainnet"), c, err);
LOGOS_ASSERT_EQ(c.expectedChainId(), static_cast<int64_t>(1));
ProxyConfig::fromJson(withField("network", "sepolia"), c, err);
LOGOS_ASSERT_EQ(c.expectedChainId(), static_cast<int64_t>(11155111));
ProxyConfig::fromJson(withField("network", "hoodi"), c, err);
LOGOS_ASSERT_EQ(c.expectedChainId(), static_cast<int64_t>(560048));
}
LOGOS_TEST(config_redacts_provider_credentials) {
ProxyConfig c;
std::string err;
json j = baseConfig();
j["executionApiUrls"] = json::array({
"wss://eth-mainnet.g.alchemy.com/v2/SUPER-SECRET",
"https://user:password@node.example/rpc?apikey=SECRET",
});
LOGOS_ASSERT_TRUE(ProxyConfig::fromJson(j, c, err));
const std::string dumped = c.redacted().dump();
LOGOS_ASSERT_FALSE(dumped.find("SUPER-SECRET") != std::string::npos);
LOGOS_ASSERT_FALSE(dumped.find("password") != std::string::npos);
LOGOS_ASSERT_FALSE(dumped.find("apikey=SECRET") != std::string::npos);
// The host must survive, or the redaction is useless for diagnosis.
LOGOS_ASSERT_CONTAINS(dumped, "eth-mainnet.g.alchemy.com");
}
+385
View File
@@ -0,0 +1,385 @@
// ProxyRuntime — the thread, the queue, the pump and the shutdown ordering.
//
// The mock queues completions and drains them ONLY from
// processVerifProxyTasks, so these tests exercise the real cross-thread design
// rather than a synchronous stand-in.
#include <chrono>
#include <thread>
#include <logos_test.h>
#include <nlohmann/json.hpp>
#include "proxy_config.h"
#include "proxy_runtime.h"
#include "mocks/mock_libverifproxy.h"
extern "C" {
#include "lib/verifproxy.h" // RET_* status codes
}
using json = nlohmann::json;
using namespace std::chrono;
namespace {
ProxyConfig testConfig() {
ProxyConfig c;
c.network = "sepolia";
c.trustedBlockRoot = "0x" + std::string(64, 'a');
c.executionApiUrls = { "https://exec.example" };
c.beaconApiUrls = { "https://beacon.example" };
c.callTimeoutMs = 1500;
c.startTimeoutMs = 5000;
c.drainTimeoutMs = 500;
c.pumpIntervalMs = 20;
c.keepAlive = "off"; // most tests do not want heartbeat noise
return c;
}
bool contains(const std::vector<std::string>& v, const std::string& s) {
for (const auto& e : v) if (e == s) return true;
return false;
}
/// Index of the LAST occurrence of `s`, or -1.
int lastIndexOf(const std::vector<std::string>& v, const std::string& s) {
for (int i = static_cast<int>(v.size()) - 1; i >= 0; --i)
if (v[static_cast<size_t>(i)] == s) return i;
return -1;
}
} // namespace
LOGOS_TEST(runtime_start_and_stop_round_trip) {
auto t = LogosTestContext("verified_proxy_module");
mockReset();
ProxyRuntime rt(nullptr);
const auto r = rt.start(testConfig());
LOGOS_ASSERT_TRUE(r.success);
LOGOS_ASSERT_TRUE(rt.running());
const auto s = rt.stop();
LOGOS_ASSERT_TRUE(s.success);
LOGOS_ASSERT_FALSE(rt.running());
}
LOGOS_TEST(runtime_confines_every_c_call_to_one_non_caller_thread) {
// The invariant that rots silently. setupForeignThreadGc /
// tearDownForeignThreadGc are bound to startVerifProxy / stopVerifProxy, so
// start, stop, the pump and every call must share one thread — and it must
// not be the dispatch thread, because startVerifProxy blocks.
auto t = LogosTestContext("verified_proxy_module");
mockReset();
ProxyRuntime rt(nullptr);
LOGOS_ASSERT_TRUE(rt.start(testConfig()).success);
LOGOS_ASSERT_TRUE(rt.call("eth_blockNumber", json::array()).success);
rt.stop();
const auto proxyThread = mockThreadOf("startVerifProxy");
LOGOS_ASSERT_TRUE(proxyThread != std::thread::id{});
LOGOS_ASSERT_TRUE(proxyThread != std::this_thread::get_id());
// These are called on every cycle, so they must be recorded AND match.
for (const char* fn : { "processVerifProxyTasks", "proxyCall",
"stopVerifProxy", "freeContext" }) {
LOGOS_ASSERT_TRUE(mockThreadOf(fn) != std::thread::id{});
LOGOS_ASSERT_TRUE(mockThreadOf(fn) == proxyThread);
}
// NimMain is once per PROCESS (std::call_once), so if an earlier test in
// this binary already started a proxy it will not have been re-recorded
// after mockReset(). Assert it only when it was actually observed here —
// an unconditional check would make this test order-dependent.
if (const auto nimMainThread = mockThreadOf("NimMain");
nimMainThread != std::thread::id{}) {
LOGOS_ASSERT_TRUE(nimMainThread == proxyThread);
}
}
LOGOS_TEST(runtime_never_calls_NimMain_a_second_time) {
// NimMain is process-global: a second call would re-initialise the Nim
// runtime underneath live GC state.
//
// Assert the DELTA, not the absolute count. std::call_once fires once per
// PROCESS, so whether this test sees 1 or 0 depends on whether an earlier
// test already started a proxy — the invariant that actually matters is
// that a restart adds none.
auto t = LogosTestContext("verified_proxy_module");
mockReset();
{ ProxyRuntime rt(nullptr); rt.start(testConfig()); rt.stop(); }
const int afterFirst = t.cFunctionCallCount("NimMain");
{ ProxyRuntime rt(nullptr); rt.start(testConfig()); rt.stop(); }
const int afterSecond = t.cFunctionCallCount("NimMain");
LOGOS_ASSERT_EQ(afterSecond, afterFirst);
LOGOS_ASSERT_LE(afterFirst, 1);
}
LOGOS_TEST(runtime_runs_the_blocking_prologue_off_the_callers_thread) {
// startVerifProxy blocks for an unbounded prologue. start() may block the
// CALLER — the latch is tripped by a different thread, so nothing starves —
// but it must never run the prologue inline.
auto t = LogosTestContext("verified_proxy_module");
mockReset();
t.mockCFunction("startVerifProxy_delay_ms").returns(250);
ProxyRuntime rt(nullptr);
const auto t0 = steady_clock::now();
const auto r = rt.start(testConfig());
const auto elapsed = duration_cast<milliseconds>(steady_clock::now() - t0);
LOGOS_ASSERT_TRUE(r.success);
LOGOS_ASSERT_GE(elapsed.count(), 200); // we did wait for it
LOGOS_ASSERT_TRUE(mockThreadOf("startVerifProxy") != std::this_thread::get_id());
rt.stop();
}
LOGOS_TEST(runtime_reports_a_null_start_and_refuses_calls_afterwards) {
auto t = LogosTestContext("verified_proxy_module");
mockReset();
t.mockCFunction("startVerifProxy_fail").returns(1);
ProxyRuntime rt(nullptr);
const auto r = rt.start(testConfig());
LOGOS_ASSERT_FALSE(r.success);
// The C API has no error out-param, so the message must say so rather than
// inventing a cause.
LOGOS_ASSERT_CONTAINS(r.error, "NULL");
LOGOS_ASSERT_FALSE(rt.running());
const auto c = rt.call("eth_blockNumber", json::array());
LOGOS_ASSERT_FALSE(c.success);
LOGOS_ASSERT_CONTAINS(c.error, "not running");
LOGOS_ASSERT_FALSE(t.cFunctionCalled("proxyCall"));
}
LOGOS_TEST(runtime_requires_a_pump_turn_to_complete_a_call) {
// Proves the queue really crosses threads: no completion can be delivered
// without processVerifProxyTasks running.
auto t = LogosTestContext("verified_proxy_module");
mockReset();
ProxyRuntime rt(nullptr);
LOGOS_ASSERT_TRUE(rt.start(testConfig()).success);
const int before = t.cFunctionCallCount("processVerifProxyTasks");
LOGOS_ASSERT_TRUE(rt.call("eth_blockNumber", json::array()).success);
const int after = t.cFunctionCallCount("processVerifProxyTasks");
LOGOS_ASSERT_GT(after, before);
rt.stop();
}
LOGOS_TEST(runtime_rejects_a_params_value_that_is_not_an_array) {
// Upstream does parseJson(params).getElems, which silently yields an empty
// list for a non-array — so the caller would get "parameters missing"
// instead of a useful message.
auto t = LogosTestContext("verified_proxy_module");
mockReset();
ProxyRuntime rt(nullptr);
LOGOS_ASSERT_TRUE(rt.start(testConfig()).success);
const auto r = rt.call("eth_getBalance", json::object({ { "a", 1 } }));
LOGOS_ASSERT_FALSE(r.success);
LOGOS_ASSERT_CONTAINS(r.error, "array");
rt.stop();
}
LOGOS_TEST(runtime_decodes_a_bare_json_encoded_result) {
// The library returns Json.encode(value) with no JSON-RPC envelope.
auto t = LogosTestContext("verified_proxy_module");
mockReset();
t.mockCFunction("proxyCall").returns("\"0x10d4f\"");
ProxyRuntime rt(nullptr);
LOGOS_ASSERT_TRUE(rt.start(testConfig()).success);
const auto r = rt.call("eth_blockNumber", json::array());
LOGOS_ASSERT_TRUE(r.success);
LOGOS_ASSERT_TRUE(r.value.is_string());
LOGOS_ASSERT_EQ(r.value.get<std::string>(), std::string("0x10d4f"));
rt.stop();
}
LOGOS_TEST(runtime_tolerates_the_non_json_error_payload) {
// A Result failure yields a RAW "errType: errMsg" string, while a failed
// Future yields a JSON-encoded one. Both must produce a readable error.
auto t = LogosTestContext("verified_proxy_module");
mockReset();
t.mockCFunction("proxyCall_status").returns(RET_ERROR);
t.mockCFunction("proxyCall").returns("VerificationError: unviable fork");
ProxyRuntime rt(nullptr);
LOGOS_ASSERT_TRUE(rt.start(testConfig()).success);
const auto r = rt.call("eth_blockNumber", json::array());
LOGOS_ASSERT_FALSE(r.success);
LOGOS_ASSERT_CONTAINS(r.error, "unviable fork");
rt.stop();
}
LOGOS_TEST(runtime_reports_an_unknown_method_without_dying) {
auto t = LogosTestContext("verified_proxy_module");
mockReset();
t.mockCFunction("proxyCall_status").returns(RET_DESER_ERROR);
t.mockCFunction("proxyCall").returns("unknown method");
ProxyRuntime rt(nullptr);
LOGOS_ASSERT_TRUE(rt.start(testConfig()).success);
const auto r = rt.call("eth_nonsense", json::array());
LOGOS_ASSERT_FALSE(r.success);
LOGOS_ASSERT_CONTAINS(r.error, "unknown method");
rt.stop();
}
LOGOS_TEST(runtime_times_out_safely_when_a_call_never_completes) {
// There is no per-call cancel in the C API, so a stalled future leaves the
// slot live forever. Joint ownership (waiter + CallBox) is what makes a
// late callback harmless; under ASan this test is the proof.
auto t = LogosTestContext("verified_proxy_module");
mockReset();
t.mockCFunction("proxyCall_status").returns(mockNeverCompletes());
ProxyConfig cfg = testConfig();
cfg.callTimeoutMs = 300;
ProxyRuntime rt(nullptr);
LOGOS_ASSERT_TRUE(rt.start(cfg).success);
const auto t0 = steady_clock::now();
const auto r = rt.call("eth_blockNumber", json::array());
const auto elapsed = duration_cast<milliseconds>(steady_clock::now() - t0);
LOGOS_ASSERT_FALSE(r.success);
LOGOS_ASSERT_CONTAINS(r.error, "timed out");
LOGOS_ASSERT_GE(elapsed.count(), 250);
rt.stop(); // must not crash on the abandoned slot
}
LOGOS_TEST(runtime_rejects_calls_beyond_the_in_flight_ceiling) {
// concurrency:"multi" spawns a QThread PER CALL, not a bounded pool, so
// without admission control a runaway caller becomes an OOM rather than an
// error string.
auto t = LogosTestContext("verified_proxy_module");
mockReset();
t.mockCFunction("proxyCall_status").returns(mockNeverCompletes());
ProxyConfig cfg = testConfig();
cfg.maxInFlight = 2;
cfg.callTimeoutMs = 400;
ProxyRuntime rt(nullptr);
LOGOS_ASSERT_TRUE(rt.start(cfg).success);
std::vector<std::thread> hold;
for (int i = 0; i < 2; ++i)
hold.emplace_back([&rt] { rt.call("eth_blockNumber", json::array()); });
// Give the pump time to dispatch both and raise m_inFlight.
std::this_thread::sleep_for(milliseconds(150));
const auto r = rt.call("eth_blockNumber", json::array());
LOGOS_ASSERT_FALSE(r.success);
LOGOS_ASSERT_CONTAINS(r.error, "in flight");
for (auto& th : hold) th.join();
rt.stop();
}
LOGOS_TEST(runtime_drains_before_stopping_and_frees_the_context_last) {
// stopVerifProxy sets ctx.stop, and processVerifProxyTasks checks it BEFORE
// polling — so anything still in flight when we stop can never complete.
// Draining first is therefore load-bearing, not tidiness.
auto t = LogosTestContext("verified_proxy_module");
mockReset();
ProxyRuntime rt(nullptr);
LOGOS_ASSERT_TRUE(rt.start(testConfig()).success);
LOGOS_ASSERT_TRUE(rt.call("eth_blockNumber", json::array()).success);
rt.stop();
const auto order = mockCallOrder();
LOGOS_ASSERT_TRUE(contains(order, "stopVerifProxy"));
LOGOS_ASSERT_TRUE(contains(order, "freeContext"));
const int lastPump = lastIndexOf(order, "processVerifProxyTasks");
const int stopAt = lastIndexOf(order, "stopVerifProxy");
const int freeAt = lastIndexOf(order, "freeContext");
LOGOS_ASSERT_LT(lastPump, stopAt); // drained before stopping
LOGOS_ASSERT_LT(stopAt, freeAt); // freed only after stopping
LOGOS_ASSERT_EQ(freeAt, static_cast<int>(order.size()) - 1);
}
LOGOS_TEST(runtime_releases_a_blocked_caller_when_the_proxy_stops) {
auto t = LogosTestContext("verified_proxy_module");
mockReset();
t.mockCFunction("proxyCall_status").returns(mockNeverCompletes());
ProxyConfig cfg = testConfig();
cfg.callTimeoutMs = 10000; // far longer than the test would tolerate
cfg.drainTimeoutMs = 200;
ProxyRuntime rt(nullptr);
LOGOS_ASSERT_TRUE(rt.start(cfg).success);
StdLogosResult captured;
std::thread caller([&] { captured = rt.call("eth_blockNumber", json::array()); });
std::this_thread::sleep_for(milliseconds(150));
const auto t0 = steady_clock::now();
rt.stop();
caller.join();
const auto elapsed = duration_cast<milliseconds>(steady_clock::now() - t0);
LOGOS_ASSERT_FALSE(captured.success);
LOGOS_ASSERT_CONTAINS(captured.error, "shutting down");
LOGOS_ASSERT_LT(elapsed.count(), 5000); // nobody waits out callTimeoutMs
}
LOGOS_TEST(runtime_pump_does_not_busy_spin_while_idle) {
// The one CPU assertion stable enough for CI: bounded, not 10^6.
auto t = LogosTestContext("verified_proxy_module");
mockReset();
ProxyConfig cfg = testConfig();
cfg.pumpIntervalMs = 50;
ProxyRuntime rt(nullptr);
LOGOS_ASSERT_TRUE(rt.start(cfg).success);
std::this_thread::sleep_for(milliseconds(500));
const int pumps = t.cFunctionCallCount("processVerifProxyTasks");
rt.stop();
LOGOS_ASSERT_GT(pumps, 2);
LOGOS_ASSERT_LT(pumps, 100);
}
LOGOS_TEST(runtime_heartbeat_issues_eth_syncing_only_when_enabled) {
// processVerifProxyTasks only poll()s while pendingCalls > 0, so an idle
// proxy does not advance its light client at all. eth_syncing is the
// cheapest keep-alive: it drives beaconSync() and touches no execution
// backend.
{
auto t = LogosTestContext("verified_proxy_module");
mockReset();
ProxyConfig cfg = testConfig();
cfg.keepAlive = "interval";
cfg.keepAliveIntervalMs = 100;
ProxyRuntime rt(nullptr);
LOGOS_ASSERT_TRUE(rt.start(cfg).success);
std::this_thread::sleep_for(milliseconds(600));
rt.stop();
LOGOS_ASSERT_GT(t.cFunctionCallCount("proxyCall:eth_syncing"), 1);
}
{
auto t = LogosTestContext("verified_proxy_module");
mockReset();
ProxyRuntime rt(nullptr);
LOGOS_ASSERT_TRUE(rt.start(testConfig()).success); // keepAlive "off"
std::this_thread::sleep_for(milliseconds(400));
rt.stop();
LOGOS_ASSERT_EQ(t.cFunctionCallCount("proxyCall:eth_syncing"), 0);
}
}
+15
View File
@@ -0,0 +1,15 @@
// Test bodies for VerifiedProxyImpl's `logos_events:` methods.
//
// Production builds get these from the codegen-emitted
// `verified_proxy_module_events_cdylib.cpp`, which marshals through the host;
// unit tests construct the impl directly without that layer, so they link
// these one-line forwarders instead. Observe with logos_test::EventCapture.
#include <logos_test.h>
#include "verified_proxy_impl.h"
using logos_test::recordEvent;
void VerifiedProxyImpl::proxyStarted(const std::string& payload) { recordEvent("proxyStarted", payload); }
void VerifiedProxyImpl::proxyStopped(const std::string& payload) { recordEvent("proxyStopped", payload); }
void VerifiedProxyImpl::proxyStateChanged(const std::string& payload) { recordEvent("proxyStateChanged", payload); }