A lightweight HTTP proxy that multiplexes Engine API requests from a consensus layer (CL) client to multiple execution layer (EL) clients simultaneously. Designed for testing scenarios where you want to run multiple block builders side-by-side without CL configuration complexity.
The CL Proxy enables dual-builder testing by intercepting Engine API calls from the beacon node and forwarding them to both a primary and secondary execution client. This allows:
- Testing multiple builder implementations simultaneously
- Comparing builder behavior under identical conditions
- Validating external builders against local implementations
- Running a fallback builder alongside a production builder
In standard Ethereum architecture:
Beacon Node (CL) ──Engine API──> Execution Client (EL)
The beacon node expects exactly one execution endpoint for:
- Fork choice updates (
engine_forkchoiceUpdated) - Block execution (
engine_newPayload) - Payload retrieval (
engine_getPayload)
Challenge: How do you test two builders receiving the same fork choice updates?
Solution: Use CL Proxy as a multiplexer:
┌──> Primary Builder (EL)
│ (provides responses)
Beacon Node ──> CL Proxy ───┤
│
└──> Secondary Builder (EL)
(receives updates, no responses)
┌──────────────────────────────────────────────────────────────────┐
│ CL Proxy │
├──────────────────────────────────────────────────────────────────┤
│ │
│ 1. Receive Request from Beacon Node │
│ ├─ JWT Authentication (forwarded from CL) │
│ └─ JSON-RPC Engine API call │
│ │
│ 2. Forward to Primary Builder │
│ ├─ Full request with all parameters │
│ ├─ Wait for response │
│ └─ Return response to beacon node │
│ │
│ 3. Forward to Secondary Builder (async) │
│ ├─ Filter block building requests │
│ │ └─ Remove payload attributes from FCU │
│ │ └─ Skip engine_getPayload entirely │
│ └─ Fire-and-forget (errors logged, not propagated) │
│ │
└──────────────────────────────────────────────────────────────────┘
The proxy applies intelligent filtering to secondary builder requests:
| Engine API Method | Primary | Secondary | Modification |
|---|---|---|---|
engine_newPayload |
✅ Full | ✅ Full | None |
engine_forkchoiceUpdated |
✅ Full | ✅ Filtered | Remove payloadAttributes param |
engine_getPayload |
✅ Full | ❌ Skipped | Not sent |
engine_exchangeCapabilities |
✅ Full | ✅ Full | None |
| Other methods | ✅ Full | ✅ Full | None |
engine_forkchoiceUpdated Filtering:
- The beacon node sends
payloadAttributesto trigger block building - The primary builder receives this and starts building a block
- The secondary builder also receives FCU but with
payloadAttributesset tonull - Reason: Secondary builders typically use MEV-Boost/Rollup-Boost for block building, not direct Engine API
engine_getPayload Skipping:
- The beacon node requests the built payload using a
payloadId - This
payloadIdis specific to the primary builder - The secondary builder doesn't have this payload (it builds via different mechanism)
- Sending this request to secondary would always fail
config := clproxy.DefaultConfig()
// Returns:
// {
// LogOutput: os.Stdout,
// Port: 5656,
// Primary: "", // Must be set
// Secondary: "", // Optional
// }| Field | Type | Description | Default | Required |
|---|---|---|---|---|
LogOutput |
io.Writer |
Log output destination | os.Stdout |
No |
Port |
uint64 |
HTTP server listen port | 5656 |
No |
Primary |
string |
Primary builder Engine API URL | "" |
Yes |
Secondary |
string |
Secondary builder Engine API URL | "" |
No |
Note: If Secondary is empty, the proxy acts as a simple pass-through to Primary.
import clproxy "github.com/flashbots/builder-playground/cl-proxy"
// Create proxy configuration
config := &clproxy.Config{
LogOutput: os.Stdout,
Port: 5656,
Primary: "http://localhost:8551", // Local Reth
Secondary: "http://localhost:9551", // External builder
}
// Create and start proxy
proxy, err := clproxy.New(config)
if err != nil {
log.Fatal(err)
}
// Run proxy (blocks until error or shutdown)
if err := proxy.Run(); err != nil {
log.Fatal(err)
}# Build the binary
cd cl-proxy/cmd
go build -o clproxy
# Run with both primary and secondary builders
./clproxy \
--primary-builder http://localhost:8551 \
--secondary-builder http://localhost:9551 \
--port 5656
# Run as simple pass-through (no secondary)
./clproxy \
--primary-builder http://localhost:8551 \
--port 5656# L1 recipe with secondary builder
builder-playground cook l1 \
--secondary-el 9551 \
--output ~/my-testnet
# This automatically:
# - Starts primary builder (Reth) on 8551
# - Configures cl-proxy on 5656
# - Connects beacon node to cl-proxy instead of Reth directly
# - Forwards requests to both Reth and localhost:9551if l.secondaryELPort != 0 {
// Use cl-proxy service to connect beacon node to two builders
elService = "cl-proxy"
svcManager.AddService("cl-proxy", &ClProxy{
PrimaryBuilder: "el",
SecondaryBuilder: fmt.Sprintf("http://localhost:%d", l.secondaryELPort),
})
} else {
elService = "el"
}
svcManager.AddService("beacon", &LighthouseBeaconNode{
ExecutionNode: elService, // Points to "cl-proxy" or "el"
MevBoostNode: "mev-boost",
})Test an external builder (like Rbuilder) alongside your local Reth instance:
# Terminal 1: Start external builder on port 9551
rbuilder run --engine-api-addr 0.0.0.0:9551 ...
# Terminal 2: Start testnet with cl-proxy
builder-playground cook l1 --secondary-el 9551Both builders receive identical fork choice updates from the beacon node.
Run two different builder implementations and compare their behavior:
# Primary: Reth (standard implementation)
# Secondary: Rbuilder (Rust builder)
builder-playground cook l1 \
--secondary-el 9551 \
--watchdog # Monitor both buildersMonitor logs to compare:
- Block building performance
- Transaction selection differences
- Gas usage and fees
Configure a production builder as primary, development builder as secondary:
config := &clproxy.Config{
Primary: "http://production-builder:8551", // Stable, returns responses
Secondary: "http://dev-builder:9551", // Experimental, logs only
}If the secondary builder crashes, the beacon node continues using the primary.
Develop a new builder without disrupting your testnet:
- Start testnet with cl-proxy and existing builder
- Point secondary to your development builder
- Iterate on your builder while testnet runs
- No need to reconfigure beacon node
- Method: POST only (Engine API standard)
- Port: Configurable (default: 5656)
- Timeouts:
- Read: 10 seconds
- Write: 10 seconds
- Content-Type:
application/json
Standard Ethereum JSON-RPC 2.0:
{
"jsonrpc": "2.0",
"id": 1,
"method": "engine_forkchoiceUpdatedV3",
"params": [
{
"headBlockHash": "0x...",
"safeBlockHash": "0x...",
"finalizedBlockHash": "0x..."
},
{
"timestamp": "0x...",
"prevRandao": "0x...",
"suggestedFeeRecipient": "0x...",
"withdrawals": [],
"parentBeaconBlockRoot": "0x..."
}
]
}The proxy forwards JWT tokens from the beacon node to both builders:
- Beacon node includes
Authorization: Bearer <jwt>header - Proxy copies header to both primary and secondary requests
- Both builders validate JWT independently
Important: Primary and secondary builders must use the same JWT secret as the beacon node.
Request to Primary:
{
"method": "engine_forkchoiceUpdatedV3",
"params": [
{"headBlockHash": "0x123...", ...},
{"timestamp": "0x...", "prevRandao": "0x...", ...} // Full payload attributes
]
}Request to Secondary:
{
"method": "engine_forkchoiceUpdatedV3",
"params": [
{"headBlockHash": "0x123...", ...},
null // Payload attributes removed
]
}Request to Primary:
{
"method": "engine_getPayloadV3",
"params": ["0x1234567890abcdef"] // PayloadId from FCU response
}Request to Secondary:
- Not sent at all
Request to Both:
{
"method": "engine_newPayloadV3",
"params": [
{
"parentHash": "0x...",
"feeRecipient": "0x...",
"stateRoot": "0x...",
// ... full execution payload
}
]
}Sent identically to both primary and secondary.
The proxy logs all requests and errors:
INFO[0000] Starting server on port 5656
INFO[0001] Received request: method=engine_forkchoiceUpdatedV3
INFO[0001] Multiplexing request to secondary: method=engine_forkchoiceUpdatedV3
INFO[0002] Received request: method=engine_newPayloadV3
INFO[0002] Multiplexing request to secondary: method=engine_newPayloadV3
WARN[0005] ForkchoiceUpdated call with only one parameter
ERROR[0010] Error multiplexing to secondary: connection refused
- INFO: Normal request flow
- WARN: Unexpected request format (e.g., FCU with only 1 param)
- ERROR: Network errors, marshalling errors (secondary only)
Note: Errors from secondary requests are logged but do not affect the response to the beacon node.
If the primary builder fails:
- Error is propagated to beacon node
- HTTP 500 returned to beacon node
- Beacon node may retry or fall back to safe head
If the secondary builder fails:
- Error is logged
- No impact on beacon node
- Primary builder response is still returned
This ensures the secondary builder cannot disrupt the testnet.
The proxy adds minimal latency:
- Primary request: synchronous (waits for response)
- Secondary request: asynchronous (fire-and-forget)
- No serialization between primary and secondary
Typical overhead: < 1ms for local forwarding.
The proxy can handle:
- ~1000 requests/second (local forwarding)
- Limited by primary builder response time
- Secondary requests do not block primary responses
- Memory: < 10 MB
- CPU: < 1% (idle), < 5% (active)
- Network: Minimal (local HTTP requests)
Only one primary builder can respond to the beacon node. Multiple secondaries could be supported with code changes.
The secondary builder's responses are ignored. The proxy does not:
- Compare responses between builders
- Merge results
- Aggregate metrics
For response comparison, you must implement custom logging/monitoring.
All builders must use the same JWT secret. This is a security consideration:
- In production, different secrets per builder are recommended
- For testing, shared secret is acceptable
The proxy does not:
- Verify builder availability before forwarding
- Implement circuit breakers
- Provide health check endpoints
If a builder is down, requests will fail and be logged.
Secondary requests are not retried on failure. If the secondary builder is temporarily unavailable, it will miss updates.
Requests are forwarded immediately. If the primary builder is slow, the beacon node will experience increased latency.
Symptom: address already in use error
Solution:
# Check what's using the port
lsof -i :5656
# Use a different port
clproxy --port 5657 --primary-builder ...Symptom: Beacon node logs failed to connect to execution endpoint
Solutions:
- Verify proxy is listening:
curl http://localhost:5656 - Check JWT secret matches beacon node
- Ensure beacon node points to proxy, not directly to builder
Symptom: Secondary builder logs show no activity
Solutions:
- Check proxy logs for errors:
Error multiplexing to secondary - Verify secondary builder URL is correct
- Test secondary builder directly:
curl -X POST http://localhost:9551 - Ensure secondary builder is running and accessible
Symptom: 401 Unauthorized in proxy logs
Solutions:
- Verify JWT secret is identical for beacon node, primary, and secondary
- Check JWT secret file permissions (must be readable)
- Ensure
Authorizationheader is forwarded correctly
Symptom: ForkchoiceUpdated call with only one parameter
Explanation: Some beacon nodes send FCU without payload attributes (block building not requested). This is normal.
Action: No action needed, warning is informational.
// Log to file
file, _ := os.OpenFile("clproxy.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
config.LogOutput = file
// Log to multiple destinations
config.LogOutput = io.MultiWriter(os.Stdout, file)
// Disable logging
config.LogOutput = io.Discardproxy, _ := clproxy.New(config)
// Start proxy in goroutine
go func() {
if err := proxy.Run(); err != nil {
log.Printf("Proxy error: %v", err)
}
}()
// Handle signals
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
<-sigCh
// Graceful shutdown (10s timeout)
if err := proxy.Close(); err != nil {
log.Printf("Shutdown error: %v", err)
}Currently not supported, but could be implemented by:
- Accepting
[]stringforSecondaryconfig - Looping over all secondaries in
handleRequest - Using
sync.WaitGroupto wait for all requests
The proxy has access to JWT tokens. Ensure:
- Proxy runs in trusted environment
- Network traffic is encrypted (or use localhost)
- JWT secret file has restrictive permissions (0600)
The proxy has basic timeouts but no rate limiting. In production:
- Add rate limiting per source IP
- Implement request size limits
- Use a reverse proxy (nginx, HAProxy) in front
The proxy does minimal validation. Malicious requests could:
- Crash primary/secondary builders
- Cause unexpected behavior
- Waste resources
Consider adding request schema validation for production use.
# Build library
cd cl-proxy
go build
# Build CLI
cd cmd
go build -o clproxy# Unit tests
go test ./...
# Integration test with mock builders
# (requires implementation)github.com/flashbots/mev-boost-relay/common- Logging setupgithub.com/sirupsen/logrus- Structured logging- Standard library only (no heavy dependencies)
- Lighthouse - Ethereum consensus client
- Reth - Execution client
- Rbuilder - Rust-based block builder
- Builder Playground - Testing framework
MIT License - Copyright (c) 2025 Flashbots
For issues or questions:
- GitHub Issues: https://github.com/flashbots/builder-playground/issues
- Tag with:
cl-proxy
Potential improvements:
- Response Comparison - Compare primary/secondary responses and log differences
- Multiple Secondaries - Support array of secondary builders
- Health Checks - Endpoint for monitoring proxy status
- Metrics - Prometheus metrics for request counts, latency, errors
- Circuit Breaker - Disable secondary if it fails repeatedly
- Request Replay - Save and replay requests for debugging
- WebSocket Support - Support WebSocket Engine API connections
- Dynamic Configuration - Reload config without restart
- TLS Support - HTTPS for remote builders
- Request Filtering Rules - Configurable filtering logic per builder