mirror of
https://github.com/logos-co/logos-verified-proxy-module.git
synced 2026-08-27 04:51:08 +00:00
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:
co-authored by
Claude Opus 5
parent
074c2e099c
commit
3f22acec91
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(""));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user