A consensus layer client that enables PoW chains to use the Engine API for block synchronization and mining. It connects to an execution layer client as an out-of-process consensus driver.
Currently configured for Ethereum Classic (ETC, ChainID 61), but the architecture is chain-agnostic — only the forks crate carries chain-specific parameters.
Post-Merge execution layer clients removed PoW infrastructure (total difficulty tracking, ethash consensus in the beacon engine, difficulty-based fork choice). Running a PoW chain on a modern EL requires either maintaining a full parallel fork or separating consensus into an external process.
etc-cl takes the second approach: a lightweight CL that handles peer discovery, block download, PoW validation, and fork choice, then feeds validated blocks to a minimally-patched EL via the standard Engine API.
┌─────────────────────────────────────┐
│ etc-cl (CL) │
│ │
ETC peers ◄──►│ devp2p ──► sync ──► engine-api ────┼──► EL
(eth/68) │ │ │ │ port 8551
│ discv4 JWT auth │ (authrpc)
│ DNS-ENR │
│ consensus chain forks │
│ │
│ mining (optional) ──────────┼──► miners
│ port 8547 │ (getWork)
└─────────────────────────────────────┘
The CL discovers peers via discv4 + DNS-ENR, downloads blocks over eth/68, validates headers (difficulty, ethash PoW, uncles), and submits them to the EL via engine_newPayloadV2. The EL executes transactions, applies mining rewards, and updates state. Fork choice is communicated via engine_forkchoiceUpdatedV2 using heaviest-chain (max TD) selection.
| Crate | Description |
|---|---|
etc-cl |
Binary entry point, CLI, main event loop, peer request serving |
engine-api |
Engine API JSON-RPC client with JWT auth, payload/header bridge |
devp2p |
Full devp2p stack: ECIES transport, RLPx framing, discv4, DNS-ENR, eth/68, EIP-2124 fork filter |
sync |
Three-phase sync state machine (CatchUp, Pipeline, TipFollowing) |
consensus |
Ethash PoW verification, ETC difficulty calculation, uncle validation |
chain |
In-memory chain tracker with ring buffer (stateless, no persistence) |
forks |
Consensus-relevant fork activations and chain parameters |
eth-rpc |
Standard eth namespace JSON-RPC client (block/receipt/uncle fetches) |
serde-hex |
Shared hex serialization/deserialization modules for Ethereum types |
mining |
Mining coordinator: work generation, PoW verification, Stratum-style RPC |
The CL has no persistent database. Chain state is an in-memory ring buffer of the last 512 blocks (ChainTracker), used to derive safe (head - 24) and finalized (head - 400) hashes for FCU. On restart, the CL seeds from the EL's current head in seconds. The EL database is the single source of truth for all persisted state.
Synchronization proceeds through three phases:
CatchUp (EL-driven) — The CL discovers the network's best block from peers, picks a target ~64 blocks behind the tip, and sends a ForkchoiceUpdated pointing to that target hash. The EL syncs to it using its own eth/68 implementation. This leverages the EL's optimized sync for the bulk of the chain. Exit: EL returns VALID.
Pipeline (CL-driven) — The CL fetches headers sequentially from one peer (192/batch), dispatches body requests to multiple peers in parallel (128/chunk), validates difficulty and ethash PoW via spawn_blocking, and submits blocks to the EL via newPayloadV2 (batched). An FCU is sent every 64 blocks to anchor progress. Exit: header peer returns 0 headers.
TipFollowing (real-time) — The CL listens for NewBlock and NewBlockHashes from peers. Each block is individually verified and submitted to the EL. An orphan buffer handles blocks whose parent hasn't arrived yet, and the CL can fetch up to 16 ancestor headers in one request to resolve short reorgs. Accepted blocks are broadcast to other peers (full NewBlock to sqrt(n) peers, NewBlockHashes to the rest).
Fallback: if a peer's TD exceeds ours by more than ~1000 blocks' worth of average difficulty, TipFollowing drops back to Pipeline.
The CL responds to incoming GetBlockHeaders, GetBlockBodies, and GetReceipts requests from peers by proxying them to the EL via eth namespace RPC. This makes the node a full participant in the eth/68 network — not just a consumer. Requests are handled in spawned tasks with a concurrency limit (32 concurrent requests) to avoid overloading the EL. GetPooledTransactions is answered with an empty response — transaction gossip is handled entirely by the EL's own peer network.
The standard Engine API was designed for PoS. Running it for PoW required the following changes.
Three optional fields carry PoW-specific data:
| Field | Type | Purpose |
|---|---|---|
difficulty |
U256 (hex) |
Block difficulty (absent in PoS) |
nonce |
[u8; 8] (hex) |
Ethash nonce |
uncles |
Vec<Header> |
Uncle headers (PoW-only concept) |
withdrawals is always null — ETC has no Shanghai fork (ShanghaiTime: nil).
Uncle headers use the standard types.Header JSON field names (sha3Uncles, miner, mixHash, baseFeePerGas).
| Payload field | PoW meaning |
|---|---|
prevRandao |
ethash mixHash (proof-of-work output) |
feeRecipient |
miner coinbase |
baseFeePerGas = "0x0" |
treated as nil by EL (pre-London blocks have no base fee) |
The standard Engine API has no way for the CL to learn the chain's fork schedule. This custom method returns the EL's network identity and EIP-2124 fork data:
{
"networkId": 1,
"genesisHash": "0xd4e5...",
"hash": "0xfc64ec04",
"next": 0,
"forkBlocks": [1150000, 2500000, ...]
}The CL uses this to construct eth/68 Status messages, validate peers via EIP-2124 fork ID checksums, and reject connections from incompatible chains. Required — the node refuses to start without it.
Mining rewards are not computed by the CL. The EL detects PoW blocks (difficulty > 0) and applies the standard reward schedule (base reward + uncle inclusion/mining rewards) during block finalization. The CL only drives the flow: FCU with PayloadAttributes → GetPayloadV2 → seal with ethash → NewPayloadV2.
During pipeline sync, multiple engine_newPayloadV2 calls are sent in a single HTTP batch to reduce round-trip overhead.
etc-cl requires an EL that supports the following behaviors. Implementation details are left to the EL.
The CL calls the following methods on the EL. All are standard except engine_getStatusInfoV1.
Engine API (JWT-authenticated):
| Method | Usage |
|---|---|
engine_exchangeCapabilities |
Startup handshake. CL sends its capability list and verifies the EL supports engine_getStatusInfoV1. |
engine_getStatusInfoV1 |
Custom method. Returns network identity and fork schedule (see below). |
engine_newPayloadV2 |
Submit blocks for validation. Also called as a JSON-RPC batch during pipeline sync. |
engine_forkchoiceUpdatedV2 |
Anchor fork choice. When called with PayloadAttributes, triggers block building for mining. |
engine_getPayloadV2 |
Retrieve a block assembled by the EL (mining only). |
Eth namespace (standard HTTP RPC):
| Method | Usage |
|---|---|
eth_blockNumber |
Read current chain head number at startup. |
eth_getBlockByNumber |
Fetch blocks by number (startup seeding, TD lookups). |
eth_getBlockByHash |
Fetch blocks by hash (parent lookups, peer request serving). |
eth_getUncleByBlockHashAndIndex |
Fetch uncle headers for peer request serving. |
eth_getBlockReceipts |
Fetch receipts for peer request serving. |
The eth namespace methods are used to serve incoming GetBlockHeaders, GetBlockBodies, and GetReceipts requests from devp2p peers. Without them, the node can sync but cannot serve data to the network.
engine_newPayloadV2 and engine_getPayloadV2 must handle three additional fields:
| Field | Type | Description |
|---|---|---|
difficulty |
QUANTITY (hex U256) |
Block difficulty. The EL must store this in the block header and use it to identify PoW blocks (difficulty > 0). |
nonce |
DATA (8 bytes, hex) |
Ethash nonce. Stored in the block header. |
uncles |
Array<Header> |
Uncle headers using standard types.Header JSON field names (sha3Uncles, miner, mixHash, baseFeePerGas). The EL must reconstruct the uncle list and compute uncleHash from them. |
The prevRandao payload field carries the ethash mixHash. feeRecipient carries the miner coinbase. baseFeePerGas = "0x0" must be treated as absent (pre-London blocks have no base fee). withdrawals is always null.
engine_forkchoiceUpdatedV2 with PayloadAttributes must accept one additional field:
| Field | Type | Description |
|---|---|---|
uncles |
Array<Header> (optional) |
Uncle candidates to include in the next block. Same format as ExecutionPayload.uncles. The EL includes these in the assembled block if they pass validation. |
This is only used during mining. Sync-only FCU calls send PayloadAttributes = null.
A custom method the CL calls at startup to learn the chain's identity and fork schedule. Must return:
{
"networkId": 1,
"genesisHash": "0xd4e5...",
"hash": "0xbe46d57c",
"next": 0,
"forkBlocks": [1150000, 2500000, ...]
}networkId— the chain's network ID (used in devp2pStatusmessages)genesisHash— genesis block hash (used for peer validation)hash,next— current EIP-2124 fork IDforkBlocks— ordered list of all fork activation block numbers (used to construct the EIP-2124 fork filter for peer compatibility checks)
The CL refuses to start without this method.
The EL must accumulate total difficulty (TD) on block insertion and expose it via:
eth_getBlockByNumber/eth_getBlockByHash—totalDifficultyfield in the response (the CL reads this at startup to seed its chain tracker and to build theeth/68Status message for peers)
When the beacon consensus engine receives a block with difficulty > 0, it must:
- Apply mining rewards — delegate block finalization to the PoW reward path (base reward, uncle inclusion rewards, uncle mining rewards, era-based reductions if applicable)
- Validate gas limit — use the pre-London gas limit bounds check (not EIP-1559 elasticity) for blocks before the EIP-1559 activation fork
- Handle BaseFee=0 as nil — when reconstructing blocks from payloads, treat
baseFeePerGas == 0as the field being absent (pre-London blocks)
The EL must have the target chain's fork schedule, consensus parameters, and chain ID configured. This is chain-specific and determines which EIPs/ECIPs activate at which block numbers.
Changes 1–3 are minimal and chain-agnostic. Changes 4–5 restore functionality removed post-Merge. Change 6 is chain-specific.
| Constant | Value | Rationale |
|---|---|---|
CATCHUP_OFFSET |
64 blocks | CatchUp targets 64 blocks behind the network tip, then hands off to Pipeline. This lets the EL's optimized eth/68 sync handle the bulk of the chain while leaving a small gap for the CL to verify via pipeline. |
HEADER_BATCH_SIZE |
192 | The de facto standard GetBlockHeaders batch size across eth/68 implementations. Larger batches reduce round-trips. |
BODY_CHUNK_SIZE |
128 | Bodies are heavier than headers, so chunks are smaller. Each chunk goes to a different peer, enabling parallel body downloads across the peer set. |
FCU_INTERVAL |
64 blocks | During pipeline sync, an FCU is sent every 64 blocks to anchor the EL's fork choice and commit progress. Also the batch size for newPayloadV2 calls — after 64 blocks are submitted, the EL gets an FCU to advance its head. |
MAX_BUFFER_SIZE |
2048 | Pipeline backpressure limit. If headers arrive faster than bodies, the buffer caps at 2048 to bound memory usage. At ~30KB/header this is ~60MB worst case. |
PIPELINE_FALLBACK_BLOCKS |
1000 | During TipFollowing, if a peer's TD exceeds ours by more than ~1000 blocks' worth of average difficulty, the CL drops back to Pipeline. Dynamic threshold: avg_difficulty_per_block * 1000. |
| Constant | Value | Rationale |
|---|---|---|
SAFE_DEPTH |
24 blocks | ETC mainnet has seen reorgs of 11+ blocks. The conventional 6-block threshold is insufficient; 24 gives comfortable margin against observed reorg depths. |
FINALIZED_DEPTH |
400 blocks | ~87 minutes at ETC's ~13s block time. At this depth, a reorg would require sustained >50% hashrate for an extended period. This is a practical approximation of finality for Engine API consumers. |
RING_BUFFER_CAP |
512 blocks | Must be ≥ FINALIZED_DEPTH (400) to have history for computing finalized hashes. 512 gives margin. The ring buffer is the CL's only "state" — O(1) memory, no persistence. |
| Constant | Value | Rationale |
|---|---|---|
ORPHAN_TIMEOUT_SECS |
60s | Orphan blocks (parent unknown) are kept for 60s while the CL tries to fetch ancestors. After that, they're evicted to prevent unbounded memory growth. |
REORG_ANCESTOR_LIMIT |
16 | When resolving an orphan's ancestry, the CL fetches up to 16 headers in one request. Covers typical reorgs (1-3 blocks) with margin, without over-fetching. |
CATCHUP_MIN_PEERS |
2 | CatchUp requires at least 2 available peers before syncing. The peer with the highest TD is chosen as the sync target. Each block is validated (PoW + EL newPayload) so a malicious target only wastes download time. Falls back to Pipeline with 1 peer after 30s (CATCHUP_PEER_WAIT_SECS). |
In post-Merge Ethereum, the CL uses libp2p and the EL handles devp2p independently. etc-cl takes a different approach: the CL runs its own devp2p/eth68 stack. This is necessary because the CL must discover, download, and validate blocks before the EL sees them. The EL still runs its own eth/68 network for transaction gossip and CatchUp sync.
The CL deliberately does not implement mining reward logic. Reward schedules are complex (base reward + uncle inclusion reward + uncle mining reward) and vary across forks (e.g., ECIP-1017 era-based reduction for ETC). The EL already has this logic in its consensus engine. When the EL sees difficulty > 0 in a payload, it delegates finalization to the PoW reward path. This avoids duplicating reward logic and ensures the EL's state transitions are authoritative.
During pipeline sync, the EL may return ACCEPTED instead of VALID for blocks whose parent hasn't been fully processed yet (async validation). The CL treats both as success during sync because blocks arrive in rapid succession and the EL may lag slightly behind. For locally mined blocks, only VALID is accepted — the EL built the block itself via GetPayloadV2, so it should be able to validate immediately. ACCEPTED for a mined block would indicate something is wrong.
Stateless CL, stateful EL. The CL keeps no persistent state — it re-seeds from the EL on every restart. This eliminates crash recovery complexity and DB corruption risk, but means the CL cannot operate independently of the EL. If both crash, the EL's database is the recovery point.
Safe/finalized are heuristic. PoW has no protocol-level finality. The CL uses fixed depths (24 blocks for safe, 400 for finalized) as approximations. If the ring buffer doesn't cover that depth (e.g., right after startup), the CL sends 0x00...00 and the EL handles it gracefully.
CatchUp delegates to the EL. The first sync phase trusts the EL to sync via its own eth/68 peers. This is much faster than CL-driven sync for catching up thousands of blocks, but means the CL is idle during that phase. If the EL stalls, the CL waits.
Single-peer header fetching. Pipeline sync fetches headers from one peer at a time. Bodies are parallelized across multiple peers, but headers remain sequential. The CatchUp phase handles the bulk of sync, so this is acceptable.
Uncle validation is basic. The CL validates uncle count (≤2), depth (≤7 blocks), and uncle hash integrity, but does not check for duplicate uncles across blocks. Maintaining a rolling set of recent uncle hashes would add statefulness for marginal benefit — the EL performs full validation in newPayload.
MESS is fail-open. The ECBP-1100 anti-reorg mechanism requires finding a common ancestor via EL RPC. If the ancestor lookup fails (EL error, block too deep), the reorg is allowed rather than stalling the node.
Pipeline accepts ACCEPTED status. During pipeline sync, the EL may return ACCEPTED (queued for async validation) instead of VALID for blocks whose parent hasn't been fully processed yet. The CL treats both as success. For locally mined blocks, only VALID is accepted since the EL built them and should validate immediately.
No state sync from scratch. The CL cannot bootstrap an EL with no data. The EL must handle its own state acquisition (via snap/fast sync during CatchUp, or from a pre-existing database).
Pre-London base fee. baseFeePerGas = 0 is the sentinel for "no base fee" (pre-London blocks). The EL converts this to nil internally. This is a convention, not a protocol guarantee.
devp2p in the CL. Unlike PoS where the CL uses libp2p and the EL handles devp2p independently, this CL runs its own devp2p stack because it needs to fetch and validate blocks before the EL sees them. The EL still maintains its own eth/68 peer network for transaction gossip and CatchUp sync.
- Rust toolchain (stable)
- A compatible EL — we test with go-ethereum-classic (branch
etc/v1.16.8-execution-layer), a minimally-patched go-ethereum v1.16.8. Build withmake geth. - A JWT secret:
openssl rand -hex 32 > /tmp/jwt.hex
Terminal 1 — Execution Layer:
geth --classic \
--datadir /tmp/el-data \
--authrpc.jwtsecret /tmp/jwt.hex \
--authrpc.port 8551 \
--http --http.port 8545 --http.api eth,net,web3Terminal 2 — Consensus Layer:
cargo run --release -p etc-cl -- \
--engine-endpoint http://localhost:8551 \
--eth-endpoint http://localhost:8545 \
--jwt-secret /tmp/jwt.hex \
--datadir /tmp/cl-data \
--listen 30304 \
--dns-discovery "enrtree://AJE62Q4DUX4QMMXEHCSSCSC65TDHZYSMONSD64P3WULVLSF6MRQ3K@all.classic.blockd.info" \
--log-level infocargo run --release -p etc-cl -- \
--engine-endpoint http://localhost:8551 \
--jwt-secret /tmp/jwt.hex \
--eth-endpoint http://localhost:8545 \
--mining \
--mining-coinbase 0xYourAddress \
--mining-port 8547Miners connect to port 8547 using eth_getWork / eth_submitWork / eth_submitHashrate.
| Flag | Required | Description |
|---|---|---|
--engine-endpoint |
no | EL Engine API URL (default: http://localhost:8551) |
--jwt-secret |
yes | Path to shared JWT secret file |
--eth-endpoint |
no | EL HTTP RPC (default: http://localhost:8545) |
--datadir |
no | Directory for node key and local data (default: data) |
--dns-discovery |
recommended | DNS discovery URL for peer bootstrap |
--bootnodes |
no | Comma-separated enode URLs |
--listen |
no | P2P listen port (default: 30303) |
--mining |
no | Enable mining mode |
--mining-coinbase |
if mining | Miner reward address |
--mining-host |
no | Mining RPC bind address (default: 127.0.0.1) |
--mining-port |
no | Mining RPC port (default: 8547) |
--mess-enabled |
no | Enable ECBP-1100 anti-reorg |
--log-level |
no | Log verbosity: trace, debug, info, warn, error (default: info) |
-C / --config |
no | Path to TOML configuration file |
CLI arguments take precedence over TOML config file values.
cargo build --release
cargo test
cargo clippyMIT