4 Commits
Author SHA1 Message Date
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
Dario Gabriel LipicarandClaude Opus 5 e0a2f591d1 feat: default the endpoints in configure(), and expose the stored config
Two additions that let the module be driven from a CLI and let a UI restore a
form from the module rather than keeping its own copy.

configure() now fills either endpoint list from the network table when the
caller omits it, so the smallest useful config is two fields:

  {"network": "mainnet", "trustedBlockRoot": "0x…"}

Only when the key is ABSENT. An explicit [] is the caller saying "no
endpoints", which stays an error — substituting a default for a value someone
deliberately wrote would hide their mistake rather than fix it. An existing
test caught exactly that distinction when the first version got it wrong.
trustedBlockRoot is never defaulted: it anchors the whole trust model, so it
has to be chosen rather than inherited.

defaultConfig(network) returns a complete template, built by round-tripping a
default config through fromJson so it is exactly what configure() would produce
rather than a second, drifting copy of the same defaults.

getConfigUnredacted() returns the stored config with URLs intact. The module
already persisted its config and reloaded it on load; what was missing was a
way to read it back, because getConfig() masks provider URLs — correctly, since
they can carry API keys — and a masked URL cannot repopulate a field. redacted()
and raw() are now one serialiser with a flag, so the two views cannot drift.

Also raises the shared test callTimeoutMs from 1500ms to 15s. Tests that
exercise a timeout set their own short value; the rest only need the call to
complete, and 1500ms made them fail under a parallel nix build rather than
merely run slower — observed once here as a spurious red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 18:05:56 -03:00
Dario Gabriel LipicarandClaude Opus 5 3f22acec91 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>
2026-08-26 17:11:32 -03:00
Dario Gabriel LipicarandClaude Opus 5 421624641a feat: wrap nimbus libverifproxy as a Logos module
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>
2026-08-20 22:54:33 -03:00