The cross build gets much further than "unproven" suggested. The mingw stdenv,
the `ar` shim that --app:staticlib needs, the vendored nat-libs that branch on
$(OS) and silently take the POSIX path under cross, and Nim compiling most of
the tree all work. It dies in one place:
state_transition_block.nim(1018,32) Error: invalid type:
'typeof(SomeBeaconBlockBody)' in this context
USE_SYSTEM_NIM=1 substitutes the nixpkgs Nim for the one nimbus-build-system
pins, and logos-nix's nixpkgs-windows carries 2.2.4 for every spelling while
nimbus-eth2's sources need 2.2.10 — the version the native build gets, and
compiles cleanly with.
Records the three non-fixes as well, since each looks plausible: a Nim from
another nixpkgs loses the cross wrapper that carries the toolchain config;
dropping USE_SYSTEM_NIM makes nimbus fetch Nim over the network, which the
sandbox forbids and which is why upstream sets the flag; and overriding
nim-unwrapped-2_2 from here cannot work because wrapNim is not exposed.
The fix belongs in logos-nix. Doing it here by importing a second nixpkgs would
put two builds of libstdc++-6.dll in one directory, which resolves by filename.
No Windows CI caller yet, deliberately: adding one now would only add a red
gate that says what this README already says.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds doctests/verified-proxy-runtime.test.yaml and the workflow that runs it:
package this commit as an .lgx, install it, start a logoscore daemon, load and
introspect the module, fetch a trusted root, configure with TWO fields, bootstrap
the light client against live Sepolia, make verified calls, and shut down.
It hits the real network on purpose. A verified proxy that cannot reach a beacon
node and prove its way to the chain head is not doing the one thing it exists
for, and mocking that away would assert nothing worth asserting. The price is
that an endpoint outage turns the job red for reasons outside this repo — hence
a separate workflow from CI, so CI stays meaningful on its own.
Two steps earn their place beyond "it ran": the config check proves the endpoint
defaults were filled in from fields the tutorial never supplied, and the last
call puts the verified head next to the provider's directly, so the couple of
blocks of lag is something the reader SEES rather than something the prose
claims. That gap is the whole point — the proxy reports the head it has proof
for, not the one the provider asserts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Grows the typed surface from 8 methods to 60: all 30 eth_* the library
dispatches, plus the 30 op_* mirrors. Everything was already reachable through
rpc(); what was missing was discoverability — `lm methods`, the LIDL contract,
and a caller's type checker.
Generated, not hand-written. Each wrapper is three lines over the same shared
path, so sixty-one of them by hand is sixty-one chances to transpose an
argument; the table is extracted from the library's OWN dispatch table (the
`case $name` in c_frontend.nim), including each parameter's real type — which
is how ethGetBlockByNumber gets a bool, ethFeeHistory a uint64 and a list, and
eth_call an object rather than everything being a string.
They are still committed as literal text: the module's code generator parses
verified_proxy_impl.h as TEXT to build the LIDL contract, so anything hidden
behind a macro would simply not exist to it. `--check` proves the committed
blocks still match, and fails on a one-character edit (verified).
eth_syncing is deliberately excluded from the typed surface — the runtime
issues it as its own keep-alive and a wrapper would invite callers to fight it.
Still reachable through rpc().
CI is new for this repo, which had none: build on Linux and macOS, unit tests
(the check derivation runs the suite as part of building it), and the codegen
drift check. Named explicitly rather than via `nix flake check`, which would
also evaluate the x86_64-windows pseudo-system.
Verified live on sepolia through a real logoscore daemon: ethGasPrice,
ethMaxPriorityFeePerGas, ethBlobBaseFee, ethGetBlockTransactionCountByNumber,
ethGetUncleCountByBlockNumber and ethGetBlockByNumber all answer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ProxyRuntime is only handed a config by start(), so until then its snapshot
describes a DEFAULT-constructed one — network "mainnet", chainId 1 — regardless
of what configure() was given. A module restored from its persisted sepolia
config therefore reported mainnet, and a panel comparing that against its own
selector warned the operator about a mismatch that did not exist.
The impl's config is the authority whenever it has one, so status() now
overrides network and chainId from it. A test pins both halves: the runtime's
own default before start, and the real config after.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
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>
Stop-then-start segfaulted the module process (signal 11), taking the panel
with it. Reported from live use after switching network, but the network was a
red herring: reproduced deterministically against the real archive with the
config and network held IDENTICAL across both runs — new thread per run exits
139, one persistent thread exits 0 and returns a fresh Context.
NimMain() binds the Nim runtime to the thread that calls it, and this build
compiles NEITHER setupForeignThreadGc NOR tearDownForeignThreadGc: both sites
in verifproxy.nim sit behind `when defined(setupForeignThreadGc)` and nothing
defines it. start() created a std::thread per run and NimMain ran under
std::call_once, so every run after the first executed on a thread with no GC
state at all and died inside startVerifProxy, before it could even return.
The thread is now created lazily on the first start() and ends only in the
destructor; start() and stop() are commands posted to it. stop() waits for the
RUN to finish rather than joining the thread, bounded by drainTimeoutMs plus
headroom. teardown() releases that wait after freeContext, so a following
start() cannot race a half-released Context. Each run also resets the head and
heartbeat-streak state, which otherwise reported the previous chain's head
after a network change.
Three tests: restart reuses the same thread (the invariant that keeps the Nim
runtime alive), four restarts in a row with the lifecycle guards asserted, and
a restart not inheriting the previous run's head.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three fields — head.blockNumber, head.updatedAt and heartbeatFailures — were
read by statusSnapshot() and never assigned, and State::Degraded appeared only
in stateName(). The heartbeat was fire-and-forget with a comment pointing at a
pollHeartbeat() that does not exist, so nothing ever observed its outcome:
status().head stayed "" for the life of the process and a proxy whose sync had
died still reported "running".
CallSlot now carries a Kind, so the trampoline can tell a user call from a
heartbeat or a head probe. Three consecutive heartbeat failures degrade the
proxy and one success clears it; head is refreshed by a separate
eth_blockNumber probe every fifth beat, since eth_syncing answers a hardcoded
`false` and cannot report it. live() joins Running and Degraded for callers
making lifecycle decisions, leaving running() strict for health.
fetchFinalizedRoot() is new, and lives here rather than in the panel because
Basecamp sandboxes ui_qml plugins away from the network: an XMLHttpRequest from
a view is refused outright. It is a convenience, not a trust anchor, and says
so. Adds libcurl, used for that one request and nothing else.
Tests spin on the condition rather than sleeping a fixed interval — the first
draft was green on an idle machine and red under a parallel build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
libverifproxy ships no server: library/verifproxy.nim imports json_rpc_backend
(the client it calls providers with) and the in-process engine/rpc_frontend, but
never json_rpc_frontend — the HTTP/WS server exists only in the standalone
binary, and nm on the archive we link finds zero of its symbols. So the endpoint
is ours.
Opt-in via `httpServer: { enabled, host, port }`, bound to loopback by default
and refusing anything but POST — this answers state queries, so an accidental
0.0.0.0 bind is a different order of mistake than it is for a metrics port.
Uses libmicrohttpd, matching openmetrics-module. Every request is forwarded
through the same proxyCall path the typed methods use, so there is one
verification path and one error shape rather than a second implementation.
Two adaptations are what make "point ethers at it" true rather than nearly true,
and both are measured rather than assumed:
* eth_call / eth_estimateGas / eth_createAccessList (and the op_ twins) take a
THIRD positional parameter upstream, optimisticStateFetch, which the spec does
not have. Stock clients send two and the library answers "parameters missing".
The endpoint appends the default and leaves an explicit third alone. Verified
against sepolia: a 2-param eth_call now reaches the engine and comes back with
a *verification* error, not a parameter one.
* eth_blockNumber answers a bare JSON number where every client expects a hex
QUANTITY (chainId and gasPrice do return hex — upstream is not uniform).
Promoted to hex here only; rpc() and the typed methods still return what the
library produced, so this cannot hide an upstream change from a direct caller.
handleBody() is pure apart from its dispatch callback, so the whole protocol
surface is unit-tested without binding a socket: envelopes, id-type
preservation, batches, notifications, the reserved error codes, and both
adaptations. 44 tests pass.
Verified end to end against sepolia: the endpoint answers
{"id":1,"jsonrpc":"2.0","result":"0xb02f64"} where the upstream provider says
"0xb02f65" — same shape, one block behind, which is what a verifying light
client should look like.
This also re-opens the eth_rpc_module integration the plan had ruled out on the
grounds that no port existed: ChainConfig.endpoint can now point here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit corrected the internal comments but left the overstatement
in the two spots that actually reach a consumer: the `stop()` doc comment —
which the generator copies verbatim into the LIDL contract and `lm methods`
shows — and the README's API table. Both still said stop() "blocks up to
drainTimeoutMs".
It does not. The drain loop checks its deadline BETWEEN calls into the library,
and a single processVerifProxyTasks was measured blocking up to 3.3s, so the
real budget is drainTimeoutMs plus up to one pump duration. Bounded, which is
what keeps the unconditional destructor join safe, but not the tight bound the
name implies.
Also documents that callers blocked in an RPC call are released with
"proxy shutting down" rather than waiting out their own timeout, since that is
the other thing someone reading stop() wants to know.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Experiment 2 from the plan: how long does processVerifProxyTasks actually
block? Three things ride on it — whether the unconditional destructor join can
stall the host, how late a command queued behind a blocked poll() can be picked
up, and whether pumpIntervalMs is sane.
The pump already measured this duration to decide its 1ms backoff, so bucketing
it costs an atomic increment. Exposed as status().pump, which also makes a
stalled pump diagnosable in production rather than only under a profiler.
Measured over 15 minutes against sepolia — 21,510 samples, 358 verified calls:
idle 99.991% <1ms (poll() is not entered when nothing
pends, so pumpIntervalMs paces it)
busy 88.8% <1ms, 98.1% <500ms,
99.94% <2000ms, max 3253ms
Conclusions, now written into the code rather than assumed:
* The join IS safe: the C call returns in bounded time (3.25s worst case), and
the measured stop() was 1102ms.
* Worst-case command-queue latency EQUALS worst-case pump duration (~3.25s),
because drainCommands() runs immediately before the poll. Real, but far under
a 30s callTimeoutMs.
* drainTimeoutMs is a POLLING bound, not a hard one — the drain loop checks its
deadline between pump calls, so stop() can overshoot it by up to one pump
duration. The doc comment said otherwise; corrected.
Also recorded: verified reads have a much fatter tail than plain RPC. The worst
single eth_blockNumber in that run took 12.6s against a 30s default timeout.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ran the module against a real sepolia light client (publicnode execution +
lodestar-sepolia beacon). Three things I had documented from reading the
upstream sources turned out to be wrong, and one is a foot-gun:
1. `keepAlive: "off"` is not "accepts cold starts". Over a 5-minute idle the
reported head went 11532988 -> 11532949 — BACKWARDS 39 blocks — while
"continuous" went 11532988 -> 11533012, i.e. tracked head exactly, and
answered in 0ms rather than 3186ms. A consumer polling block numbers would
see time run backwards, so "off" is now documented as diagnostic-only.
2. `ethBlockNumber` returns a JSON NUMBER, not the hex quantity string the
JSON-RPC spec implies and my doc comment claimed. The encoding is not
uniform: chainId and gasPrice do return hex strings, getBlockByNumber and
eth_syncing return objects.
3. `eth_syncing` does not return a hardcoded `false` — it returns an object
with a syncObject. (It is still the right heartbeat: it drives beaconSync()
and touches no execution backend.)
Also records a real integration limit rather than leaving it to be rediscovered:
state reads resolve their proof against the light client's FINALIZED header,
which lags head, and free public providers refuse with "distance to target block
exceeds maximum proof window". Proof-free reads are unaffected. That is what
`archiveUrls` is for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The value is identical to what `module //` already provided, but
`ws sync-graph` decides dep-graph.nix's hasTests by grepping the flake for a
`checks =` line (scripts/ws:2745). Inheriting it recorded hasTests = false, so
`ws test logos-verified-proxy-module` would have reported the repo as having no
tests while `nix build .#unit-tests` ran 28 of them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Covers the quick start, the API shape, and the three things that are not
guessable from the method list: the provider must support eth_getProof;
eth_call/estimateGas/createAccessList carry a non-standard third positional
parameter; and an idle proxy does not advance its light client, which is why
the keep-alive exists.
Also states why `network` and `logLevel` are whitelisted — they are the two
fields that reach a Nim quit() and would take the host process down.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>