Files
logos-verified-proxy-module/doctests/verified-proxy-runtime.test.yaml
Dario Gabriel LipicarandClaude Opus 5 c4728671f9 test: an executable tutorial that verifies against live Sepolia
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>
2026-08-26 23:17:02 -03:00

310 lines
14 KiB
YAML

name: "Running a Light-Client-Verified Ethereum Proxy"
output: verified-proxy-runtime.md
release: ""
intro: |
In this tutorial you will run a **trust-minimised** Ethereum RPC endpoint from
source.
Ordinary RPC asks a provider for an answer and believes it. This module runs
[nimbus's verified proxy](https://github.com/status-im/nimbus-eth1/tree/master/nimbus_verified_proxy)
instead: 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 provider it does not trust.
You will:
1. Build the Logos runtime (`logoscore`) and the local package manager (`lgpm`).
2. Build **this** module as an installable `.lgx` package and install it.
3. Start the daemon, load the module, and introspect its 60-method surface.
4. Fetch a finalized block root, configure the proxy with **two fields**, and
start it — watching the light client bootstrap against a live network.
5. Make verified calls and compare one against the untrusted provider directly,
to see the light client's lag for yourself.
This is an **executable tutorial**: it runs automatically on every module
change, against the live Sepolia network.
what_you_build: "A running, light-client-verified Ethereum RPC proxy on Sepolia — configured, started, queried, and stopped through the `logoscore` CLI."
what_you_learn:
- How to build the Logos runtime and the `lgpm` package manager from source
- How a module's Nix flake exposes a ready-to-install `.lgx` via its `#lgx` output
- How to start the `logoscore` daemon, load a module, and introspect its methods
- Why a *trusted block root* is the anchor of the whole trust model, and how to get one
- How per-network defaults make a two-field configuration sufficient
- How to make verified `eth_*` calls, and why the verified head lags the provider's
prerequisites:
- |
**Nix** with flakes enabled. Install from [nixos.org](https://nixos.org/download.html), then enable flakes:
```bash
mkdir -p ~/.config/nix
echo 'experimental-features = nix-command flakes' >> ~/.config/nix/nix.conf
```
Verify: `nix flake --help >/dev/null 2>&1 && echo "Flakes enabled"`
- "**A Linux or macOS machine.**"
- "**`jq`** on your `PATH` — used to pull values out of the JSON replies. Verify: `jq --version`"
- "**Network access.** This tutorial syncs a real light client against Sepolia."
sections:
- title: "Build the Logos daemon"
step: true
text: |
Build the Logos runtime CLI from its published flake. `logoscore` is the
headless frontend for [`logos-liblogos`](https://github.com/logos-co/logos-liblogos);
it runs as a daemon and brings in the whole module-runtime stack.
steps:
- title: "Build the CLI"
run: "nix build 'github:logos-co/logos-logoscore-cli' --out-link ./logos"
code_block: |
nix build 'github:logos-co/logos-logoscore-cli' --out-link ./logos
check_file: "logos/bin/logoscore"
post_text: |
The build produces `logos/bin/logoscore` plus a `logos/modules/`
directory containing the built-in `capability_module`, which the auth
handshake needs when loading modules.
- title: "Build the lgpm package manager"
step: true
text: |
`lgpm` installs `.lgx` packages into a modules directory and scans what is
installed.
steps:
- title: "Build lgpm"
run: "nix build 'github:logos-co/logos-package-manager#cli' -o lgpm"
check_file: "lgpm/bin/lgpm"
- title: "Build and install this module"
step: true
text: |
Build **this** module's `.lgx` straight from its flake's `#lgx` output.
Be warned that the first build is genuinely slow: the module links
`libverifproxy`, which is a source build of nimbus's Nim toolchain. The
Logos binary cache usually has it already.
steps:
- title: "Build the module's .lgx"
run: "nix build 'github:logos-co/logos-verified-proxy-module{release}#lgx' -o proxy-lgx"
code_block: |
# From inside the clone this is simply: nix build '.#lgx'
nix build 'github:logos-co/logos-verified-proxy-module{release}#lgx' -o proxy-lgx
post_text: "The `.lgx` package is now under `./proxy-lgx/`:"
extra_run:
run: "ls proxy-lgx/*.lgx"
- title: "Install the .lgx with lgpm"
text: |
`verified_proxy_module` is a `core` module, so it goes to
`--modules-dir`. The package is unsigned (a local dev build), so we
pass `--allow-unsigned`.
run: "./lgpm/bin/lgpm --modules-dir ./modules --allow-unsigned install --file proxy-lgx/*.lgx"
expect_contains:
- "Installed to:"
- title: "Confirm the install"
run: "./lgpm/bin/lgpm --modules-dir ./modules list"
expect_contains:
- "verified_proxy_module"
check_file: "modules/verified_proxy_module/manifest.json"
- title: "Start the daemon and load the module"
step: true
text: |
Start `logoscore` in daemon mode pointed at `./modules`. Daemon output goes
to `logs.txt` — worth watching, because the light client's own progress is
logged there and nowhere else.
steps:
- title: "Start the daemon"
run: "sh -c './logos/bin/logoscore -D -m ./modules > logs.txt 2>&1 &'"
code_block: "logoscore -D -m ./modules > logs.txt &"
- run: "sleep 3"
- title: "Load the module"
run: "./logos/bin/logoscore load-module verified_proxy_module"
code_block: "logoscore load-module verified_proxy_module"
expect_contains:
- "verified_proxy_module"
- title: "Introspect the method surface"
text: |
The module mirrors the library's whole dispatch table: 30 `eth*`
methods and 30 `op*` mirrors, plus configuration and lifecycle. They
are generated from the library's own table rather than hand-written.
run: "./logos/bin/logoscore module-info verified_proxy_module"
code_block: "logoscore module-info verified_proxy_module"
expect_contains:
- "verified_proxy_module"
- "ethBlockNumber"
- "ethGetBalance"
- "supportedNetworks"
- title: "Configure the proxy"
step: true
text: |
The proxy needs to know which chain to verify, where to reach a beacon and
an execution endpoint, and — critically — a **trusted block root**.
steps:
- title: "See which networks are supported"
text: |
Each entry carries its chain id and a default endpoint pair. Build a UI
selector from this rather than hardcoding a list: an unrecognised
`network` reaches a `quit()` inside the Nim library and would take the
whole host process down.
run: "./logos/bin/logoscore call verified_proxy_module supportedNetworks"
code_block: "logoscore call verified_proxy_module supportedNetworks"
expect_contains:
- "sepolia"
- "mainnet"
- "hoodi"
- title: "Look at a default configuration"
text: |
`defaultConfig` returns a complete, ready-to-edit template for a
network. Note that `trustedBlockRoot` is deliberately **empty** — it
anchors the entire trust model, so it is the one field that cannot be
defaulted.
run: "./logos/bin/logoscore call verified_proxy_module defaultConfig sepolia"
code_block: "logoscore call verified_proxy_module defaultConfig sepolia"
expect_contains:
- "lodestar-sepolia"
- "trustedBlockRoot"
- title: "Fetch a finalized block root"
text: |
The root is where trust enters the system. Here we ask the module to
fetch the current finalized root from a beacon node, which is fine for
getting started — but understand what it is not: a root taken from the
same endpoint you are about to verify against proves nothing. For
anything holding real value, obtain the root from a source you
independently trust and paste it in.
run: "./logos/bin/logoscore call verified_proxy_module fetchFinalizedRoot https://lodestar-sepolia.chainsafe.io"
code_block: |
logoscore call verified_proxy_module fetchFinalizedRoot \
https://lodestar-sepolia.chainsafe.io
expect_contains:
- '"success":true'
- "root"
- title: "Configure with two fields"
text: |
Everything else comes from the network's defaults, so a working
configuration is just the chain and the root:
run: |
ROOT=$(./logos/bin/logoscore call verified_proxy_module fetchFinalizedRoot https://lodestar-sepolia.chainsafe.io | jq -r '.result.value.root')
echo "trusted root: $ROOT"
./logos/bin/logoscore call verified_proxy_module configure "json:{\"network\":\"sepolia\",\"trustedBlockRoot\":\"$ROOT\"}"
code_block: |
ROOT=$(logoscore call verified_proxy_module fetchFinalizedRoot \
https://lodestar-sepolia.chainsafe.io | jq -r '.result.value.root')
logoscore call verified_proxy_module configure \
"json:{\"network\":\"sepolia\",\"trustedBlockRoot\":\"$ROOT\"}"
expect_contains:
- '"success":true'
- title: "Confirm the defaults were filled in"
text: |
The endpoints we never supplied are now set, and the configuration has
been persisted — it will be reloaded automatically next time the module
starts.
run: "./logos/bin/logoscore call verified_proxy_module getConfig"
code_block: "logoscore call verified_proxy_module getConfig"
expect_contains:
- "lodestar-sepolia.chainsafe.io"
- "ethereum-sepolia-rpc.publicnode.com"
- title: "Start the light client and make verified calls"
step: true
text: |
`start` blocks until the light client has bootstrapped from the trusted
root, which takes a little while — it is verifying its way to the chain
head, not just opening a socket.
steps:
- title: "Start the proxy"
run: "./logos/bin/logoscore call verified_proxy_module start"
code_block: "logoscore call verified_proxy_module start"
expect_contains:
- '"success":true'
- "chainId"
- title: "Check the state"
text: |
`running`, with the chain id the light client actually verified against
— not merely the one we asked for.
run: "./logos/bin/logoscore call verified_proxy_module status"
code_block: "logoscore call verified_proxy_module status"
expect_contains:
- '"state":"running"'
- "11155111"
- title: "A verified block number"
run: "./logos/bin/logoscore call verified_proxy_module ethBlockNumber"
code_block: "logoscore call verified_proxy_module ethBlockNumber"
expect_contains:
- '"success":true'
- title: "Compare against the untrusted provider"
text: |
Ask the execution endpoint directly and put the two side by side. The
verified head is normally a couple of blocks **behind**, and that gap
is the point: the proxy reports the head it has proof for, not the one
the provider asserts.
run: |
VERIFIED=$(./logos/bin/logoscore call verified_proxy_module ethBlockNumber | jq -r '.result.value')
PROVIDER=$(curl -s -X POST -H 'Content-Type: application/json' \
--data '{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}' \
https://ethereum-sepolia-rpc.publicnode.com | jq -r '.result')
echo "verified: $VERIFIED"
echo "provider: $((PROVIDER)) ($PROVIDER)"
code_block: |
logoscore call verified_proxy_module ethBlockNumber | jq -r '.result.value'
curl -s -X POST -H 'Content-Type: application/json' \
--data '{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}' \
https://ethereum-sepolia-rpc.publicnode.com | jq -r '.result'
expect_contains:
- "verified:"
- "provider:"
- title: "Some other verified calls"
text: "All of these are proved against the attested state, not taken on trust:"
run: |
./logos/bin/logoscore call verified_proxy_module ethChainId
./logos/bin/logoscore call verified_proxy_module ethGasPrice
./logos/bin/logoscore call verified_proxy_module ethGetBlockTransactionCountByNumber latest
code_block: |
logoscore call verified_proxy_module ethChainId
logoscore call verified_proxy_module ethGasPrice
logoscore call verified_proxy_module ethGetBlockTransactionCountByNumber latest
expect_contains:
- '"success":true'
- title: "Shut down"
step: true
steps:
- title: "Stop the proxy"
text: |
`stop` drains anything in flight before releasing the library's
context, so it is not instantaneous.
run: "./logos/bin/logoscore call verified_proxy_module stop"
code_block: "logoscore call verified_proxy_module stop"
expect_contains:
- '"success":true'
- title: "Stop the daemon"
run: "./logos/bin/logoscore stop"
code_block: "logoscore stop"
- run: "sleep 2"
- title: "Confirm the daemon has stopped"
text: |
With no daemon running the client reports `not_running` and exits
non-zero, so we add `|| true` to let the doc-test assert on the output:
run: "./logos/bin/logoscore status || true"
code_block: "logoscore status"
expect_contains:
- '"status":"not_running"'