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