Build a production-ready Uniswap V2 indexer using Effect.ts with a React frontend.
An Ethereum indexer that tracks Uniswap V2 swaps, pairs, and reserves in real-time, stores them in SQLite, serves them via HTTP API, and displays them in a React UI.
| Component | Library | Notes |
|---|---|---|
| Runtime | Bun | Use bun:test for testing |
| Core | effect | Services, Layers, Streams, Config |
| HTTP Server | @effect/platform-bun | JSON API endpoints |
| Database | @effect/sql-sqlite-bun | SQLite storage |
| Ethereum | voltaire-effect | RPC types (but see notes below) |
| Frontend State | effect-atom + @effect-atom/atom-react | useAtomValue, useAtomSet hooks |
| Frontend | React 19 + Vite | Bundled separately |
┌─────────────────────────────────────────────────────────────┐
│ index.ts │
│ - Loads config from env (ALCHEMY_API_KEY, PORT, DB_PATH) │
│ - Composes layers │
│ - Runs HTTP server + indexer │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────────────┼─────────────────────┐
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ ApiServer │ │ Indexer │ │ AppConfig │
│ /api/pairs │ │ backfill() │ │ rpcUrls[] │
│ /api/swaps │ │ start() │ │ port │
│ /api/status │ │ blockStream │ │ dbPath │
└───────────────┘ └───────────────┘ └───────────────┘
│ │
└──────────┬──────────┘
▼
┌───────────────────┐
│ Repositories │
│ PairRepository │
│ SwapRepository │
│ IndexerState │
└───────────────────┘
│
▼
┌───────────────────┐
│ SQLite Database │
│ @effect/sql │
└───────────────────┘
# Start indexer and backfill last 100 blocks
bun run start --index --from=24340000
# In another terminal, query the API
curl http://localhost:3000/api/status
# Returns: {"lastProcessedBlock":24340100,"isRunning":true}
curl http://localhost:3000/api/swaps?limit=5
# Returns: Array of real Uniswap swaps from mainnet
bun test
# All tests pass-
Config Layer (
src/config/AppConfig.ts)- Read from env:
ALCHEMY_API_KEY(optional),PORT(default 3000),DB_PATH - Build
rpcUrls[]: Alchemy first if key exists, then public fallbacks - Public RPCs:
https://eth.llamarpc.com,https://rpc.ankr.com/eth
- Read from env:
-
Provider Service (
src/services/ProviderService.ts)getBlockNumber(): Effect<bigint, RpcError>getBlock(n): Effect<Block, RpcError>getLogs({fromBlock, toBlock, topics}): Effect<Log[], RpcError>blockStream(from): Stream<Block, RpcError>- RPC Fallback: Try URLs in sequence until one succeeds
- NO MOCK in main code - mock only in test layer
-
Database (
src/db/)- Schema:
pairs,swaps,indexer_statetables - Repositories as Effect Context.Tag services
- Return
Effect<T, SqlError.SqlError>
- Schema:
-
Indexer (
src/indexer/)- Event topics (hardcoded - don't compute at runtime):
Swap: "0xd78ad95fa46c994b6551d0da85fc275fe613ce37657fb8d5e3d130840159d822" Sync: "0x1c411e9a96e071241c2f21f7726b17ae89e3cab4c78be50e062b03a9fffbbad1" PairCreated: "0x0d3648bd0f6ba80134a33ba9275ac585d9d315f0ad8355cddefde31afa28d0e9"
backfill(from, to): Batch process historical blocks (100 blocks per getLogs call)start(from?): Backfill to head, then stream new blocks- Decode events manually (slice hex data, no ABI library needed)
- Event topics (hardcoded - don't compute at runtime):
-
HTTP API (
src/http/ApiServer.ts)GET /health→{ok: true}GET /api/pairs→ All pairsGET /api/pairs/:id→ Single pair (404 if not found)GET /api/swaps?limit=50→ Recent swapsGET /api/swaps/:pairId→ Swaps for pairGET /api/status→{lastProcessedBlock, updatedAt, isRunning}- CORS headers for frontend dev
-
Tests (
tests/)- Use
:memory:SQLite for repository tests - Create
ProviderServiceMocklayer for indexer tests (only in test files) - Test event decoding with real log data samples
- Use
├── src/
│ ├── config/AppConfig.ts
│ ├── db/
│ │ ├── schema.ts
│ │ └── repository.ts
│ ├── indexer/
│ │ ├── events.ts # Topic hashes + decoders
│ │ ├── BlockProcessor.ts
│ │ └── Indexer.ts
│ ├── http/ApiServer.ts
│ ├── services/
│ │ ├── DatabaseService.ts
│ │ └── ProviderService.ts
│ └── types/index.ts
├── tests/
│ ├── repository.test.ts
│ └── indexer.test.ts
├── index.ts
├── package.json
└── .env.example
# Terminal 1: Backend
bun run start --index --from=24340000
# Terminal 2: Frontend
bun run dev:ui
# Open http://localhost:5173
# See: Status bar (green "Connected", block number updating)
# See: Trading Pairs table with real pairs
# See: Recent Swaps table with real swaps
# Click pair → Price panel shows reserves-
Vite Setup (
vite.config.ts,web/)- Proxy
/api/*to backend at localhost:3000 - React 19 with @vitejs/plugin-react
- Proxy
-
Atoms (
src/frontend/atoms.ts)pairsAtom: Atom<Pair[]>recentSwapsAtom: Atom<Swap[]>selectedPairIdAtom: Atom<string | null>indexerStatusAtom: Atom<{isRunning, lastBlock, error}>isLoadingAtom: Atom<boolean>
-
App Component (
src/frontend/App.tsx)- Use
useAtomValue/useAtomSetfrom@effect-atom/atom-react - Wrap app in
<RegistryProvider> - Fetch from
/api/*on mount - Poll
/api/statusevery 5 seconds
- Use
-
UI Components
- Status bar: Connection indicator, last block
- Pairs table: Clickable rows, show reserves
- Swaps table: Time, pair, buy/sell, amount, etherscan link
- Price panel: Shows when pair selected
├── web/
│ ├── index.html
│ └── main.tsx # RegistryProvider + App
├── src/frontend/
│ ├── atoms.ts
│ └── App.tsx
├── vite.config.ts
- Indexer recovers from RPC errors without crashing
- Can backfill 10,000+ blocks without memory issues
- Logs are structured and useful
-
Structured Logging
- Use Effect's logging with timestamps
- Log: blocks processed, errors, RPC fallbacks
-
Error Recovery
- Retry failed RPC calls with exponential backoff
- On persistent failure, log and continue to next block
- Never crash the HTTP server
-
Graceful Shutdown
- Handle SIGINT/SIGTERM
- Commit current block before exit
-
Performance
- Batch inserts for swaps (insert many per transaction)
- Index database columns used in queries
Factory: 0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f
Deployed at block: 10000835
// Real Swap log from mainnet
const SAMPLE_SWAP_LOG = {
address: "0x0d4a11d5eeaac28ec3f61d100daf4d40471f1852",
topics: [
"0xd78ad95fa46c994b6551d0da85fc275fe613ce37657fb8d5e3d130840159d822",
"0x0000000000000000000000003fc91a3afd70395cd496c647d5a6cc9d4b2b7fad",
"0x0000000000000000000000003fc91a3afd70395cd496c647d5a6cc9d4b2b7fad"
],
data: "0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003b9aca00000000000000000000000000000000000000000000000000016345785d8a00000000000000000000000000000000000000000000000000000000000000000000",
blockNumber: "0x173b5e7",
transactionHash: "0x...",
logIndex: "0x42"
}1. https://eth-mainnet.g.alchemy.com/v2/{ALCHEMY_API_KEY} (if key set)
2. https://eth.llamarpc.com
3. https://rpc.ankr.com/eth
4. https://ethereum.publicnode.com
- NO mocks in production code -
ProviderServiceMockbelongs intests/only - NO dynamic topic computation - Hardcode the hex strings
- NO ABI decoding libraries - Slice hex manually, it's simple enough
- NO
useStatein frontend - Use effect-atom throughout - NO polling from indexer - Use proper block streaming
- NO
anytypes - Everything typed with Effect's error channel
# Development
bun install
bun run start # API server only
bun run start --index # API + indexer from last block
bun run start --index --from=24340000 # API + indexer from specific block
bun run dev:ui # Frontend dev server
# Testing
bun test # All tests
bun run typecheck # TypeScript check
# Production
bun run build:ui # Build frontend