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

logos-verified-proxy-module

Light-client-verified Ethereum JSON-RPC for Logos, wrapping status-im's nimbus_verified_proxy in its C library form (libverifproxy).

An ordinary RPC client forwards a request to a provider and trusts the answer. This module doesn't: it syncs the beacon-chain light client from a trusted block root and verifies every eth_* response against the attested execution state, requesting Merkle proofs from the (untrusted) provider. A provider that lies produces an error, not a wrong value.

No extra process, no local port — the verification runs in-process, and results come back over the normal Logos RPC surface.

Quick start

nix build && lm methods ./result/lib/verified_proxy_module_plugin.so

Get a trusted block root — this is the root of trust, so take it from a source you trust, not from the same provider you are about to verify:

curl -s https://beaconstate.info/eth/v1/beacon/headers/finalized | jq -r '.data.root'

config.json:

{
  "network": "mainnet",
  "trustedBlockRoot": "0x...",
  "executionApiUrls": ["wss://eth-mainnet.example/v2/<key>"],
  "beaconApiUrls":    ["https://beaconstate.info"]
}

Then:

logosctl call verified_proxy_module configure json:@config.json
logosctl call verified_proxy_module start && logosctl call verified_proxy_module status
logosctl call verified_proxy_module ethGetBalance 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 latest

The execution provider must support eth_getProof — verification is impossible without it. (Infura notably does not.)

API

Method Notes
configure(config) Validate and store config. Synchronous; starts nothing.
getConfig() Effective config, credentials redacted.
start() / stop() Blocking, bounded by startTimeoutMs / drainTimeoutMs.
ok() / status() Health probe and full state. status() never blocks on the proxy thread.
rpc(method, params) Any method the proxy supports. params is a JSON-RPC array.
ethBlockNumber(), ethGetBalance(...), ethCall(...), … Typed wrappers over the same path.

Events: proxyStarted, proxyStopped, proxyStateChanged.

All RPC methods are synchronous — they return the verified result, or an error, within callTimeoutMs. Consumers that want concurrency use the generated <method>Async twin on their side; the module is concurrency: "multi", so blocked callers do not stall each other.

rpc() and optimisticStateFetch

eth_call, eth_estimateGas and eth_createAccessList take a third positional parameter, optimisticStateFetch (a bool) — an upstream extension to the standard JSON-RPC signature. The typed wrappers supply it; anything calling rpc() with a hand-built params array must too.

Configuration

Required: trustedBlockRoot (0x + 64 hex), executionApiUrls, beaconApiUrls. network is one of mainnet, sepolia, hoodi. OP-Stack L2 is enabled by setting opExecutionApiUrls (there is no op-* network name in the library's JSON config — that is a CLI-only option on the standalone binary).

Module-side knobs: callTimeoutMs (30000), startTimeoutMs (120000), drainTimeoutMs (2000), pumpIntervalMs (50), maxInFlight (64), keepAlive (off | interval | continuous), keepAliveIntervalMs (1000), autoStart (false). Upstream tuning lives under tuning.

Config is persisted to the host-provided per-instance directory and reloaded on load. VERIFIED_PROXY_MODULE_CONFIG (inline JSON or a path) supplies a deploy-time default.

Two fields are validated for safety, not tidiness

network and logLevel are whitelisted before they can reach the library, because an unrecognised value there reaches a Nim quit() that would terminate the whole host process:

  • an unknown network reaches nimbus-eth2's getMetadataForNetwork, whose fallthrough is fatal + quit 1;
  • a log level Nim's updateLogLevel rejects reaches setupLogging's quit 1.

Neither is validated upstream. Everything else — bad JSON, a missing trustedBlockRoot, a malformed URL — is already caught and turned into a NULL return, so validating it here only improves the error message.

The keep-alive is not optional

processVerifProxyTasks only advances chronos while a call is in flight, so an idle proxy does not advance its light client at all. The heartbeat (keepAlive: "interval", the default) issues eth_syncing, which drives the sync loop and touches no execution backend.

Measured on sepolia over a 5-minute idle:

keepAlive: "off" keepAlive: "continuous"
head at start 11532988 11532988
head after 5 min idle 1153294939 blocks backwards 11533012 (+24, tracking)
latency of that call 3186 ms 0 ms
light-client headers tracked 3 26

"off" does not merely go stale: the reported head regresses, so a consumer polling block numbers sees time run backwards. Treat it as a diagnostic setting, not a deployment option.

Known limitations

  • State reads need a provider with a wide proof window. eth_getBalance, eth_getCode and eth_getTransactionCount resolve their proof against the light client's finalized header, which lags the chain head. Against free public sepolia providers this exceeds their eth_getProof window and the call fails with distance to target block exceeds maximum proof window; the backend is then marked ineligible for GetProof until it recovers. Reads that need no proof (eth_blockNumber, eth_chainId, eth_gasPrice, eth_getBlockByNumber) work fine. Point archiveUrls at a provider that serves historical proofs.
  • Return encodings are not uniform. eth_blockNumber answers a JSON number; eth_chainId and eth_gasPrice answer hex strings; eth_getBlockByNumber and eth_syncing answer objects. There is no JSON-RPC envelope — the value is returned bare.
  • logLevel is validated but inert. The library logs Logging configuration options not enabled in the current build, so the level is not applied at runtime. It still has to be whitelisted, because an invalid value reaches a quit() before that point.
  • Sync observability is limited: there is no exported getter for the finalized/optimistic slot. status().state == "degraded" means "up, but heartbeats are failing", inferred from their error strings.

Development

nix build .#unit-tests && ./result-tests/bin/verified_proxy_module_tests

Unit tests link a mocked libverifproxy (tests.mockCLibs), so they never build the ~25-minute upstream toolchain. Unlike a synchronous mock, it queues completions and drains them only from the pump, so the cross-thread design is actually exercised.

nix build .#libverifproxy   # the upstream archive alone (slow, cached)

Licence

MIT / Apache-2.0, matching the Logos workspace.

S
Description
Light-client-verified Ethereum JSON-RPC for Logos — wraps status-im's nimbus libverifproxy
Readme
379 KiB
Languages
C++ 88.4%
Nix 4.5%
Python 3.6%
CMake 2.7%
C 0.8%