feat: one network table, with live-verified default endpoints per chain

The supported-network set was written out in three places — the configure()
whitelist, expectedChainId(), and the panel's hardcoded dropdown model — and
adding per-chain defaults would have made four. They are now one table, exposed
as supportedNetworks() so a UI builds its selector from the module's own
whitelist. That is a safety property, not tidiness: `network` is one of two
config fields whose value reaches a quit() inside Nim when upstream does not
recognise it, so a UI list that drifts from the whitelist kills the host.

The defaults are live-verified, not sourced from documentation. A beacon URL is
only listed if /eth/v1/beacon/light_client/bootstrap/<root> answered 200, and an
execution URL only if eth_getProof returned a result. Both filters matter:
several hosts serve the standard beacon API but 404 the light_client namespace
(Checkpointz instances especially, which answer /eth/v1/node/version and look
healthy), and several long-published RPC URLs are now dead, key-gated or
intermittent.

mainnet and hoodi take drpc for execution because it answered eth_getProof deep
in history where the pruning free tiers refuse anything past ~head-1024. That
distinction is load-bearing here rather than cosmetic: the light client verifies
against its FINALIZED header, which lags the head, so a pruning provider fails
proofs for precisely the blocks this module asks about — and it surfaces as
"distance to target block exceeds maximum proof window" long after start()
reported success. Sepolia stays on publicnode because dRPC gates that chain
behind a paid plan.

Six tests pin the table's invariants: it covers exactly the three networks
upstream compiles in, every entry is accepted by configure(), every chain id is
non-zero (0 is the sentinel that would silently disable the post-start chain
check), lookup rejects a plausible typo, non-empty defaults are well formed and
all-or-nothing, and a profile's defaults are accepted as a real config.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dario Gabriel Lipicar
2026-08-26 17:11:32 -03:00
co-authored by Claude Opus 5
parent 074c2e099c
commit 3f22acec91
8 changed files with 185 additions and 5 deletions
+1
View File
@@ -63,6 +63,7 @@ impossible without it. (Infura notably does not.)
| `start()` | Blocks until the light client initialises, bounded by `startTimeoutMs`. |
| `stop()` | Drains, then releases. See the note on `drainTimeoutMs` below — it is not a tight bound. |
| `ok()` / `status()` | Health probe and full state. `status()` never blocks on the proxy thread. |
| `supportedNetworks()` | The accepted networks with their chain ids and a default endpoint pair. Build a UI selector from this, not a hardcoded list. |
| `fetchFinalizedRoot(beaconUrl)` | Convenience: asks a beacon node for its current finalized root. **Not** a trust anchor — see below. |
| `rpc(method, params)` | Any method the proxy supports. `params` is a JSON-RPC array. |
| `ethBlockNumber()`, `ethGetBalance(...)`, `ethCall(...)`, … | Typed wrappers over the same path. |
+43 -5
View File
@@ -7,12 +7,52 @@
using json = nlohmann::json;
// The one table. Endpoint defaults are LIVE-VERIFIED, not guessed: a beacon
// URL appears here only if `/eth/v1/beacon/light_client/bootstrap/<root>`
// answered 200 (many public beacon nodes serve the standard API but not the
// light_client namespace), and an execution URL only if `eth_getProof`
// returned a result rather than an error.
const std::vector<NetworkProfile>& networkProfiles() {
static const std::vector<NetworkProfile> v{
{ "mainnet", 1,
"https://lodestar-mainnet.chainsafe.io",
// drpc is archive-capable: it answered eth_getProof deep in history,
// where the pruning free tiers refuse anything past ~head-1024. That
// matters here specifically, because the light client verifies
// against its FINALIZED header, which lags the chain head — a pruned
// provider makes state reads fail with "distance to target block
// exceeds maximum proof window" long after start() reported success.
"https://eth.drpc.org" },
{ "sepolia", 11155111,
"https://lodestar-sepolia.chainsafe.io",
// dRPC's sepolia endpoint is behind a paid plan, so this one stays on
// publicnode — the pair a full light-client sync and verified calls
// have actually run against.
"https://ethereum-sepolia-rpc.publicnode.com" },
{ "hoodi", 560048,
"https://lodestar-hoodi.chainsafe.io",
"https://hoodi.drpc.org" },
};
return v;
}
const NetworkProfile* networkProfile(const std::string& name) {
for (const auto& p : networkProfiles())
if (p.name == name) return &p;
return nullptr;
}
namespace {
// Derived from networkProfiles() so the whitelist cannot drift from the table.
// 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" };
static const std::set<std::string> v = [] {
std::set<std::string> out;
for (const auto& p : networkProfiles()) out.insert(p.name);
return out;
}();
return v;
}
@@ -316,8 +356,6 @@ json ProxyConfig::redacted() const {
}
int64_t ProxyConfig::expectedChainId() const {
if (network == "mainnet") return 1;
if (network == "sepolia") return 11155111;
if (network == "hoodi") return 560048;
return 0;
const NetworkProfile* p = networkProfile(network);
return p ? p->chainId : 0;
}
+25
View File
@@ -20,6 +20,31 @@
/// writes to stderr and `quit 1`s.
///
/// So both are whitelisted here, before the value can ever cross the FFI.
/// One supported network: the whitelist entry, its chain id, and a suggested
/// public endpoint pair.
///
/// ONE table, because these three facts must not drift apart. `network` is a
/// value that reaches `quit()` inside Nim when upstream does not recognise it,
/// so the whitelist is a safety control; a chain id missing for an accepted
/// network would silently disable the post-start chain check; and a default
/// endpoint pointing at the wrong chain builds a config that cannot bootstrap.
struct NetworkProfile {
std::string name;
int64_t chainId = 0;
/// Empty when no public endpoint is known to meet the requirements. A
/// beacon endpoint must serve the light-client REST API, and an execution
/// endpoint must support `eth_getProof` — verification is impossible
/// without either, so a plausible-but-unqualified URL is worse than none.
std::string beaconApiUrl;
std::string executionApiUrl;
};
/// The supported networks, in the order a UI should offer them.
const std::vector<NetworkProfile>& networkProfiles();
/// Lookup by name, or nullptr when the network is not supported.
const NetworkProfile* networkProfile(const std::string& name);
struct ProxyConfig {
// ── Required ─────────────────────────────────────────────────────────
std::string network = "mainnet"; // -> eth2Network
+13
View File
@@ -189,6 +189,19 @@ LogosMap VerifiedProxyImpl::status() {
std::string VerifiedProxyImpl::moduleVersion() { return VERIFIED_PROXY_MODULE_VERSION; }
std::string VerifiedProxyImpl::libraryVersion() { return VERIFIED_PROXY_NIMBUS_REV; }
LogosList VerifiedProxyImpl::supportedNetworks() {
LogosList out = json::array();
for (const auto& p : networkProfiles()) {
out.push_back(json{
{ "name", p.name },
{ "chainId", p.chainId },
{ "beaconApiUrl", p.beaconApiUrl },
{ "executionApiUrl", p.executionApiUrl },
});
}
return out;
}
StdLogosResult VerifiedProxyImpl::fetchFinalizedRoot(const std::string& beaconUrl) {
const std::string base = beacon_client::trim(beaconUrl);
if (base.empty()) return { false, {}, "beacon URL is required" };
+19
View File
@@ -128,6 +128,25 @@ public:
/// path the typed methods use.
std::string localEndpoint();
/// The networks this module accepts, each with its chain id and a
/// suggested public endpoint pair.
///
/// A UI should build its network selector from THIS rather than hardcoding
/// a list: `network` is one of exactly two config fields whose value
/// reaches a `quit()` inside the Nim library when upstream does not
/// recognise it, taking the whole host process down, so a UI list that
/// drifts from the module's whitelist is a crash waiting to happen.
///
/// Returns a list of `{"name", "chainId", "beaconApiUrl",
/// "executionApiUrl"}`. The two URLs are a convenience for prefilling a
/// form and may be EMPTY, which means no public endpoint is known to meet
/// the requirements for that network — a beacon endpoint has to serve the
/// light-client REST API and an execution endpoint has to support
/// `eth_getProof`. Empty is deliberate: a plausible URL that cannot
/// actually verify is worse than none, because it fails long after the
/// choice that caused it.
LogosList supportedNetworks();
/// Fetch the current finalized beacon block root from `beaconUrl`.
///
/// A convenience for operators who have no root to hand: it queries
+1
View File
@@ -0,0 +1 @@
Not found
+82
View File
@@ -5,6 +5,9 @@
// 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>
@@ -185,3 +188,82 @@ LOGOS_TEST(config_redacts_provider_credentials) {
// 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(""));
}
}
+1
View File
File diff suppressed because one or more lines are too long