A blockchain transaction analysis platform for detecting and investigating money laundering patterns on Ethereum and EVM-compatible chains.
Chain Analysis models blockchain activity as a property graph. Analysts can explore transaction flows visually, label suspicious entities, group related addresses, run AML detection algorithms, queue targeted ingestion jobs, and monitor the whole pipeline from a browser.
Key capabilities:
- Interactive graph explorer with 1–2 hop neighborhood traversal
- Path finding between any two addresses
- Entity labeling with risk levels (unknown / low / medium / high / critical)
- Group management — tag related addresses into named investigation groups
- AML pattern detection (peel chains, structuring, round trips, fan-out/fan-in, mixer interactions)
- Human-in-the-loop labeling — queue targeted fetches, review tasks, submit annotations
- Rust ETL workers (Redis Streams) writing to both Neo4j+PostgreSQL and ClickHouse
- Dagster orchestration (backfills, reprocess schedules, Redis-queue sensor)
- Observability — Prometheus metrics, Grafana dashboards, Alertmanager routing
- Dashboard with system health and quick links to every operator tool
- MCP server for agent access to graph, group, feature, and labeling tools
| Layer | Technology |
|---|---|
| Graph DB | Neo4j 5.x + GDS plugin |
| Relational DB | PostgreSQL 17 |
| OLAP | ClickHouse |
| Message Queue | Redis Streams |
| Object Storage | MinIO (S3-compatible) |
| ETL Workers | Rust (tokio, reqwest, neo4rs, sqlx, clickhouse-rs) |
| Orchestration | Dagster (webserver + daemon) |
| Observability | Prometheus, Grafana, Alertmanager |
| Backend API | Python, FastAPI, SQLAlchemy async, Alembic |
| Frontend | React 18, TypeScript, Vite, Tailwind CSS, React Router v6 |
| Graph Visualization | Cytoscape.js (fcose layout) |
- Docker + Docker Compose
git clone <repo>
cd chain-analysis
cp compose/secrets.dev.env.example compose/secrets.dev.env
docker compose up -dOn first start the backend automatically:
- Runs PostgreSQL migrations (
alembic upgrade head) - Seeds known Ethereum labels into PostgreSQL
- Seeds default auth users
- Creates Neo4j constraints and indexes
- Seeds sample entities and transactions into Neo4j
| Service | URL |
|---|---|
| Frontend (Vite dev) | http://localhost:5173 |
| Backend API | http://localhost:8000 |
| MCP Endpoint | http://localhost:8000/mcp |
| API Docs (Swagger) | http://localhost:8000/docs |
| Neo4j Browser | http://localhost:7474 |
| Dagster UI | http://localhost:3000 |
| Grafana | http://localhost:3001 |
| Prometheus | http://localhost:9090 |
| Alertmanager | http://localhost:9093 |
| MinIO Console | http://localhost:9001 |
| ClickHouse HTTP | http://localhost:8123 |
Every operator tool above also appears as a tile on the in-app Dashboard.
Default local Neo4j credentials: neo4j / changeme.
docker compose build backend && docker compose up -d backend
docker compose build frontend && docker compose up -d frontendcd frontend
npm install
npm run dev # http://localhost:5173 — proxies API to localhost:8000cd backend
uvicorn src.api.main:app --reload --port 8000cd etl-rs
cargo build --release
# → target/release/ingest
# → target/release/process
# → target/release/clickhouse-processSee etl-rs/README.md for subcommands, env vars, orchestration, and observability details.
The backend now exposes an MCP server in two forms:
- Streamable HTTP:
http://localhost:8000/mcp - Stdio:
cd backend && pip install -e . && chain-analysis-mcp
Authentication:
- Streamable HTTP reuses the existing API bearer auth. First obtain a JWT from
/api/auth/loginor/api/auth/register, then sendAuthorization: Bearer <token>with MCP HTTP requests. - Stdio does not use HTTP bearer auth; it relies on local process access instead.
Typical agent/client configuration examples:
{
"mcpServers": {
"chain-analysis": {
"command": "chain-analysis-mcp"
}
}
}{
"mcpServers": {
"chain-analysis": {
"transport": "http",
"url": "http://localhost:8000/mcp",
"headers": {
"Authorization": "Bearer <token>"
}
}
}
}chain-analysis/
├── docker-compose.yml
├── compose/ # Modular compose includes (infra, app, etl, dagster, prometheus, grafana)
│ ├── prometheus.config.yml # Prometheus scrape config
│ └── grafana/ # Grafana provisioning + dashboard JSON
├── scripts/ # Init + seed scripts (run by backend on startup)
├── etl-rs/ # Rust ETL workspace (types, config, sources, sinks, pipeline, ingest, process)
├── backend/
│ └── src/
│ ├── api/
│ │ ├── routes/ # FastAPI route handlers (entities, groups, labels, pipeline, stats, health, auth)
│ │ └── models/ # Pydantic request/response schemas
│ ├── core/
│ │ ├── ports/ # Database + storage interfaces (protocols)
│ │ └── adapters/ # Neo4j, PostgreSQL, Redis, MinIO implementations
│ ├── etl/ # Dagster assets, ops, jobs, sensors, schedules
│ ├── graph/ # AML Cypher detection queries
│ └── db/ # SQLAlchemy models + Alembic migrations
└── frontend/
└── src/
├── pages/ # HomePage, LoginPage, SignupPage, GraphExplorerPage, GroupsPage,
│ # LabelsPage, ETLPage, DashboardPage, AdminUsersPage
├── components/ # Shared UI (Nav, GraphCanvas, NodePanel, TxPanel, SearchBar, ...)
├── hooks/ # useHealth, useGraphStats, useToast
├── api/client.ts # Typed API client
└── types/index.ts # TypeScript interfaces
| Method | Path | Description |
|---|---|---|
| GET | /api/entities/{address} |
Fetch entity |
| PUT | /api/entities/{address} |
Upsert entity |
| PATCH | /api/entities/{address} |
Partial update |
| DELETE | /api/entities/{address} |
Delete entity |
| GET | /api/entities/{address}/neighbors |
1–3 hop neighbors |
| GET | /api/entities/{src}/paths/{tgt} |
Find paths |
| GET | /api/transactions/{hash} |
Fetch transaction |
| PUT | /api/transactions/{hash} |
Upsert transaction |
| DELETE | /api/transactions/{hash} |
Delete transaction |
| Method | Path | Description |
|---|---|---|
| GET | /api/groups |
List all groups |
| POST | /api/groups |
Create group |
| GET | /api/groups/{address} |
Get group + members |
| PATCH | /api/groups/{address} |
Update group |
| DELETE | /api/groups/{address} |
Delete group |
| GET | /api/entities/{address}/members |
List members |
| POST | /api/entities/{address}/members |
Add member |
| DELETE | /api/entities/{address}/members/{member} |
Remove member |
| Method | Path | Description |
|---|---|---|
| POST | /api/labels/fetch |
Queue targeted fetch (addresses / hashes / neighborhood) |
| GET | /api/labels/tasks |
List label tasks (optional status filter) |
| GET | /api/labels/tasks/{task_id} |
Get single task |
| POST | /api/labels/annotations |
Submit annotation (flips task to completed) |
| GET | /api/labels/annotations/{address} |
Annotations for an address |
| Method | Path | Description |
|---|---|---|
| POST | /api/pipeline/ingest-address |
Fetch address via Etherscan → Neo4j + PG |
| GET | /api/ingestion-runs |
List ingestion runs |
| GET | /api/ingestion-runs/{run_id} |
Get single run |
| Method | Path | Description |
|---|---|---|
| POST | /api/auth/login |
JWT login |
| POST | /api/auth/register |
Register user |
| GET | /api/auth/me |
Current user |
| GET | /api/admin/users |
List users (admin) |
| POST | /api/admin/users |
Create user (admin) |
| PATCH | /api/admin/users/{id} |
Update user (admin) |
| DELETE | /api/admin/users/{id} |
Delete user (admin) |
| Method | Path | Description |
|---|---|---|
| GET | /health/live |
Liveness probe |
| GET | /health/ready |
Readiness probe (Neo4j + PG) |
| GET | /health |
Full health check |
| GET | /api/stats |
Graph statistics |
(from:Entity)-[:SENT]->(tx:Transaction)-[:RECEIVED]->(to:Entity)
(member:Entity)-[:IN_GROUP]->(group:Entity)
(:Trace) -- call-level detail with unique `uid`
(:TokenTransfer) -- ERC-20 transfer events with unique `uid`
Entity labels: EOA, Contract, Mixer, LendingPool, Bridge, DEX, CEXHotWallet, Application
Risk levels: unknown, low, medium, high, critical
Parameterized Cypher queries in backend/src/graph/queries.py:
| Pattern | Description |
|---|---|
| Peel chain | Linear chain of single-output hops — common in layering |
| Structuring | Fan-out to many receivers within a block window |
| Round trip | Funds return to originating address |
| Fan-out / fan-in | Layering through intermediary addresses |
| Mixer interaction | Direct sends to/from known Mixer-labeled nodes |
- Queue a fetch — on
/labels, operators submit addresses, transaction hashes, or a neighborhood seed. The backend LPUSHes jobs to the Redis listingest:targeted_queueand creates pendinglabel_tasksrows. - Dagster drains the queue — the
targeted_queue_sensorticks every 30s and spawnstargeted_drain_job, which shells out toingest targeted from-label-tasks. - Analysts annotate — pending tasks appear in the task table on
/labels. Submitting the annotation form writes to theannotationstable and flips task status tocompleted. - Deep link from the explorer — the graph explorer's NodePanel has a Label this entity button that opens
/labels?address=0x…with the address prefilled.
Groups tag a flat set of addresses under a named label (e.g. "Tornado Cash Protocol", "Suspect Cluster A"). Rules:
- An address can belong to at most one group
- A group cannot contain itself
- Deleting a group requires removing all members first
- Membership is stored as
(member)-[:IN_GROUP]->(group)in Neo4j
Dagster runs alongside the stack (webserver on :3000, plus a daemon). It wraps the Rust ingest binary as subprocess-executed jobs.
| Job | Trigger |
|---|---|
backfill_job |
Launchpad (manual block range) |
reprocess_job |
Hourly schedules (etherscan :00, alchemy :15) |
targeted_drain_job |
Redis sensor on INGEST_TARGETED_QUEUE (30s) |
targeted_addresses_job |
Launchpad (ad-hoc) |
targeted_neighborhood_job |
Launchpad (ad-hoc) |
Schedules and the sensor default to stopped — enable them in the Dagster UI.
All three Rust workers expose Prometheus metrics on :${METRICS_PORT:-9100}/metrics. Prometheus scrapes them every 15s; Grafana ships with a provisioned ETL Overview dashboard; Alertmanager routes rule violations.
| Tool | URL | Role |
|---|---|---|
| Prometheus | http://localhost:9090 | Metrics + rule evaluation |
| Grafana | http://localhost:3001 | Dashboards (anonymous Viewer) |
Scrape config lives in compose/prometheus.config.yml. Grafana
provisioning + dashboard JSON lives in compose/grafana/.
See etl-rs/README.md for the full metric catalog.
For Docker Compose, copy compose/secrets.dev.env.example to
compose/secrets.dev.env before starting services. The real
compose/secrets.dev.env file is ignored by Git so each developer can keep
local secrets out of version control.
Copy .env.example to .env for non-Docker deployments. Core keys:
# Graph / OLTP
NEO4J_URI=bolt://localhost:7687
NEO4J_USER=neo4j
NEO4J_PASSWORD=password123
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_DB=chain_analysis
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres123
# OLAP
CLICKHOUSE_URL=http://localhost:8123
CLICKHOUSE_DATABASE=chain_analysis
CLICKHOUSE_USER=default
CLICKHOUSE_PASSWORD=
# Queue + storage
REDIS_URL=redis://localhost:6379
MINIO_ENDPOINT=localhost:9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin123
MINIO_BUCKET=chain-analysis
# Ingest provider — pick one
INGEST_SOURCE=etherscan # or: alchemy | mock
ETHERSCAN_API_KEY=
ALCHEMY_API_KEY=
# Auth
JWT_SECRET_KEY=change-me-in-production
The Rust workers accept additional INGEST_*, PROCESS_*, CLICKHOUSE_*, and DAGSTER_* knobs — documented in etl-rs/README.md.
# Backend
cd backend && pytest
# Rust ETL (unit only — no services required)
cd etl-rs && cargo test --workspace --lib --bins
# Rust ETL (integration — needs Redis + ClickHouse)
E2E_REDIS_URL=redis://localhost:6379 \
E2E_CLICKHOUSE_URL=http://localhost:8123 \
cargo test --workspace
# Frontend type-check + build
cd frontend && npm run build