mirror of
https://github.com/logos-co/logos-verified-proxy-module.git
synced 2026-08-27 13:01:09 +00:00
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>
168 lines
5.0 KiB
C++
168 lines
5.0 KiB
C++
// 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
|
|
}
|