Vibing the Ethereum EL and CL Proxy
A Rust proxy that monitors Ethereum Execution Layer (EL) and Consensus Layer (CL) nodes, tracks their health, and routes requests to healthy nodes.
+-------------------+
| TOML Config |
| (nodes, settings) |
+--------+----------+
|
+--------------+--------------+
| |
+--------v--------+ +--------v--------+
| EL Health Mon | | CL Health Mon |
| eth_getBlockNum | | /eth/v1/node/ |
| track chain head| | health + headers|
+--------+--------+ +--------+--------+
| |
v v
+--------+--------+ +--------+--------+
| EL Node Pool | | CL Node Pool |
| healthy/lagging | | healthy/lagging |
+--------+--------+ +--------+--------+
| |
+--------------+--------------+
|
+--------v--------+
| Proxy Server |
| EL HTTP: /el/* |
| EL WS: /el/ws |
| CL HTTP: /cl/* |
+-----------------+
Example config.toml:
[global]
max_el_lag_blocks = 5
max_cl_lag_slots = 3
health_check_interval_ms = 1000
# Primary EL nodes - used first
[[el.primary]]
name = "geth-1"
http_url = "http://localhost:8545"
ws_url = "ws://localhost:8546"
[[el.primary]]
name = "geth-2"
http_url = "http://localhost:8547"
ws_url = "ws://localhost:8548"
# Backup EL nodes - only used when ALL primary nodes are unavailable
[[el.backup]]
name = "alchemy-1"
http_url = "https://eth-mainnet.g.alchemy.com/v2/xxx"
ws_url = "wss://eth-mainnet.g.alchemy.com/v2/xxx"
[[el.backup]]
name = "infura-1"
http_url = "https://mainnet.infura.io/v3/xxx"
ws_url = "wss://mainnet.infura.io/ws/v3/xxx"
[[cl]]
name = "lighthouse-1"
url = "http://localhost:5052"
[[cl]]
name = "prysm-1"
url = "http://localhost:5053"- Call
eth_getBlockNumbervia JSON-RPC → returns block number in hex (e.g.,"0x10d4f") - Parse hex to u64
- Track the highest block number across all EL nodes = "chain head"
- Calculate lag for each node:
chain_head - node_block_number - Node is unhealthy if
lag > MAX_EL_LAG_BLOCKS
EL nodes are split into two lists:
- Primary: Preferred nodes, used under normal operation
- Backup: Fallback nodes, only used when ALL primary nodes are unavailable
Failover logic:
- Try to select from primary nodes first
- If NO primary node is available (all unhealthy), use backup nodes
- Health monitoring runs on BOTH primary and backup nodes continuously
- When a primary node becomes available again, switch back to primary
- Call
GET /eth/v1/node/health→ must return HTTP 200 - Call
GET /eth/v1/beacon/headers/head→ extract slot from/data/header/message/slot - Track the highest slot across all CL nodes = "chain head"
- Calculate lag for each node:
chain_head_slot - node_slot - Node is unhealthy if health != 200 OR
lag > MAX_CL_LAG_SLOTS
TDD Workflow: Tests are written FIRST, then implementation makes them pass.
Phase 1: Setup → Phase 2: Write Tests (RED) → Phase 3-10: Implement (GREEN) → Refactor
IMPORTANT: After completing each phase, update the Progress table in
README.mdto reflect the current status (Not Started → In Progress → Completed).
GIT COMMITS: Commit often with verbose, descriptive messages. Each logical change should be its own commit. Examples:
feat(config): add TOML config parsing with Global and ElNode structstest(el_health): add unit tests for hex block number parsingfeat(health/el): implement eth_getBlockNumber health checkfix(proxy): handle failover edge case when all primary nodes down
DIARY.md: Create and maintain a
DIARY.mdfile as a development log. Update it whenever you:
- Complete a task or phase (what was done, what was learned)
- Encounter hardships or blockers (what went wrong, how it was resolved)
- Make important decisions (why a certain approach was chosen)
- Discover something interesting or unexpected
This log will be used to create a documentary of the development journey. Write in first person, be honest about struggles, and capture the human (or AI) side of building software.
- Add dependencies to Cargo.toml:
tokio(async runtime with full features)axum(HTTP server for proxy)reqwest(HTTP client for health checks)serde+serde_json(JSON parsing)toml(config parsing)tracing+tracing-subscriber(logging)eyreorthiserror(error handling)tokio-tungstenite(WebSocket client for EL WS proxy)futures-util(for stream handling with WebSocket)prometric(Prometheus metrics - https://github.com/chainbound/prometric)
- Add dev-dependencies to Cargo.toml:
cucumber(BDD testing framework)wiremock(mock HTTP server for testing)tokio-test(async test utilities)
- Create minimal stub files so tests can compile (but fail):
src/lib.rs(expose modules)src/config.rs(empty structs)src/state.rs(empty structs)src/health/mod.rs,el.rs,cl.rs(empty functions)src/proxy/mod.rs,selection.rs,http.rs,ws.rs(empty functions)src/monitor.rs(empty function)src/metrics.rs(empty struct)
- Create
justfilefor common development commands:fmt- format codefmt-check- check formatting without modifyingclippy- run clippy lintstest- run unit tests (TDD)test-bdd- run BDD tests (cucumber)test-all- run both TDD and BDD testsci- run full CI checks (fmt-check, clippy, test-all)
- Create GitHub Actions CI workflow
.github/workflows/ci.yml:- Trigger on push/PR to main branch
- Jobs: format check, clippy, unit tests (TDD), BDD tests (cucumber), build
- Use rust caching for faster CI runs
- Integrate Claude code review in CI
.github/workflows/claude-review.yml:- Trigger on pull requests
- Use Claude to review code changes and provide feedback
- Post review comments on the PR
Setup the test infrastructure that will be used throughout development.
- Setup BDD test infrastructure:
- Create
tests/cucumber.rsas test harness - Create
tests/world.rswithVixyWorldstruct:#[derive(Debug, Default, World)] pub struct VixyWorld { pub config: Option<Config>, pub el: Vec<ElNodeState>, pub cl: Vec<ClNodeState>, pub mock_servers: Vec<MockServer>, pub selected_node: Option<String>, pub last_response: Option<Response>, pub last_error: Option<String>, }
- Configure
[[test]]in Cargo.toml for cucumber - Create
tests/steps/mod.rsfor step definitions
- Create
Testing Philosophy:
┌─────────────────────────────────────────────────────────────┐
│ Testing Pyramid │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────┐ │
│ │ BDD │ ← Integration/Acceptance │
│ │ (cucumber)│ (few, slow, valuable) │
│ └───────────┘ │
│ ┌─────────────────────┐ │
│ │ TDD │ ← Unit Tests │
│ │ (cargo test) │ (many, fast, cheap)│
│ └─────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
Each phase follows the TDD cycle:
- Write Tests - Write actual test code (not todo!), create stubs so it compiles
- RED - Run tests, verify they fail (no implementation yet)
- GREEN - Implement code to make tests pass
- REFACTOR - Clean up while keeping tests green
Tests can be improved/modified during implementation as long as they remain robust.
IMPORTANT: Finish writing ALL tests with real assertions before moving to 3.2
- Write BDD feature
tests/features/config.feature(scenarios for valid config, missing fields, etc.) - Write step definitions in
tests/steps/config_steps.rs - Write unit tests in
src/config.rs#[cfg(test)]module:-
test_parse_valid_config -
test_parse_config_missing_el_fails -
test_parse_config_missing_cl_fails -
test_parse_config_invalid_url_fails -
test_default_values_applied
-
- Create stub structs/functions so tests compile (use
unimplemented!()) - Run
cargo test config- verify tests FAIL (RED ✗)
- Implement
src/config.rs:-
Configstruct withGlobal,El,Vec<Cl> -
Globalstruct withmax_el_lag_blocks,max_cl_lag_slots,health_check_interval_ms -
Elstruct withprimary: Vec<ElNode>,backup: Vec<ElNode> -
ElNodestruct withname,http_url,ws_url -
Clstruct withname,url -
Config::load(path)andConfig::from_str(s)to parse TOML
-
- Run
cargo test config- should PASS (GREEN ✓)
IMPORTANT: Finish writing ALL tests with real assertions before moving to 4.2
- Write unit tests in
src/state.rs#[cfg(test)]module:-
test_el_node_state_from_config -
test_cl_node_state_from_config -
test_app_state_initialization -
test_initial_health_is_false
-
- Create stub structs so tests compile
- Run
cargo test state- verify tests FAIL (RED ✗)
- Implement
src/state.rs:-
ElNodeStatestruct (name, urls, block_number, is_healthy, lag) -
ClNodeStatestruct (name, url, slot, health_ok, is_healthy, lag) -
AppStatestruct (Arc<RwLock<Vec<...>>> for nodes, AtomicU64 for chain heads, AtomicBool for failover)
-
- Run
cargo test state- should PASS (GREEN ✓)
IMPORTANT: Finish writing ALL tests with real assertions before moving to 5.2
- Write BDD feature
tests/features/el_health.feature(healthy node, lagging node, unreachable node) - Write step definitions in
tests/steps/el_health_steps.rs - Write unit tests in
src/health/el.rs#[cfg(test)]module:-
test_parse_hex_block_number(with and without 0x prefix) -
test_parse_hex_block_number_invalid -
test_check_el_node_success(use wiremock) -
test_check_el_node_timeout -
test_check_el_node_invalid_response -
test_calculate_el_lag -
test_el_node_healthy_within_lag -
test_el_node_unhealthy_exceeds_lag -
test_update_chain_head_finds_max
-
- Create stub functions so tests compile
- Run
cargo test el- verify tests FAIL (RED ✗)
- Implement
src/health/mod.rs(module definition) - Implement
src/health/el.rs:-
parse_hex_block_number(hex: &str) -> Result<u64> -
check_el_node(url: &str) -> Result<u64> -
update_el_chain_head(nodes: &[ElNodeState]) -> u64 -
calculate_el_health(node: &mut ElNodeState, chain_head: u64, max_lag: u64)
-
- Run
cargo test el- should PASS (GREEN ✓)
IMPORTANT: Finish writing ALL tests with real assertions before moving to 6.2
- Write BDD feature
tests/features/cl_health.feature(healthy, health endpoint fails, lagging) - Write step definitions in
tests/steps/cl_health_steps.rs - Write unit tests in
src/health/cl.rs#[cfg(test)]module:-
test_check_cl_health_returns_true_on_200 -
test_check_cl_health_returns_false_on_503 -
test_check_cl_slot_parses_json -
test_check_cl_slot_invalid_json -
test_calculate_cl_lag -
test_cl_node_unhealthy_when_health_fails -
test_cl_node_unhealthy_when_lagging -
test_cl_node_healthy_when_both_pass
-
- Create stub functions
- Run
cargo test cl- verify tests FAIL (RED ✗)
- Implement
src/health/cl.rs:-
check_cl_health(url: &str) -> Result<bool> -
check_cl_slot(url: &str) -> Result<u64> -
check_cl_node(url: &str) -> Result<(bool, u64)> -
update_cl_chain_head(nodes: &[ClNodeState]) -> u64 -
calculate_cl_health(node: &mut ClNodeState, chain_head: u64, max_lag: u64)
-
- Run
cargo test cl- should PASS (GREEN ✓)
IMPORTANT: Finish writing ALL tests with real assertions before moving to 7.2
- Write unit tests in
src/monitor.rs#[cfg(test)]module:-
test_monitor_updates_el_node_state -
test_monitor_updates_cl_node_state -
test_monitor_calculates_chain_head -
test_monitor_sets_failover_flag -
test_monitor_clears_failover_when_primary_recovers -
test_monitor_runs_at_configured_interval
-
- Create stub functions
- Run
cargo test monitor- verify tests FAIL (RED ✗)
- Implement
src/monitor.rs:-
run_health_monitor(state: AppState)- async loop checking all nodes - Update chain heads, recalculate health, manage failover flag, log changes
-
- Run
cargo test monitor- should PASS (GREEN ✓)
IMPORTANT: Finish writing ALL tests with real assertions before moving to 8.2
- Write BDD features:
-
tests/features/el_failover.feature(primary preference, backup failover, recovery) -
tests/features/proxy_http.feature(forward requests, 503 on no healthy) -
tests/features/proxy_ws.feature(establish connection, bidirectional, reconnect)
-
- Write step definitions in
tests/steps/(failover, proxy) - Write unit tests for
src/proxy/selection.rs:-
test_select_healthy_node_from_list -
test_select_skips_unhealthy_nodes -
test_select_primary_before_backup -
test_select_backup_when_no_primary_available -
test_select_returns_none_when_all_unavailable
-
- Write unit tests for
src/proxy/http.rs:-
test_el_proxy_forwards_request -
test_el_proxy_returns_503_no_healthy_nodes -
test_cl_proxy_forwards_get_request -
test_cl_proxy_preserves_path -
test_proxy_timeout_returns_504
-
- Write unit tests for
src/proxy/ws.rs:-
test_ws_upgrade_success -
test_ws_message_forwarded_upstream -
test_ws_message_forwarded_downstream -
test_ws_client_disconnect_closes_upstream -
test_ws_no_healthy_node_returns_503
-
- Create stub functions
- Run
cargo test proxy- verify tests FAIL (RED ✗)
- Implement
src/proxy/mod.rs - Implement
src/proxy/selection.rs(node selection with failover) - Implement
src/proxy/http.rs(EL and CL HTTP handlers) - Implement
src/proxy/ws.rs(WebSocket upgrade and bidirectional piping) - Run
cargo test proxy- should PASS (GREEN ✓) - Run
cargo test --test cucumber- BDD tests should PASS ✓
- Implement
src/main.rs:- Parse CLI args for config path
- Load config, initialize AppState and metrics
- Spawn health monitor, start axum server with routes +
/metrics - Add graceful shutdown handling
- Run
cargo build- should compile ✓
IMPORTANT: Finish writing ALL tests with real assertions before moving to 10.2
- Write unit tests in
src/metrics.rs#[cfg(test)]module:-
test_metrics_initialization -
test_el_request_counter_increments -
test_gauge_updates
-
- Create stub struct
- Run
cargo test metrics- verify tests FAIL (RED ✗)
- Implement
src/metrics.rs:- Define
VixyMetricsstruct using#[metrics(scope = "vixy")]:#[metrics(scope = "vixy")] pub struct VixyMetrics { // EL metrics #[metric(rename = "el_requests_total", labels = ["node", "tier"])] el_requests: Counter, // tier = "primary" | "backup" #[metric(rename = "el_request_duration_seconds", labels = ["node", "tier"])] el_request_duration: Histogram, #[metric(rename = "el_node_block_number", labels = ["node", "tier"])] el_block_number: Gauge, #[metric(rename = "el_node_lag_blocks", labels = ["node", "tier"])] el_lag: Gauge, #[metric(rename = "el_node_healthy", labels = ["node", "tier"])] el_healthy: Gauge, // 1 = healthy, 0 = unhealthy #[metric(rename = "el_failover_total")] el_failovers: Counter, // times switched to backup // CL metrics #[metric(rename = "cl_requests_total", labels = ["node"])] cl_requests: Counter, #[metric(rename = "cl_request_duration_seconds", labels = ["node"])] cl_request_duration: Histogram, #[metric(rename = "cl_node_slot", labels = ["node"])] cl_slot: Gauge, #[metric(rename = "cl_node_lag_slots", labels = ["node"])] cl_lag: Gauge, #[metric(rename = "cl_node_healthy", labels = ["node"])] cl_healthy: Gauge, // WebSocket metrics #[metric(rename = "ws_connections_active")] ws_connections: Gauge, #[metric(rename = "ws_messages_total", labels = ["direction"])] ws_messages: Counter, // direction = "upstream" | "downstream" }
- Create static instance:
static METRICS: LazyLock<VixyMetrics> = ... - Implement helper functions to record metrics throughout the codebase
- Add
/metricsendpoint using prometric's HTTP exporter
- Define
- Integrate metrics into health monitor:
- Update
el_block_number,el_lag,el_healthygauges on each check - Update
cl_slot,cl_lag,cl_healthygauges on each check - Increment
el_failoverscounter on primary→backup switch
- Update
- Integrate metrics into proxy:
- Increment
el_requests/cl_requestson each request - Record
el_request_duration/cl_request_durationhistograms - Update
ws_connectionsgauge on connect/disconnect - Increment
ws_messagescounter on each message
- Increment
- Run
cargo test metrics- metrics tests should now PASS ✓
- Run
just ci(or manually:cargo fmt --check && cargo clippy -- -D warnings && cargo test && cargo test --test cucumber)-
cargo fmt --check- code is formatted -
cargo clippy -- -D warnings- no warnings -
cargo test- ALL unit tests should PASS ✓ -
cargo test --test cucumber- ALL BDD tests should PASS ✓
-
- Verify GitHub Actions CI passes on push/PR
- Add
/statusendpoint to view all node health states as JSON - Implement round-robin or least-connections load balancing
- Add retry logic for failed proxy requests (try next healthy node)
- Add request timeout configuration
- Add TLS/HTTPS support
- Add CL WebSocket support (CL events API)
- Create
BLOG.md- a blog post telling the story of building Vixy with an AI Agent- Use
DIARY.mdas the primary resource for content - Highlight the engineering practices applied:
- TDD (Test-Driven Development) - tests first, then implementation
- BDD (Behavior-Driven Development) - cucumber scenarios for acceptance tests
- CI/CD - automated checks on every push/PR
- Small incremental commits - frequent, focused, well-documented changes
- Good documentation - AGENT.md as the blueprint, README.md for users
- Emphasize the speed and precision of AI-assisted development
- Include specific examples of challenges overcome (from DIARY.md)
- Reflect on what worked well and what could be improved
- Make it engaging - this is a story, not just a technical report
- Use
- Set up Kurtosis integration test infrastructure:
- Create
kurtosis/network_params.yaml- 4-node Ethereum testnet config - Create
scripts/setup-kurtosis.sh- Auto-detects nodes, generates Vixy config - Add justfile commands:
kurtosis-up,kurtosis-down,kurtosis-vixy,integration-test
- Create
- Create integration test scenarios (
tests/features/integration/):-
cl_proxy.feature- CL proxy forwarding and failover (4 scenarios) -
el_proxy.feature- EL proxy forwarding, failover, backup failover, WebSocket (6 scenarios) -
health_monitoring.feature- Status, detection, recovery, lag, metrics (5 scenarios)
-
- Implement integration step definitions (
tests/steps/integration_steps.rs):- Kurtosis service start/stop helpers
- HTTP request steps for EL JSON-RPC and CL Beacon API
- Health polling and status verification
- Backup failover test (stop ALL primaries, verify backups work)
- Fix bugs found by integration tests:
- HTTP proxy Content-Type header forwarding
- Accept 2xx status codes (Lighthouse returns 206 when syncing)
Test Configuration:
# 4-node testnet: 2 primary + 2 backup EL nodes
participants:
- el_type: geth
cl_type: lighthouse
count: 4
network_params:
preset: minimal
seconds_per_slot: 2Running Integration Tests:
just integration-test # Full cycle: setup, test, cleanupsrc/
├── main.rs # Entry point
├── config.rs # TOML config parsing
├── state.rs # Shared state (EL/CL node states)
├── metrics.rs # Prometheus metrics using prometric
├── monitor.rs # Background health check loop
├── health/
│ ├── mod.rs # Module exports
│ ├── el.rs # EL health check (eth_getBlockNumber)
│ └── cl.rs # CL health check (node/health + headers/head)
└── proxy/
├── mod.rs # Module exports
├── selection.rs # Node selection logic (health + failover)
├── http.rs # HTTP proxy for EL and CL
└── ws.rs # WebSocket proxy for EL (eth_subscribe support)
tests/
├── cucumber.rs # BDD test harness entry point
├── world.rs # Test world state struct
├── features/ # Gherkin feature files
│ ├── config.feature
│ ├── el_health.feature
│ ├── cl_health.feature
│ ├── el_failover.feature
│ ├── proxy_http.feature
│ └── proxy_ws.feature
└── steps/ # Step definitions
├── mod.rs
├── config_steps.rs
├── el_health_steps.rs
├── cl_health_steps.rs
├── failover_steps.rs
└── proxy_steps.rs
# Create config
cp config.example.toml config.toml
# Edit with your node URLs
# Run
cargo run -- --config config.toml
# Test EL HTTP proxy
curl -X POST http://localhost:8080/el \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}'
# Test EL WebSocket proxy (using websocat or similar)
websocat ws://localhost:8080/el/ws
# Then send: {"jsonrpc":"2.0","method":"eth_subscribe","params":["newHeads"],"id":1}
# Test CL proxy
curl http://localhost:8080/cl/eth/v1/beacon/headers/head
# Check Prometheus metrics
curl http://localhost:8080/metrics# Using just (recommended)
just # Show all available commands
just fmt # Format code
just clippy # Run lints
just test # Run unit tests (TDD)
just test-bdd # Run BDD tests (cucumber)
just ci # Run full CI checks
# Using cargo directly
cargo test # Unit tests (TDD)
cargo test --test cucumber # BDD tests
cargo fmt --check # Check formatting
cargo clippy -- -D warnings # Run lintsImportant: When working on new features or fixes that generate significant documentation, organize files properly:
All documentation, analysis, and artifacts from an AI-assisted development session should go into a dedicated folder:
agent/
└── <session-name>/ # e.g., "websocket-reconnection-fix"
├── README.md # Session overview and summary
├── <ANALYSIS>.md # Root cause analysis, investigation
├── <FIX-PLAN>.md # Implementation plan
├── <IMPROVEMENTS>.md # Testing/design improvements
└── ... # Other session-specific docs
- AGENT.md (this file) - Core development guide
- DIARY.md - Ongoing development diary (all sessions)
- README.md - Project documentation
- BLOG.md - Project blog posts and stories (general, not session-specific)
- INTEGRATION_TESTS.md - Integration testing guide (general, not session-specific)
- Cargo.toml, Justfile, etc. - Configuration files
vixy/
├── AGENT.md # ← Core guide (stays in root)
├── DIARY.md # ← Development log (stays in root)
├── README.md # ← Project docs (stays in root)
├── BLOG.md # ← Blog posts (stays in root)
├── INTEGRATION_TESTS.md # ← Integration testing guide (stays in root)
├── agent/ # ← Session artifacts folder
│ └── websocket-reconnection-fix/ # ← Example session
│ ├── README.md # Session summary
│ ├── WEBSOCKET-RECONNECTION-FIX.md
│ └── TESTING-IMPROVEMENTS.md
└── src/ # ← Source code
- Clean Root Directory: Project essentials remain visible
- Organized History: Each AI session is self-contained
- Easy Reference: Find all artifacts from a specific fix/feature
- No Clutter: Session-specific docs don't pollute the root
- Create folder:
agent/<descriptive-session-name>/ - Add session README.md explaining the goal
- Place all analysis, fixes, and documentation in that folder
- Update DIARY.md with references to the session folder
- Keep root clean!