Not every app needs a full node. Sync (and archive) only what you need.
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, ethersUntil 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 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.
| 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.
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.
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]
| 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 |
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 hintsNo 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.yamlFrom 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 msMonitoring: 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 preloadedThe 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
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.
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(includingpending) - 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 againstreceiptsRoot. - 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_subscribefornewHeads,logsandskim_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.
- 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.
| 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.
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: archivekeeps every BAL-written version of subscribed state on disk.eth_getStorageAtandeth_callthen 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 Ntargets 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. Addedeth_sendRawTransaction, receipts andeth_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, andskim_subscriptionspushes with every block'son_blockresults - 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_call3× 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/.
Licensed under either of Apache License 2.0 or MIT, at your option.
