A smart-money indexer for Uniswap V3 on Ethereum.
Ingest → decode → per-wallet PnL → a read API and Telegram alerts — reorg-safe,
exactly-once, and flat on disk. Built two ways behind one seam: an in-process
channel and a distributed Redpanda log.
docker compose up -d · API on :8080 · Grafana on :3000
chainscope follows Uniswap V3 on Ethereum mainnet and answers one question in near real time: which wallets are winning. It ingests swaps and liquidity events, computes each wallet's realised PnL on a FIFO cost basis, serves it over a read API, and pushes Telegram alerts when smart money moves.
The point is not another price feed. It is being correct under the two failure modes that usually get hand-waved — chain reorganisations and process crashes — so every derived number can be proven right after a block is orphaned or the process is killed mid-write.
- Exactly-once — a write and the cursor that names it commit in one transaction.
- Reorg-safe — orphaned blocks and everything derived from them (PnL, candles, the leaderboard) are walked backwards, not left stale.
- Flat on disk — raw events roll off past finality; candles and wallet aggregates are the permanent record they burn into.
| Audience | What they get |
|---|---|
| Analysts / traders | A live smart-money leaderboard and per-wallet PnL scorecards, wash-trade filtered. |
| Alert consumers | Telegram pings on watchlist moves, coordinated cluster buys, and fresh pools clearing a scorecard. |
| Integrators | A read API — pools, swaps, OHLCV candles, wallet trades, /metrics — with keyset pagination. |
| Operators | One-command Docker stack, Prometheus metrics, and a provisioned Grafana dashboard. |
| Developers | A full Rust workspace — chain source, pipeline, API, alerter — with exactly-once and reorg recovery implemented two ways to fork and study. |
Every stage publishes to a transport seam and reads from it; none calls the next directly. One block of work flows through, in order:
producer ──[BlockUnit]──▶ transformer ──[RowBatch]──▶ writer ──▶ PnL · candles · retention
fetch decode exactly-once fold
- producer — walks the chain one block at a time from the stored cursor;
resumes rather than repeats after a restart. Typed RPC errors (
Transient,RangeTooLarge,BlockNotFound,Fatal) decide retry vs. stop. - transformer — decodes swaps and liquidity events into partitioned raw tables.
- writer — commits each batch and the cursor in one transaction: exactly-once by construction. PnL, candle folds and pool discovery extend that same transaction.
- retention — folds candles, rolls them up (1m→1h→1d), and drops raw partitions past a finality floor, keeping the footprint flat.
chainscope's thesis is that it implements exactly-once and reorg recovery two ways, behind the same seam, switchable with one line of config.
- Phase 1 — in-process channel (M1–M4). All stages run in one process over a bounded in-memory channel. Exactly-once is a single Postgres transaction; a crash leaves "all rows + cursor" or "neither", never a half-state.
- Phase 2 — Redpanda log (M5). The same stages split into separate processes reading a Kafka-compatible topic. Exactly-once becomes idempotent consumers keyed on offset, and a reorg is a compensating revert event on the log rather than a rollback inside one transaction.
Both satisfy the same behavioural tests. Switching is one setting in
chainscope.toml — no stage is touched:
[pipeline]
transport = "channel" # phase 1, one process
# transport = "redpanda" # phase 2, distributed; brokers under docker composeThe seam (crates/core/src/transport.rs) is the only place either transport is
named — enforced by tests/seam_is_not_leaking.rs, which fails the build if any
other file reaches for a channel or a Kafka client directly.
Three surfaces, all from one indexed store:
- Read — the API answers pools, swaps, OHLCV candles, wallet scorecards and the leaderboard, keyset-paginated with an opaque cursor and a hot-stats cache.
- Alert — the alerter polls the same database and pushes to Telegram on a watchlist move, a cluster of watched wallets buying the same pool, or a new pool clearing the scorecard threshold. It is a separate process so a hung outbound call can never backpressure ingestion.
- Observe — Prometheus scrapes the API's
/metrics; Grafana renders ingest lag, the block heads, and the raw-vs-aggregate disk footprint M9 keeps flat.
Everything runs under Docker. docker compose up builds the three binaries and
brings up the full stack — Postgres, indexer, API, alerter, Prometheus, Grafana.
# 1. configure — set RPC endpoint(s) and, for alerts, a Telegram bot token
cp .env.example .env
# 2. one command up
docker compose up -d --build
docker compose ps # postgres healthy, then indexer/api/alerter up
# 3. prove it end to end
./ops/smoke.sh # brings up, gates every health check, asserts the surfaceops/smoke.sh is the exit criterion: it waits for each service's health check, then
asserts /status and /metrics answer, Prometheus is actually scraping the API
(up == 1), and Grafana is serving — one green line or a named failure. To drive a
binary from the host instead, run just the database (migrations apply on startup):
docker compose up -d postgres
cargo run --bin chainscope-indexer| Surface | Where |
|---|---|
| Ingest status + lag | GET /status |
| Indexed / new pools | GET /pools, GET /pools/:address, GET /pools/new |
| Pool swaps / candles | GET /pools/:address/swaps, .../candles?resolution=1m|1h|1d |
| Wallet PnL scorecard | GET /wallets/:address |
| Wallet realised trades | GET /wallets/:address/trades |
| Smart-money leaderboard | GET /leaderboard |
| Prometheus metrics / health | GET /metrics, GET /healthz |
| Grafana dashboard | http://localhost:3000 (admin/admin) — lag, heads, footprint |
| Prometheus | http://localhost:9090 |
The read API and alerter are read-only consumers of the store the indexer owns:
ChainSource (JSON-RPC) ← the only thing that knows Ethereum exists
│ blocks + logs
▼
indexer pipeline ← fetch → decode → PnL → retention, exactly-once
│ writes
▼
Postgres (partitioned) ← raw events roll off; candles + PnL are permanent
│ read-only
├────────────▶ api ← pools, swaps, candles, scorecards, /metrics
└────────────▶ alerter ← Telegram: moves, cluster buys, new pools
Only crates/eth-source knows Ethereum exists — cargo tree -p chainscope-core
shows no chain library at all, the boundary enforced by the compiler rather than by
discipline.
chainscope/
├─ crates/
│ ├─ core/ # chain-agnostic domain types, cursor, the transport seam
│ └─ eth-source/ # the only crate that knows Ethereum: ChainSource + decoders
├─ bins/
│ ├─ indexer/ # ingestion pipeline: fetch → decode → PnL → retention
│ ├─ api/ # read API (axum): keyset pagination, hot-stats cache, /metrics
│ └─ alerter/ # Telegram alerts: watchlist moves, cluster buys, new pools
├─ migrations/ # embedded, applied on startup
├─ ops/ # Dockerfile helpers, prometheus.yml, grafana provisioning, smoke.sh
└─ scripts/ # redpanda topic setup (phase 2)
chainscope is verified at levels that each catch what the one below can't — many are behavioural, asserting an invariant rather than a fixed output:
| Level | Where | What it proves |
|---|---|---|
| Unit | cargo test (per crate) |
math, decoding, cursor and config logic |
| Crash resumability | tests/crash_resumability.rs |
kill at any point, resume gap-free — 50 randomised trials |
| Reorg recovery | tests/reorg_* |
orphaned blocks and their derived rows walk backwards |
| Seam isolation | tests/seam_is_not_leaking.rs |
no stage names a transport directly |
| Store integration | tests/*_db.rs |
writer, PnL, candles, retention, API against real Postgres |
| Log transport | tests/kafka_* |
idempotent consumers, offsets, broadcast under a storm |
| End-to-end | ops/smoke.sh |
one command up, stack healthy, dashboard live |
The load-bearing test is crash_resumability: it runs the real producer and writer
against a synthetic chain, aborts at a randomised point, restarts from the cursor,
and asserts blocks form a gap-free run with no duplicates — and a companion test
deliberately breaks atomicity to prove the invariant can actually fail.
Every milestone is complete — the stack comes up with one command, reaches the mainnet tip, and renders live on Grafana.
| Milestone | What it added |
|---|---|
| ✅ M1 | Crash-safe ingestion: single-transaction exactly-once, cursor resume, supervised shutdown |
| ✅ M2 | Event decoding: swaps + liquidity events into partitioned raw tables |
| ✅ M3 | Throughput: concurrent backfill, batching, range bisection under RPC limits |
| ✅ M4 | Reorg recovery: fork detection, rollback of orphaned blocks and everything derived |
| ✅ M5 | Phase-2 transport: Redpanda log, separate processes, idempotent consumers + revert events |
| ✅ M6 | Per-wallet PnL: FIFO cost basis, lot-consumption ledger for exact reorg reversal, wash flagging |
| ✅ M7 | Read API: keyset pagination, hot-stats cache, leaderboard materialised view |
| ✅ M8 | Alerts + sniffer: Telegram watchlist moves, cluster buys, new-pool scorecards |
| ✅ M9 | Retention: live candle fold, 1m→1h→1d downsampling, partition pruning past finality |
| ✅ M10 | Ops: Prometheus /metrics, provisioned Grafana, Dockerfiles, one-command full-stack compose |
chainscope.toml is committed and holds everything shareable — chain id, pool list,
tuning knobs. .env is not committed and holds the secrets: the database URL and RPC
endpoints. Any value is overridable from the environment as
CHAINSCOPE_<SECTION>__<KEY> (the environment always wins), and DATABASE_URL /
RUST_LOG keep their conventional names.
Everything is validated before a socket opens — addresses must be 20 bytes of hex, the pool list non-empty and duplicate-free, unknown keys are errors — and a failure names the field and echoes the bad value rather than starting mis-configured.
RPC note (measured 2026-07-23): free endpoints differ wildly in history depth.
rpc.flashbots.netis the best keyless option; most others capeth_getLogsrange or want a token past ~128 blocks. Deep backfill (M3) needs a paid archive endpoint; the network tests derive a recent settled block from the tip rather than pinning historical block numbers, so they test the code, not a billing tier.
- Rust (stable) for the whole workspace — indexer, API, alerter, core, eth-source.
- Postgres 16 as the store; migrations are embedded and applied on startup.
- Docker + Docker Compose for the full stack.
- Redpanda (Kafka-compatible) for the phase-2 log transport.
- Prometheus + Grafana for metrics and the dashboard.
MIT