Commit Graph
6 Commits
Author SHA1 Message Date
Dario Gabriel LipicarandClaude Opus 5 7702f060a7 docs: stop() is not bounded by drainTimeoutMs, in the places callers read
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>
2026-08-21 09:50:14 -03:00
Dario Gabriel LipicarandClaude Opus 5 1bd466469c feat: report a pump-duration histogram in status()
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>
2026-08-20 23:43:56 -03:00
Dario Gabriel LipicarandClaude Opus 5 5b05f0310e docs: correct three claims the sepolia run contradicted
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>
2026-08-20 23:11:07 -03:00
Dario Gabriel LipicarandClaude Opus 5 37e3c16a04 fix(flake): spell checks explicitly so ws sync-graph sees the tests
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>
2026-08-20 22:58:57 -03:00
Dario Gabriel LipicarandClaude Opus 5 424e6c6912 docs: add README
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>
2026-08-20 22:57:02 -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