Skip to content
leektPublic

About

Application-specific Ethereum state client: sync (and archive) only the state your app needs, straight from P2P. BAL-driven, verified, local eth_call in ms.

Topics

Resources

Stars

73 stars

Watchers

0 watching

Forks

Repository files navigation

skim: sync only what you need

status network rust license

Not every app needs a full node. Sync (and archive) only what you need.

skim in 30 seconds: no RPC provider; your own peer on Ethereum's P2P network; every value proven against the block

skim demo: following Sepolia over P2P, Uniswap V3 and V4 quotes and logs through plain viem, in milliseconds
Real recording, real Sepolia: skim follows the chain from P2P gossip, and unmodified viem gets Uniswap V3/V4 state, quotes and logs from it. (MP4 · how it was recorded)

skim is an application-specific Ethereum state client. You declare the state your app cares about: accounts, storage slots, or whole simulations. skim keeps exactly that state current, straight from the Ethereum P2P network, and runs your eth_calls locally against it in milliseconds. Anything else is fetched on demand, with Merkle proofs verified, only when a request needs it.

skim never holds Ethereum's full state. It holds your app's working set and keeps it current block by block from Glamsterdam's block-level access lists (BALs): the values each block wrote, committed in its header. Anything else is fetched when a request needs it, with Merkle proofs. No full node, no RPC provider.

skim run defaults to Sepolia; Hoodi is built in too (--network hoodi, from its Glamsterdam fork on 2026-10-26), and custom Glamsterdam networks work with --network <dir>, using the mainnet or minimal consensus preset. See network configuration and the verified devnet example. The bundled app examples use Sepolia contracts.

$ skim run --config skim.yaml          # follows Sepolia, serves JSON-RPC on :8545
$ cast call 0xfFf9…6B14 "balanceOf(address)(uint256)" 0x3289…EfF1 --rpc-url http://127.0.0.1:8545
101838818991837710804 [1.018e20]       # unmodified Foundry, viem, ethers

Why this is possible now

Until Glamsterdam, knowing the latest state meant executing every block, or asking a node that did. EIP-7928 changes that. Every block now commits to a block access list: every account and slot it touched, with the post-values it wrote. With ePBS, that list travels in the execution payload gossiped on the consensus layer.

Applying a block's BAL updates state without re-executing the block. skim applies it only to the keys it holds, so its cost follows your app's state, not the chain's.

skim builds on that:

How skim gets it
New blocks + their state diffs CL execution_payload gossip. The EL header is rebuilt from the payload, so the BAL is bound to the block hash. eth/71 BAL requests fill gaps.
State it has never seen snap/1 + snap/2 point lookups at the parent root, verified against the state root with Merkle proofs, then brought to the head by replaying the BAL
Execution revm with Amsterdam rules over a per-key MVCC store; optional EVM2 for plain calls (experiment)

State age, history, and backfill limits

State written before Glamsterdam can still be fetched at today's head, even if it has never changed since. Its current value remains in the state trie. skim fetches it through SNAP, verifies its Merkle proof against a recent state root, and applies any later BAL writes to reach the head. The key does not need to appear in a recent BAL.

The age of a value and the age of the block you want to query are different:

What you want Can skim do it? Why
Read today's value of an old account or slot, including one last changed before Glamsterdam Yes, when a SNAP peer can serve the required state SNAP supplies current state regardless of when it was written.
Read an old block already covered by your selective archive Yes, while you keep the archive Archived coverage does not expire with age; the values and headers are on disk.
Backfill state from before you subscribed, but after Glamsterdam Conditionally Peers must supply a complete, verified chain of headers and BALs for the scan. Each key gets its own coverage range.
Start today and recover months or years of post-Glamsterdam state history Only if peers still serve all the required BALs Ordinary peers can prune old BALs. Increasing --archive-backfill cannot recreate missing data.
Read state at a block before Glamsterdam No, in the current implementation Those blocks have no BALs to reconstruct history from. A current-state proof does not prove an earlier value.
Run a historical eth_call that needs an uncovered key No Every state dependency must be available in the archive. Missing history returns -32002; it is never guessed.

Backfill cannot undo a write. If a key has no writes throughout the scanned interval, including its known base block, its known value can extend back to the scan's start. If there are writes, coverage starts at the earliest recovered write: BALs contain new values, not the values they replaced.

Peers must retain BALs for at least 3,533 epochs (about 15.7 days), but may prune them afterward (EIP-7928). On 2026-10-19, all 7 Sepolia eth/71 peers sampled by skim probe-retention still served BALs, bodies and receipts back to the fork block (about 12.7 days). This limits new backfills and healing offline gaps; it does not expire history already archived locally. --archive-backfill N targets N blocks before the archive's first block. The current implementation does not automatically clamp that target to the fork or peers' retained history, so an unavailable range can keep failing and retrying. See the archive guide for configuration and coverage details.

External services: state reads and backfills use Ethereum P2P peers, with no external Ethereum JSON-RPC fallback. Consensus bootstrap separately defaults to an HTTP checkpoint endpoint when neither --checkpoint nor a previously verified checkpoint is available; providing a trusted checkpoint avoids that HTTP request. The trust model still applies.

The model: subscribed state, on-demand everything else

Subscribed (your state watchlist) On demand
What Accounts, slots and simulations you declare Other state at supported blocks
Freshness Applied from every block's BAL Fetched for the requested block; cached for a TTL
Hot path No network. Target: a few ms One verified fetch wave, then local
Guarantee Ready = head BAL applied and every required key known. Otherwise reported as not ready. Best effort, and never wrong: unknown state is never treated as zero
# skim.yaml (see skim.example.yaml)
state:
  subscriptions:
    - name: weth-usdc-0.3%
      address: "0x6Ce0896eAE6D4BD668fDe41BB784548fb8F59b50"
      account: true
      storage: ["0x0", "0x4"]            # slot0, liquidity
    - name: my-quoter                     # a simulation you run every block
      calls:
        - to: "0xEd1f6473345F45b75F8179591dd5bA1888cf2FB3"
          data: "0xc6a5026a…"
      on_block: true                      # re-run each block; read via skim_subscriptionStatus
      retention: { mode: archive }        # keep every version on disk (skim run --archive <path>)
  on_demand: { enabled: true, cache_ttl_blocks: 4 }
  limits:    { max_subscribed_keys: 100000 }

Simulation subscriptions don't need you to know storage layouts. skim learns each call's access set, and protocol-aware predictors cover it too: for Uniswap V3, the tick range a swap of a given size can reach. Those keys stay warm.

What we measured

All numbers are from Sepolia over public peers; unless a row says otherwise, measured from one home connection. Details and caveats are in the experiment logs.

Result
Correctness Every completed run matched a reference archive node byte for byte at the same block hash. Totals: 1,753 calls in a 1-hour soak; 2,700 calls with randomized, never-seen Uniswap V3 quotes; 866 with proof verification on; 36/36 JSON-RPC requests (P0, verified state).
State ready after a new block BAL applied 0.16 ms after the payload arrives. State ready at about 880 ms P50 after slot start, bounded by gossip arrival (M1).
Warm simulation Uniswap V3 quote over JSON-RPC: 0.25 ms P50, 0.56 ms P99; ~8,500 calls/s at 50 in flight
Fetch reliability 298,084 SNAP jobs over public peers, 0 failed, 126 disconnects absorbed (soak)
Unseen inputs Background prefetch had 99.9% of predicted keys ready at call time. Worst on-path fetch: 1.6 s (P1).
Footprint Linux with --history local and an indexer: 179 MB RSS at 26 min in one run; a three-hour run reached 276 MB. Short macOS soaks with --window 32: 99–105 MB median. Typical measured CPU: 0.25–0.5 core. Restart data is saved by default; optional history and archives add disk use. See hardware requirements for conditions and remaining memory questions.

Warm execution and cold-fetch latency depend on the workload. skim saves resources by keeping a working set and asking SNAP peers for missing state. Its consensus light client authenticates safe / finalized; default latest remains optimistic, and --require-verified opts into signed heads. See the trust model.

Architecture

flowchart LR
  subgraph P2P["Ethereum P2P (configured Glamsterdam network)"]
    CL["CL gossip<br/>execution_payload + BAL"]
    EL["EL peers<br/>eth/71 BALs · snap/1 · snap/2"]
  end
  CL -->|verify: rebuilt header hash matches block_hash| CHAIN[Chain placer<br/>reorgs · gaps]
  EL -->|gap fill| CHAIN
  CHAIN -->|BAL writes for tracked keys| MVCC[(Per-key MVCC store<br/>subscribed · on-demand TTL)]
  SUB[Subscriptions<br/>accounts · slots · simulations] --> PRED[State access predictor<br/>learned sets · V3 ticks]
  PRED -->|prefetch, low priority| SCHED
  SIM[revm simulator] -->|miss| SCHED[Adaptive SNAP scheduler<br/>AIMD · hedging · priorities]
  SCHED -->|Merkle-verified values| MVCC
  EL --- SCHED
  MVCC --> SIM
  SIM --> RPC[JSON-RPC :8545<br/>eth_call · estimateGas · getStorageAt …]
  SIM --> API[Rust API]
Loading
Crate Role
skim-bal EIP-7928 decoding, commitment checks, per-key normalization
skim-cl Gloas gossip and req/resp over discv5/libp2p (QUIC and TCP), network presets, payload verification and consensus light-client primitives
skim-el EL networking on reth: eth/71 BALs, snap/2 and a snap/1 client, head following
skim-snap Adaptive request scheduler and SNAP Merkle-proof verification
skim-state Per-key MVCC store (BAL application, base inserts with replay, reorg rollback, retention) and the selective on-disk archive
skim-sim revm over snapshots, state access predictor, subscriptions
skim-chain Verified headers, bodies, receipts, BALs, transaction lookup/sending, log scans and the post-fork history store
skim-cli skim run (JSON-RPC), skim follow, probes and benchmarks

Quick start

curl -fsSL https://raw.githubusercontent.com/leekt/skim/main/install.sh | sh   # Linux, macOS
skim                                      # a Sepolia node, no config: JSON-RPC on 127.0.0.1:8545
skim status                               # how it's doing, with hints

No addresses need a subscription to start: every state read is fetched on demand, verified, and kept current while it's used. A config that subscribes your app's hot state is a latency optimization:

skim --config examples/uniswap-v3/skim.yaml

From source: git clone https://github.com/leekt/skim && cd skim && cargo build --release (Rust 1.97, set in rust-toolchain.toml); see installation. Or with Docker (docker build -t skim .; the image's default command is run --rpc 0.0.0.0:8545 --data-dir /data). Tagged releases publish Linux and macOS binaries and an image at ghcr.io/leekt/skim.

Wait for state ready in the log (logged for each applied block; skim_subscriptionStatus says when your subscriptions are ready). Then point any Ethereum tooling at http://127.0.0.1:8545:

import { createPublicClient, http, parseAbi, parseEther } from 'viem'
import { sepolia } from 'viem/chains'

const client = createPublicClient({ chain: sepolia, transport: http('http://127.0.0.1:8545') })
const { result } = await client.simulateContract({
  address: '0xEd1f6473345F45b75F8179591dd5bA1888cf2FB3',          // Uniswap V3 QuoterV2
  abi: parseAbi(['function quoteExactInputSingle((address,address,uint256,uint24,uint160)) returns (uint256,uint160,uint32,uint256)']),
  functionName: 'quoteExactInputSingle',
  args: [['0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14', '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238', parseEther('1'), 3000, 0n]],
})
// answered locally in a few ms

Monitoring: run skim with --metrics 127.0.0.1:9465 and start the bundled Prometheus and Grafana stack (metrics/):

docker compose -f metrics/docker-compose.yaml up -d     # Grafana on port 3001, skim dashboard preloaded

The dashboard shows:

  • peers by client, with SNAP latency per peer
  • block arrival and RPC latency per method
  • which addresses your calls read, and which of them aren't covered by a subscription
  • what skim serves to other peers, and process CPU and memory
  • estimated retained payloads, entries and capacities by structure, compared with RSS

skim Grafana dashboard

Documentation

The documentation index lists the guides and experiment logs. Start with the comparison or app setup guide, then follow the references for your workload.

What you need Guide
Choose between skim, Helios, a full node and an archive node skim compared
Choose subscriptions, on-demand reads, history and simulations for your app What to set up for your app
Build, run and make your first call Getting started
Understand BALs, verified fetches and state prediction How skim works
Configure skim.yaml and skim run Configuration reference
Integrate RPC methods, subscriptions and block streams JSON-RPC reference
Keep selective state history and backfill it Archiving history for your state
Size a machine and understand the measured footprint Hardware requirements
Tune memory and latency, with the reasons behind each default Memory and latency
Understand what is verified and what is trusted Trust and correctness
Diagnose unavailable state, missing peers and other issues Troubleshooting

Runnable examples: Uniswap V3 and V4, WebSocket subscriptions, sending transactions, and ENS resolution with bigname and smallname. For monitoring, see the Prometheus and Grafana setup.

For network development, see the local devnet walkthrough. PROJECT.md describes the implemented architecture; the contributor handoff records current work and library boundaries.

JSON-RPC

Supported methods:

  • State and simulation: eth_call (with state overrides), eth_simulateV1, debug_traceCall / debug_traceTransaction / debug_traceBlockBy* (default, callTracer, prestateTracer, 4byteTracer, muxTracer), eth_createAccessList, eth_getProof, eth_estimateGas, eth_getBalance, eth_getCode, eth_getStorageAt, eth_getTransactionCount (including pending)
  • Transactions: eth_sendRawTransaction (gossiped straight to EL peers), eth_getTransactionByHash, eth_getTransactionReceipt, eth_getTransactionByBlock*AndIndex. Lookups by hash cover recent transactions that change a subscribed address (--tx-index subscribed, the default) or every transaction (--tx-index all), plus every transaction sent through skim; others return null.
  • Logs: eth_getLogs. Receipts are fetched only for blocks whose bloom and BAL match, and checked against receiptsRoot.
  • Blocks and chain: eth_getBlockByNumber / eth_getBlockByHash (full transactions), eth_getBlockReceipts, eth_feeHistory, eth_getBlockTransactionCountBy*, eth_blockNumber, eth_chainId, net_version, net_listening, eth_syncing, eth_gasPrice, eth_maxPriorityFeePerGas, web3_clientVersion
  • skim_subscriptionStatus, skim_status (including per-structure memory estimates), skim_dashboard, skim_archiveCoverage, skim_streamNext (stateless block streams with log/BAL changes, reorg rewinds and gaps)
  • WebSocket on the same port: eth_subscribe for newHeads, logs and skim_subscriptions

Errors:

  • Out-of-scope methods (eth_sendTransaction, since skim holds no keys, and the explicitly unsupported HTTP filter/subscription methods) return -32004. WebSocket subscriptions are supported; unknown methods return -32601.
  • State or blocks skim can't serve return -32002. skim never returns a fabricated value.

Correctness rules skim lives by

  • Unknown is never zero. A value is used only if it was written by a BAL, or fetched and verified.
  • A SNAP answer must prove itself. Accounts are checked against the state root, slots against the verified storage root. A slot is zero only when proven: absent account, empty trie, or an exclusion proof. Anything else is "unavailable" and gets retried elsewhere.
  • Fetch at the parent, replay to the head. At gossip time peers usually haven't executed the new block yet. skim reads at root(H−1) and applies BAL(H) itself.
  • Reorgs roll back only to the common ancestor, using the EL chain, and replay from there.

Trust model

Check Scope
BAL ↔ block hash (header rebuilt from the payload), block hash ↔ builder bid Every accepted payload
Every SNAP value ↔ the block's state root (Merkle proofs); code, body and receipt commitments Every fetched value or block item served
Sync-committee signatures and Merkle branches, including committee rotation Light-client updates after a trusted-checkpoint bootstrap; used by safe, finalized and --require-verified

Default latest follows the newest state-ready gossiped block. Verified tags require usable light-client data and matching hashes on the selected chain; they return -32002 until available. skim does not re-execute every block or implement full consensus fork choice.

skim doesn't hold the full state, so it can't recompute a block's state root after applying its BAL. A BAL is committed in the header, so for safe and finalized blocks the values are as trustworthy as the header. On default latest, the risk is a gossiped block that consensus later rejects. See trust and correctness.

Status and roadmap

Open work, with what "done" means for each item: docs/TODO.md.

  • M0: P2P feasibility (CL payload gossip, eth/71, snap/1+2, adaptive scheduler)
  • M1: BAL state follower with MVCC store
  • M2: simulator, JSON-RPC adapter, verified state, state access predictor
  • M3: subscriptions (subscribed vs on-demand state)
  • Resource baseline vs a Nethermind full node at the same block hashes (P3); conditions and limits are recorded with the measurements
  • M4: selective state archiving. retention: archive keeps every BAL-written version of subscribed state on disk. eth_getStorageAt and eth_call then work at historical blocks for what you subscribed to, from the block each key became known (90/90 and 51/51 historical reads matched a reference archive node, restart gaps healed from eth/71; see M4). Sync and archive only what you need.
  • M4.2: backfill from retained BALs. --archive-backfill N targets N blocks before the archive's first block, subject to BAL availability and per-key coverage. Unchanged keys can be covered for the whole scan; written keys are covered from their earliest recovered write. Live: 102 historical reads matched a reference archive node, 3 were correctly refused, 0 were wrong.
  • M5: generalized learned patterns (exact args + amount buckets) with key/byte bounds. The V3 predictor is configurable in skim run: calls that needed the network fell from 51 to 19 out of 400. Added eth_sendRawTransaction, receipts and eth_getLogs, all identical to a reference node in every check (M5).
  • storage: all: a contract's whole storage, downloaded and verified against its storage root, then kept current from BALs (Uniswap V3 pool: 3,677 slots; 29/29 sampled slots identical to a reference node). WebSocket JSON-RPC: newHeads, logs, and skim_subscriptions pushes with every block's on_block results
  • M6: correctness and bounds. Review fixes with regression tests. 2 h chaos soak (kills, restarts, key-set switches): 3,465 matches, 23 refusals, 0 wrong. Verified restart snapshots, warm eth_call 3× faster (M6)
  • Serving the network: recent headers, bodies, receipts and BALs (eth/69–71, snap/2), bytecode, and payload envelopes by range/root (consensus), each re-checked against its header before it is sent (M6)
  • M7: regression gates. Review findings each have a deterministic test, in CI on every push. A soak gates on availability and recovery as well as wrong answers, and exercises storage-mode transitions on purpose. Also: peer-acquisition tracking with a ranked saved-peer list, parallel restart catch-up (655 blocks in ~9 s), Grafana alerts, release binaries and a container image (M7)
  • Consensus light-client verification and committee rotation for both presets; verified end to end on a minimal-preset devnet. Availability on public networks depends on peers serving Gloas light-client data
  • Configured Glamsterdam networks (--network <dir>), including minimal-preset devnets, QUIC discovery and scheduled fork-digest topic changes; a live blob-parameter fork crossed on the devnet without losing peers
  • Transaction index limited to transactions that change a subscribed address (--tx-index), with a bounded lifecycle for sent transactions
  • TCP peers that require a muxer other than yamux, and RSS over a 27-hour run (see open work)

Design: PROJECT.md. Guides and experiment logs: docs/.

License

Licensed under either of Apache License 2.0 or MIT, at your option.

About

Application-specific Ethereum state client: sync (and archive) only the state your app needs, straight from P2P. BAL-driven, verified, local eth_call in ms.

Topics

Resources

Stars

73 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages