-
Notifications
You must be signed in to change notification settings - Fork 2.3k
Description
Summary
Hey @gakonst , I built a proof-of-concept ExEx that I'd love to get feedback on and potentially see land upstream (or at least get a formal endorsement as a reference implementation).
The short version: every time a Reth node commits a block, the ExEx signs the state root with EIP-712, and a new vrpc_* RPC namespace attaches those signed attestations alongside standard EIP-1186 Merkle proofs to every response. Clients can then verify correctness without trusting the infrastructure.
This writeup lays out the design, the implementation, the tradeoffs, and the specific places where I'd want Reth core's input before trying to turn this into anything more permanent.
Motivation
The Ethereum execution layer is cryptographically verifiable end-to-end — except for the last mile. When a wallet, dApp, or protocol calls eth_getBalance, eth_call, or eth_getStorageAt, it trusts the RPC provider implicitly. There is no standard mechanism to verify that the response corresponds to the canonical chain's state.
This creates a structural problem:
- 6,000+ full nodes verify Ethereum but earn zero revenue from doing so.
- Centralized RPC providers act as trusted intermediaries and capture the entire $135M+ NaaS market (projected $318M by 2032) on the back of that trust gap.
- Protocols that settle trillions of dollars (stablecoins settled $27T+ in 2024) still bootstrap every transaction with an unverifiable RPC call.
The fix is to make trust mathematical rather than organizational: sign the state root you executed against, attach an EIP-1186 proof, and let clients verify locally.
What I Built
The project (reth-verifiable-rpc) is a four-crate Reth workspace:
vrpc-attestation — EIP-712 types, sign_state_root(), verify_attestation()
vrpc-exex — ExEx that hooks into block commits and signs state roots
vrpc-rpc — Four new vrpc_* JSON-RPC methods
vrpc-node — Drop-in Reth binary that wires everything together
Plus two Solidity contracts (NodeRegistry, StateRootRegistry) and a Foundry test suite, but those are out of scope for a Reth issue — including them here for context.
Technical Design
ExEx Integration
The ExEx listens to ExExNotification events and handles all three cases:
| Notification | Action |
|---|---|
ChainCommitted |
Call sign_state_root() for every new block; insert into AttestationStore |
ChainReverted |
Evict attestations for reverted block numbers |
ChainReorged |
Evict old canonical blocks + attest new canonical chain |
The AttestationStore is an Arc<DashMap<u64, SignedStateAttestation>> shared between the ExEx task and the RPC handlers. DashMap was chosen for lock-free concurrent reads since RPC threads outnumber ExEx threads dramatically at runtime.
EIP-712 Attestation
// Struct type (via alloy sol! macro)
sol! {
struct StateAttestation {
uint64 chainId;
bytes32 stateRoot;
uint64 blockNumber;
address nodeAddress;
uint64 timestamp;
}
}
Domain:
name: "VerifiableRPC"
version: "1"
chainId: <from CLI or node config>
verifyingContract: <optional NodeRegistry address>Signing is async (sign_state_root() uses alloy_signer_local::PrivateKeySigner) and happens on the ExEx task, not the RPC thread, so signing latency doesn't affect RPC response time. The RPC layer simply does a DashMap lookup.
Proof Generation
The RPC handler calls Reth's StateProofProvider:
let proof = provider
.history_by_block_number(block_number)?
.proof(TrieInput::default())
.account_proof(address, &[])?;
Because Reth already computed the Merkle trie during execution, this is essentially a trie traversal over already-materialized data — no re-execution required. Proof sizes are ~2-3 KB for account proofs and scale linearly with the number of storage slots.
RPC Methods
vrpc_getBalanceWithProof(address, blockTag)
→ { balance, nonce, codeHash, storageHash, accountProof[], blockNumber, stateRoot, attestation }
vrpc_getStorageAtWithProof(address, slot, blockTag)
→ { value, storageProof[], blockNumber, stateRoot, attestation }
vrpc_getAttestation(blockNumber)
→ { chainId, stateRoot, blockNumber, nodeAddress, timestamp, signature }
vrpc_nodeInfo()
→ { nodeAddress, chainId, latestAttestedBlock, attestationCount }
All methods are added via Reth's extend_rpc_modules hook — zero fork required.
CLI Extension
The binary extends Reth's CLI with:
--vrpc.key <hex> Operator signing key (hex-encoded private key)
--vrpc.keystore <path> eth-keystore JSON file
--vrpc.registry <addr> StateRootRegistry contract address (for EIP-712 domain)
--vrpc.chain-id <id> Override chain ID in EIP-712 domain
If neither --vrpc.key nor --vrpc.keystore is provided, an ephemeral key is generated
at startup (useful for local dev / testing).
Current Status
- Full EIP-712 attestation signing and verification (unit-tested, round-trip verified)
- ExEx with reorg handling (unit-tested: attest → revert → re-attest)
- Four vrpc_* RPC methods implemented and wired up
- Drop-in node binary with CLI extension
- Foundry test suite for contracts (6 tests + fuzz)
- On-chain attestation submission from ExEx (off-chain signing only today)
- Complete slashing iteration in resolveDispute (stub, not production-ready)
- Off-chain EIP-712 charge vouchers for query fee batching (designed, not built)
Specific Questions for Reth Core
- StateProofProvider API stability
I'm calling history_by_block_number(n).proof(TrieInput::default()).account_proof(...). Is this surface considered stable? I noticed the TrieInput API changed between minor versions. Is there a recommended pattern for proof generation in ExEx/plugin contexts that won't break on upgrades?
- ExEx + RPC shared state pattern
The current design uses Arc passed from the ExEx initializer into the RPC extension via a closure. This works but feels slightly awkward — the node binary has to manually thread the Arc through both install_exex and extend_rpc_modules closures.
Is there a preferred pattern in Reth for ExEx-to-RPC communication? A shared context type, an event bus, or a node extension trait that would be cleaner here?
- BlockNumberOrTag resolution in RPC handlers
For BlockNumberOrTag::Latest / BlockNumberOrTag::Finalized / BlockNumberOrTag::Safe, I'm calling provider.best_block_number() / provider.finalized_block_number() etc. Is there a canonical helper for this tag-to-number resolution that I should use instead of rolling my own?
- Signing key management
The operator key today is a raw PrivateKeySigner. For mainnet deployment the right approach is probably remote signing (EIP-3030 / Web3Signer / AWS KMS), but that adds async RPC hops into the critical path of block commitment.
Has there been any thought in Reth core about a signing interface that could work for both local and remote signers without blocking the ExEx event loop?
- ExEx notification backpressure
If signing or proof generation falls behind block time (unlikely given proof generation is cheap, but possible under load), do ChainCommitted notifications queue up or get dropped? I want to understand the failure mode — is there a bounded channel here I should be aware of?
Potential Path to Upstream
I'm not proposing this merges as-is. But I see a few possible outcomes:
Option A —> Reference ExEx (preferred): Add vrpc-exex to examples/exex/ as a reference implementation showing how ExEx + RPC extension + shared state works together. Strip the economic/contract layer; keep the core signing + proof pattern.
Option B —> Unstable feature flag: If there's interest in making verifiable RPC a first-class Reth feature, add it behind --features verifiable-rpc with the understanding that the vrpc_* namespace is experimental.
Option C —> Ecosystem / plugin: Keep it as a standalone crate that node operators can add to any Reth deployment. The main ask from core would be: (a) don't break the extend_rpc_modules API, and (b) consider stabilizing the StateProofProvider surface that makes zero-cost proof generation possible.
Repository
Source code: https://github.com/[your-handle]/reth-verifiable-rpc
X: https://x.com/oxwizzdom/status/2026995525464285353?s=20
Happy to open a PR for any of the above options once there's alignment on direction.
cc: @gakonst
Thanks for reading through this.
Additional context
No response
Metadata
Metadata
Assignees
Labels
Type
Projects
Status