mirror of
https://github.com/logos-co/logos-verified-proxy-module.git
synced 2026-08-27 04:51:08 +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>
188 lines
7.5 KiB
C++
188 lines
7.5 KiB
C++
// 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");
|
|
}
|