A high-performance API for indexing ERC20 token Transfer events across multiple EVM chains and querying EOA holder balances.
- Multi-Chain Support: Index 9MM tokens across multiple EVM chains simultaneously
- EOA-Only Filtering: Automatically filters out contracts, LPs, and zero address
- Continuous Sync: Indexes from start block and keeps syncing new blocks forever for each chain
- Resumable: Tracks progress per chain in SQLite, resumes from last indexed block on restart
- Pre-computed Balances: Instant
/holdersresponse with pre-computed balance table per chain - Response Caching: 30-second TTL cache for frequently accessed endpoints
- Gzip Compression: Automatic compression for large JSON responses
- Batch RPC: Batch JSON-RPC calls for efficient EOA checking (100 addresses/batch)
- Retry with Backoff: Exponential backoff retry for RPC failures
- Prometheus Metrics: Built-in
/metricsendpoint for monitoring (per-chain metrics) - WAL Mode: SQLite Write-Ahead Logging for better concurrent performance
- Kubernetes Ready: Includes Dockerfile and K8s manifests for production deployment
The indexer supports any EVM-compatible chain. Currently indexing 9MM token on:
- PulseChain (369) -
0x7b39712Ef45F7dcED2bBDF11F3D5046bA61dA719 - Base (8453) -
0xe290816384416fb1dB9225e176b716346dB9f9fE - Sonic (146) -
0xC5cB0B67D24d72b9D86059344c88Fb3cE93BF37C - Ethereum (1) -
0x824b556259C69d7F2F12F3b21811Cfb00CE126aF
| Endpoint | Description |
|---|---|
GET /chains |
List all configured chains |
GET /holders?chain_id=369 |
Get all token holders for a chain |
GET /holders?chain_id=369&include_contracts=true |
Include contract addresses |
GET /status |
Sync status for all chains |
GET /status?chain_id=369 |
Sync status for specific chain |
GET /stats |
Statistics for all chains |
GET /stats?chain_id=369 |
Statistics for specific chain |
GET /health |
Health check (for K8s probes) |
GET /metrics |
Prometheus metrics |
Live API: https://index-api.9mm.pro
Examples:
# Get PulseChain holders
curl https://index-api.9mm.pro/holders?chain_id=369
# Check sync progress
curl https://index-api.9mm.pro/status
# Get all chains
curl https://index-api.9mm.pro/chains- Clone and setup environment:
cd Indexer
cp .env.example .env- Configure chains in
.env:
Edit .env and configure your chains using the CHAINS_CONFIG JSON format:
CHAINS_CONFIG=[
{"chain_id": 1, "chain_name": "Ethereum", "rpc_url": "https://eth.llamarpc.com", "token_address": "0x...", "start_block": 0},
{"chain_id": 137, "chain_name": "Polygon", "rpc_url": "https://polygon-rpc.com", "token_address": "0x...", "start_block": 0}
]- Install dependencies:
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
pip install -r requirements.txt- Run the server:
uvicorn app.main:app --reloadThe API will be available at http://localhost:8000
# Build and run with docker-compose
docker-compose up -d
# Or build manually
docker build -t token-indexer .
docker run -p 8000:8000 -v indexer_data:/data \
-e CHAINS_CONFIG='[{"chain_id": 1, "chain_name": "Ethereum", "rpc_url": "https://eth.llamarpc.com", "token_address": "0x...", "start_block": 0}]' \
token-indexerFor reliable indexing, we recommend using Alchemy (free tier available):
- Sign up at https://alchemy.com
- Create apps for Ethereum, Base, and Sonic
- Use your API key in the RPC URLs
Why Alchemy? Public RPCs often truncate eth_getLogs results, causing missed transfers.
The indexer supports multiple configuration methods:
Set CHAINS_CONFIG as a JSON array:
CHAINS_CONFIG=[{"chain_id": 1, "chain_name": "Ethereum", "rpc_url": "https://eth.llamarpc.com", "token_address": "0x...", "start_block": 0}, {"chain_id": 137, "chain_name": "Polygon", "rpc_url": "https://polygon-rpc.com", "token_address": "0x...", "start_block": 0}]CHAIN_IDS=1,137,56
CHAIN_1_NAME=Ethereum
CHAIN_1_RPC_URL=https://eth.llamarpc.com
CHAIN_1_TOKEN_ADDRESS=0x...
CHAIN_1_START_BLOCK=0
CHAIN_137_NAME=Polygon
CHAIN_137_RPC_URL=https://polygon-rpc.com
CHAIN_137_TOKEN_ADDRESS=0x...
CHAIN_137_START_BLOCK=0RPC_URL=https://rpc.pulsechain.com
TOKEN_ADDRESS=0x7b39712Ef45F7dcED2bBDF11F3D5046bA61dA719
START_BLOCK=20326117
CHAIN_ID=369
CHAIN_NAME=PulseChainGet all registered chains.
Response:
{
"chains": [
{
"chain_id": 1,
"chain_name": "Ethereum",
"token_address": "0x...",
"start_block": 0,
"is_active": true
}
]
}Returns all EOA token holders with their balances for a specific chain. Response is cached for 30 seconds and gzip compressed.
Query Parameters:
chain_id(required): Chain ID to queryinclude_contracts(optional): Include contract addresses (default: false)
Response:
{
"chain_id": 1,
"chain_name": "Ethereum",
"token_address": "0x...",
"holder_count": 43000,
"last_indexed_block": 24903456,
"sync_in_progress": false,
"holders": [
{"address": "0x...", "balance": "1000000000000000000"},
...
]
}Health check endpoint for Kubernetes probes. Returns status of all chains.
Response:
{
"status": "healthy",
"chains": [
{
"chain_id": 1,
"chain_name": "Ethereum",
"token_address": "0x...",
"start_block": 0,
"is_active": true
}
],
"any_syncing": false
}Detailed sync status. If chain_id is not provided, returns status for all chains.
Query Parameters:
chain_id(optional): Filter by chain ID
Response (single chain):
{
"chains": [
{
"chain_id": 1,
"chain_name": "Ethereum",
"last_indexed_block": 24900000,
"chain_head_block": 24903456,
"blocks_behind": 3456,
"is_syncing": true,
"addresses_checked": 50000
}
]
}Indexer statistics. If chain_id is not provided, returns stats for all chains.
Query Parameters:
chain_id(optional): Filter by chain ID
Response (single chain):
{
"chain_id": 1,
"chain_name": "Ethereum",
"token_address": "0x...",
"total_transfers_indexed": 150000,
"eoa_holder_count": 43000,
"total_addresses_checked": 55000,
"total_eoa_addresses": 43000,
"total_contract_addresses": 12000,
"last_indexed_block": 24903456,
"sync_in_progress": false,
"start_block": 0
}Response (all chains):
{
"chains": [
{
"chain_id": 1,
"chain_name": "Ethereum",
...
},
{
"chain_id": 137,
"chain_name": "Polygon",
...
}
]
}Prometheus metrics endpoint for monitoring.
Metrics exposed (per chain):
indexer_requests_total- Total HTTP requests by method, endpoint, statusindexer_request_latency_seconds- Request latency histogramindexer_holder_count{chain_id}- Current number of EOA holders per chainindexer_transfer_count{chain_id}- Total indexed transfers per chainindexer_last_indexed_block{chain_id}- Last indexed block number per chainindexer_blocks_behind{chain_id}- Blocks behind chain head per chainindexer_sync_in_progress{chain_id}- Sync status per chain (1=syncing, 0=idle)
A static HTML/JS frontend is available in balance-checker.html to query the API and display user balances.
Simply open balance-checker.html in your browser. Configure the API endpoint in the API_BASE constant if running locally.
The frontend is designed to be served via Nginx in Kubernetes. See k8s-frontend/ for manifests.
A deploy.sh script is provided to simplify Kubernetes operations.
chmod +x deploy.sh
# Deploy Backend API
./deploy.sh deploy-backend
# Deploy Frontend
./deploy.sh deploy-frontend
# Check Status
./deploy.sh status- Kubernetes cluster
kubectlconfigured- Container registry access
- (Optional) Prometheus for metrics scraping
- Build and push the Docker image:
docker build -t your-registry/token-indexer:latest .
docker push your-registry/token-indexer:latest- Update the deployment:
Edit k8s/deployment.yaml and replace your-registry/token-indexer:latest with your actual image.
- Configure chains in ConfigMap:
Edit k8s/configmap.yaml and update the CHAINS_CONFIG JSON array with your chain configurations.
- Update the Ingress (optional):
Edit k8s/ingress.yaml and replace indexer.yourdomain.com with your domain.
- Apply the manifests:
kubectl apply -f k8s/- Check status:
kubectl get pods -l app=token-indexer
kubectl logs -f deployment/token-indexerThe deployment includes Prometheus scrape annotations. If you have Prometheus Operator installed, metrics will be automatically scraped from /metrics. Metrics are labeled by chain_id for multi-chain monitoring.
Configuration is managed via the ConfigMap in k8s/configmap.yaml:
| Variable | Description | Default |
|---|---|---|
CHAINS_CONFIG |
JSON array of chain configurations | See example in configmap.yaml |
BATCH_SIZE |
Blocks per batch | 10000 |
DATABASE_PATH |
SQLite database path | /data/indexer.db |
┌─────────────────────────────────────────────────────────────┐
│ FastAPI Server │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Middleware: Gzip Compression | Metrics | CORS │ │
│ └─────────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────────┤
│ /chains │ /holders │ /health │ /status │ /stats │ /metrics│
│ ↓ ↓ ↓ ↓ ↓ │
│ [Direct] [Cache] [Direct] [Direct] [Cache] │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Multi-Chain Background Indexer │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Chain 1 │ │ Chain 137 │ │ Chain 56 │ │
│ │ Indexer │ │ Indexer │ │ Indexer │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ - Batch Transfer fetching (10k blocks) │
│ - Batch EOA checking (100 addresses/request) │
│ - Retry with exponential backoff │
│ - Updates pre-computed balances incrementally │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ SQLite Database (WAL Mode) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ chains │ │ transfers │ │ balances │ │
│ │ (config) │ │(per chain) │ │(per chain) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │address_types │ │ sync_state │ │
│ │(per chain) │ │ (per chain) │ │
│ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘
| Feature | Benefit |
|---|---|
| Pre-computed balances | O(n) read vs O(n²) calculation on each request |
| Response caching | 30s TTL eliminates redundant DB queries |
| Gzip compression | ~90% smaller response for large holder lists |
| Batch RPC calls | 100x fewer HTTP requests for EOA checking |
| SQLite WAL mode | Better concurrent read/write performance |
| Incremental updates | Only process new transfers, not entire history |
| Per-chain indexing | Independent sync progress per chain |
The database uses chain_id as a key component:
- chains: Chain configurations (chain_id, chain_name, rpc_url, token_address, start_block)
- transfers: Transfer events indexed by chain_id
- balances: Pre-computed balances per chain_id
- address_types: EOA/contract cache per chain_id
- sync_state: Sync progress per chain_id
- Initial sync may take several hours depending on RPC performance and chain size
- The indexer is resumable - continues from where it left off after restart
- Each chain syncs independently and concurrently
- SQLite database is persisted via PVC in Kubernetes
- All chains share the same database but data is isolated by chain_id
MIT