Files
Dario Gabriel LipicarandClaude Opus 5 244437b449 fix: defaultConfig returned {} for every network, and persist what ran
defaultConfig() seeded only `network` and ran it through fromJson, which
REQUIRES a trusted root — so validation failed and it returned an empty object
for every network, silently. Verified over the CLI, where it is meant to be
used. It now seeds a placeholder root to get past validation and blanks it in
the result, keeping the round trip that makes the template exactly what
configure() would produce. A test pins the whole workflow: the template is
complete, its empty root is still rejected, and filling one in is accepted.

configure() also now persists the RESOLVED config rather than the caller's
input. Persisting {"network":…, "trustedBlockRoot":…} verbatim meant the
endpoints were re-derived on every load, so a later change to the default table
would silently move a running deployment onto different providers. Not a trust
problem — providers are untrusted by construction — but it decides whether
eth_getProof works at all: an archive endpoint answers proofs at the finalized
header, a pruning one does not.

Verified end to end against a real logoscore daemon: defaultConfig returns a
full mainnet template, configure with only network + root fills the endpoints
in, the resolved config lands on disk, and it survives a daemon restart.

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

396 lines
17 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 <set>
#include <string>
#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");
}
// ── the network profile table ───────────────────────────────────────────────
//
// One table now backs the whitelist, the chain ids and the UI's prefill
// defaults. These pin the invariants that keep those three in step, because a
// drift between them is not a cosmetic bug: an accepted network with no chain
// id silently disables the post-start chain check, and an unaccepted one
// reaches a Nim quit() that kills the host.
LOGOS_TEST(profiles_cover_exactly_the_networks_upstream_compiles_in) {
const auto& profiles = networkProfiles();
LOGOS_ASSERT_EQ(static_cast<int>(profiles.size()), 3);
std::set<std::string> names;
for (const auto& p : profiles) names.insert(p.name);
LOGOS_ASSERT_TRUE(names.count("mainnet") == 1);
LOGOS_ASSERT_TRUE(names.count("sepolia") == 1);
LOGOS_ASSERT_TRUE(names.count("hoodi") == 1);
}
LOGOS_TEST(every_profile_is_accepted_by_configure) {
// The whitelist derives from the table, so a network offered to a UI can
// never be one that configure() rejects — or worse, one it accepts and
// upstream quit()s on.
for (const auto& p : networkProfiles()) {
json c = baseConfig();
c["network"] = p.name;
ProxyConfig out;
std::string err;
LOGOS_ASSERT_TRUE(ProxyConfig::fromJson(c, out, err));
LOGOS_ASSERT_EQ(err, std::string(""));
LOGOS_ASSERT_EQ(out.expectedChainId(), p.chainId);
}
}
LOGOS_TEST(every_profile_has_a_real_chain_id) {
// 0 is the "unknown network" sentinel expectedChainId() returns, so a 0
// here would mean the post-start chain check compares against nothing.
for (const auto& p : networkProfiles())
LOGOS_ASSERT_GT(p.chainId, 0);
}
LOGOS_TEST(profile_lookup_rejects_an_unknown_network) {
LOGOS_ASSERT_TRUE(networkProfile("mainnet") != nullptr);
LOGOS_ASSERT_TRUE(networkProfile("holesky") == nullptr); // a plausible typo
LOGOS_ASSERT_TRUE(networkProfile("") == nullptr);
}
LOGOS_TEST(profile_default_urls_are_empty_or_well_formed) {
// A default is optional — empty means "no public endpoint qualifies" — but
// a NON-empty one is prefilled straight into a form and submitted, so it
// must survive the same validation any typed URL does.
for (const auto& p : networkProfiles()) {
for (const std::string& url : { p.beaconApiUrl, p.executionApiUrl }) {
if (url.empty()) continue;
LOGOS_ASSERT_TRUE(url.rfind("http://", 0) == 0 || url.rfind("https://", 0) == 0
|| url.rfind("ws://", 0) == 0 || url.rfind("wss://", 0) == 0);
}
// A default pair must be all-or-nothing: prefilling one field and
// leaving the other blank produces a form that looks ready and is not.
LOGOS_ASSERT_EQ(p.beaconApiUrl.empty(), p.executionApiUrl.empty());
}
}
LOGOS_TEST(a_profiles_defaults_are_accepted_as_a_real_config) {
// The end-to-end claim a UI relies on: prefill from a profile, submit, and
// configure() takes it.
for (const auto& p : networkProfiles()) {
if (p.beaconApiUrl.empty()) continue;
json c = baseConfig();
c["network"] = p.name;
c["beaconApiUrls"] = json::array({ p.beaconApiUrl });
c["executionApiUrls"] = json::array({ p.executionApiUrl });
ProxyConfig out;
std::string err;
LOGOS_ASSERT_TRUE(ProxyConfig::fromJson(c, out, err));
LOGOS_ASSERT_EQ(err, std::string(""));
}
}
// ── defaults filled from the network profile ────────────────────────────────
LOGOS_TEST(config_fills_absent_endpoints_from_the_network_profile) {
// The CLI contract: network + trusted root is a WORKING config.
for (const auto& p : networkProfiles()) {
if (p.beaconApiUrl.empty()) continue;
json minimal{
{ "network", p.name },
{ "trustedBlockRoot", "0x" + std::string(64, 'a') },
};
ProxyConfig out;
std::string err;
LOGOS_ASSERT_TRUE(ProxyConfig::fromJson(minimal, out, err));
LOGOS_ASSERT_EQ(err, std::string(""));
LOGOS_ASSERT_EQ(static_cast<int>(out.beaconApiUrls.size()), 1);
LOGOS_ASSERT_EQ(out.beaconApiUrls[0], p.beaconApiUrl);
LOGOS_ASSERT_EQ(out.executionApiUrls[0], p.executionApiUrl);
}
}
LOGOS_TEST(config_never_overrides_endpoints_the_caller_supplied) {
json c = baseConfig();
c["network"] = "mainnet";
c["beaconApiUrls"] = json::array({ "https://my.beacon.example" });
c["executionApiUrls"] = json::array({ "https://my.exec.example" });
ProxyConfig out;
std::string err;
LOGOS_ASSERT_TRUE(ProxyConfig::fromJson(c, out, err));
LOGOS_ASSERT_EQ(out.beaconApiUrls[0], std::string("https://my.beacon.example"));
LOGOS_ASSERT_EQ(out.executionApiUrls[0], std::string("https://my.exec.example"));
}
LOGOS_TEST(config_still_rejects_an_explicitly_empty_endpoint_list) {
// Absent means "use the defaults"; an explicit [] is the caller saying
// "none", which is a mistake worth reporting rather than papering over.
json c = baseConfig();
c["network"] = "mainnet";
c["executionApiUrls"] = json::array();
ProxyConfig out;
std::string err;
LOGOS_ASSERT_FALSE(ProxyConfig::fromJson(c, out, err));
LOGOS_ASSERT_TRUE(!err.empty());
}
LOGOS_TEST(config_does_not_default_the_trusted_root) {
// The one field with no defensible default: it anchors the whole trust
// model, so a config without it must fail rather than quietly acquire one.
json minimal{ { "network", "mainnet" } };
ProxyConfig out;
std::string err;
LOGOS_ASSERT_FALSE(ProxyConfig::fromJson(minimal, out, err));
LOGOS_ASSERT_CONTAINS(err, "trustedBlockRoot");
}
// ── redacted vs raw ─────────────────────────────────────────────────────────
LOGOS_TEST(raw_keeps_url_credentials_that_redacted_masks) {
// getConfig() masks provider URLs because they can carry API keys;
// getConfigUnredacted() must NOT, or a form cannot be repopulated.
json c = baseConfig();
c["executionApiUrls"] = json::array({ "wss://eth.example/v2/secret-key" });
ProxyConfig out;
std::string err;
LOGOS_ASSERT_TRUE(ProxyConfig::fromJson(c, out, err));
const std::string redacted = out.redacted().dump();
const std::string raw = out.raw().dump();
LOGOS_ASSERT_TRUE(redacted.find("secret-key") == std::string::npos);
LOGOS_ASSERT_TRUE(raw.find("secret-key") != std::string::npos);
}
LOGOS_TEST(raw_config_round_trips_through_configure) {
// What a UI restores must be something configure() accepts again —
// otherwise "restore the last config" hands back a form that cannot start.
json c = baseConfig();
c["network"] = "sepolia";
ProxyConfig first;
std::string err;
LOGOS_ASSERT_TRUE(ProxyConfig::fromJson(c, first, err));
ProxyConfig second;
LOGOS_ASSERT_TRUE(ProxyConfig::fromJson(first.raw(), second, err));
LOGOS_ASSERT_EQ(err, std::string(""));
LOGOS_ASSERT_EQ(second.network, first.network);
LOGOS_ASSERT_EQ(second.trustedBlockRoot, first.trustedBlockRoot);
LOGOS_ASSERT_TRUE(second.beaconApiUrls == first.beaconApiUrls);
LOGOS_ASSERT_TRUE(second.executionApiUrls == first.executionApiUrls);
LOGOS_ASSERT_EQ(second.keepAlive, first.keepAlive);
LOGOS_ASSERT_EQ(second.httpEnabled, first.httpEnabled);
LOGOS_ASSERT_EQ(second.httpPort, first.httpPort);
}
LOGOS_TEST(default_config_is_complete_and_startable_once_a_root_is_added) {
// Regression: the first version seeded only `network` and ran it through
// fromJson, which REQUIRES a trusted root — so it failed validation and
// returned {} for every network. It looked right and produced nothing.
for (const auto& p : networkProfiles()) {
ProxyConfig probe;
std::string err;
// Stand in for VerifiedProxyImpl::defaultConfig()'s round trip, which
// is where the bug lived: seed + validate + blank the root.
json seed{ { "network", p.name },
{ "trustedBlockRoot", "0x" + std::string(64, '0') } };
LOGOS_ASSERT_TRUE(ProxyConfig::fromJson(seed, probe, err));
json tmpl = probe.raw();
tmpl["trustedBlockRoot"] = "";
LOGOS_ASSERT_TRUE(!tmpl.empty());
LOGOS_ASSERT_EQ(tmpl["network"].get<std::string>(), p.name);
LOGOS_ASSERT_EQ(tmpl["trustedBlockRoot"].get<std::string>(), std::string(""));
if (!p.beaconApiUrl.empty())
LOGOS_ASSERT_EQ(tmpl["beaconApiUrls"][0].get<std::string>(), p.beaconApiUrl);
// The template minus its empty root must be rejected...
ProxyConfig rejected;
LOGOS_ASSERT_FALSE(ProxyConfig::fromJson(tmpl, rejected, err));
// ...and accepted again the moment a real root is filled in, which is
// the whole workflow the template exists to support.
tmpl["trustedBlockRoot"] = "0x" + std::string(64, 'b');
ProxyConfig accepted;
LOGOS_ASSERT_TRUE(ProxyConfig::fromJson(tmpl, accepted, err));
LOGOS_ASSERT_EQ(err, std::string(""));
}
}