Real-time transaction monitoring system for the Cardano blockchain. Ingests blocks and mempool events via Ogmios, tracks full transaction lifecycle (PENDING → CONFIRMED → ROLLED_BACK), scores transactions against 9 Polimi attack classes (Token Dust, Large Value, Large Datum, Multiple Satisfaction, Front-Running, Sandwich, Circular Transfers, Fake Token, Phishing), and exposes a REST API and live WebSocket feed. Access is gated two ways: programmatic clients authenticate with a TMS-API-Key header, while the operator dashboard uses magic-link email login with role-based accounts (Admin / Reviewer) backed by PostgreSQL.
For step-by-step setup and operations see RUNBOOK.md.
- A running Cardano node + Ogmios v6: either external infrastructure (see RUNBOOK.md §Prerequisites) or the bundled local stack (see Ingestion modes below)
- Python 3.12+ (the optional clustering sidecar in
services/clustering/requires Python 3.13+) - Docker and Docker Compose
The cardano-node and ogmios services in docker-compose.yml are gated behind the ingestion Compose profile, so you can choose where transactions come from:
| Mode | How to start | When to use |
|---|---|---|
| Remote Ogmios | Set OGMIOS_WS_URL=ws://<host>:1337 in .env, then docker compose --profile app up |
Production, staging, or any environment with shared node infrastructure |
| Local full stack | Place node config at ./cardano-config/preprod/ (or set CARDANO_CONFIG_DIR), keep default OGMIOS_WS_URL=ws://localhost:1337, then docker compose --profile app --profile ingestion up |
Self-contained local development; requires ~30 GB disk and initial chain sync |
| No ingestion | docker compose --profile app up with no reachable Ogmios |
API/dashboard work against whatever has already been ingested (an empty database on first run) |
CARDANO_CONFIG_DIR must contain config.json and topology.json for the target network. Defaults to ./cardano-config/preprod.
The supported way to run the full stack is Docker Compose. The application (API + dashboard) is gated behind the app Compose profile.
# 1. Clone and enter the repository
git clone <repository-url>
cd <repository-directory>
# 2. Configure
cp .env.example .env
# Edit .env: set API_KEYS (or TMS_ALLOW_DEV_MODE=1 for local-only open access)
# and OGMIOS_WS_URL to point at your Ogmios v6 endpoint.
# 3. Start the full stack (databases + API + dashboard)
docker compose --profile app up -d
# API on http://localhost:8000, dashboard at http://localhost:8000/
# 4. Bootstrap the first dashboard account (one-time).
# Creates an Admin and emails a magic-link; --no-email prints the link to stdout.
docker compose exec app python -m app.cli create-admin you@example.com "Your Name" --no-emailWith no per-network override file present, the app monitors mainnet using its built-in defaults. To monitor a testnet, create the per-network file from the tracked template and select it with TMS_ENV:
cp .env.preprod.example .env.preprod # then edit OGMIOS_WS_URL
TMS_ENV=preprod docker compose --profile app up -d # preprod, API on 8000
cp .env.preview.example .env.preview # then edit OGMIOS_WS_URL
TMS_ENV=preview docker compose --profile app up -d # preview, API on 8001.env.preprod and .env.preview are gitignored; only the .example templates are tracked. Each sets CARDANO_NETWORK, OGMIOS_WS_URL, and API_PORT for its network. To run the bundled Cardano node and Ogmios as well, add the ingestion profile (see Ingestion modes).
For backend work you can run the API on the host against the Compose databases:
python3 -m venv venv && source venv/bin/activate
pip install -r requirements.txt
docker compose up -d postgres clickhouse mailpit # databases only
cd backend && python run.py # or ./scripts/start.shrun.py binds uvicorn to settings.API_PORT. A direct uvicorn app.main:app invocation needs --port on the CLI, because uvicorn does not read pydantic settings.
A first-party sidecar (services/clustering/) adds unsupervised, per-contract
profiling on top of the nine supervised scorers, surfaced as a tenth attack
class, contract_anomaly. It is fully opt-in and gated behind the clustering
Compose profile:
# Bring it up alongside the app (shares the ClickHouse server; its state lives
# in the tms_clustering database, chain reads come from tms_analytics):
CLUSTERING_ENABLED=true docker compose --profile app --profile clustering up -dCLUSTERING_ENABLED=true makes the app wire in the /api/clustering/*
reverse-proxy (for the Validators / cluster-graph UI) and merge the module's
contract_anomaly verdict into /api/analysis/*. Without the profile (or with
CLUSTERING_ENABLED unset) the system runs as the nine-class engine, unchanged.
See docs/ARCHITECTURE.md
for how it integrates and services/clustering/README.md
for the module itself.
Configuration is layered across files:
.env: values shared across every network (DB ports, API keys, rate limits, logging, Ogmios tuning)..env.preprod,.env.preview,.env.<name>: per-network overrides, each settingCARDANO_NETWORK,OGMIOS_WS_URL, andAPI_PORT. These files are gitignored; copy the tracked.env.preprod.example/.env.preview.exampletemplates to create them.
Selection is via TMS_ENV=<name> at launch. The selected per-network file is layered on top of .env if it exists; with none present, the app uses its built-in defaults (mainnet). Shell env vars override both files.
Per-network variables (defined in .env.<name>):
| Variable | Example | Description |
|---|---|---|
CARDANO_NETWORK |
preprod |
mainnet, preprod, or preview |
OGMIOS_WS_URL |
ws://<host>:1337 |
Ogmios WebSocket endpoint |
API_PORT |
8000 |
Port uvicorn binds to |
Shared variables (in .env):
| Variable | Default | Description |
|---|---|---|
API_KEYS |
(empty) | Comma-separated API keys. Empty = open access; the app refuses to start in that mode unless TMS_ALLOW_DEV_MODE=1 is also set |
RATE_LIMIT_ENABLED |
true |
Enable per-key rate limiting |
RATE_LIMIT_REQUESTS |
240 |
Max requests per window per key/IP |
RATE_LIMIT_WINDOW_SECONDS |
60 |
Sliding window duration |
ANALYSIS_ENGINE_ENABLED |
true |
Run background analysis engine |
ANALYSIS_ENGINE_INTERVAL_SECONDS |
30 |
Analysis poll interval |
ANALYSIS_ENABLED |
true |
Enable 9-class detection engine |
LOG_LEVEL |
INFO |
Log verbosity |
APP_BASE_URL |
http://localhost:8000 |
Base URL embedded in magic-link emails |
SESSION_TTL_DAYS |
7 |
Dashboard session lifetime |
MAGIC_LINK_TTL_MINUTES |
15 |
Magic-link validity window |
SMTP_HOST / SMTP_PORT / SMTP_FROM_EMAIL |
(compose: mailpit) | Outbound mail for magic-link delivery |
Postgres (POSTGRES_*), ClickHouse (CLICKHOUSE_*), the full SMTP/session/magic-link set, and trusted-proxy / CORS settings are documented in .env.example and the RUNBOOK configuration reference. Database defaults match the Docker Compose setup and rarely need changing. Two first-run notes: when API_KEYS is set, the app also refuses to start if CORS_ALLOW_ORIGINS is empty or * (set it to your dashboard origin); and TRUSTED_PROXY_ENABLED ships as true in .env.example while its code default is false.
The API accepts two authentication methods. Programmatic clients send a TMS-API-Key header. Browser/dashboard clients authenticate with a magic-link session cookie obtained via /api/auth/* (no API key needed). For local dev without a key set, boot with both API_KEYS= (empty) and TMS_ALLOW_DEV_MODE=1; requests are then accepted without either credential.
All data endpoints accept an optional network query parameter (mainnet, preprod, or preview); defaults to the instance's CARDANO_NETWORK.
Interactive docs: http://localhost:8000/docs
| Method | Path | Description |
|---|---|---|
| GET | /api/transactions/ |
List transactions (params: limit, before, address) |
| GET | /api/transactions/{tx_hash} |
Transaction detail with inputs, outputs, block_index |
| GET | /api/transactions/address/{address} |
Transactions for an address |
| GET | /api/transactions/stats/summary |
Aggregate stats (count, volume, fees) |
| GET | /api/transactions/blocks/recent |
Recent blocks derived from the transactions table (params: limit) |
| GET | /api/transactions/stats/throughput |
Recent transactions per minute over a sliding window (params: window_minutes) |
| Method | Path | Description |
|---|---|---|
| GET | /api/lifecycle |
List lifecycle records (params: status, limit, offset) |
| GET | /api/lifecycle/{tx_id} |
Lifecycle state for a single transaction |
| GET | /api/lifecycle/stats/summary |
Pending count, avg latency, rollback rate |
Lifecycle statuses: PENDING (mempool), CONFIRMED (in block), ROLLED_BACK (chain reorg), DROPPED (pending beyond TTL without confirmation).
| Method | Path | Description |
|---|---|---|
| GET | /api/analysis/results |
Analysis results (params: risk_band, min_score, min_corroboration, attack_class, sort, analyzed_from, analyzed_to, limit, offset). Valid attack_class values are the nine stored classes (token_dust, large_value, large_datum, multiple_sat, front_running, sandwich, circular, fake_token, phishing) plus the synthetic contract_anomaly (resolved at read time; returns empty when the clustering profile is off). |
| GET | /api/analysis/results/{tx_hash} |
Analysis result for a single transaction |
| GET | /api/analysis/stats |
Risk-band distribution and per-class score stats |
| GET | /api/analysis/stats/timeseries |
Daily High and Critical alert counts (params: days) |
| GET | /api/analysis/baselines/{scope_type}/{scope_id} |
Baseline percentiles for a scope |
| Method | Path | Description |
|---|---|---|
| POST | /api/auth/request-link |
Request a magic-link login email for an address |
| GET | /api/auth/verify |
Verify a magic-link token and start a session |
| POST | /api/auth/logout |
Invalidate the current session |
| GET | /api/auth/me |
Current authenticated user |
| GET | /api/users |
List users (Admin) |
| POST | /api/users |
Invite a user (Admin) |
| DELETE | /api/users/{user_id} |
Remove a user (Admin) |
| POST | /api/users/{user_id}/resend-invite |
Resend an invite magic-link (Admin) |
First-admin bootstrap is done from the CLI, not the API: python -m app.cli create-admin <email> "<name>" (see Setup).
| Method | Path | Description |
|---|---|---|
| POST | /api/archive |
Archive an analysis result as a known false positive (tx_hash, network, note, archived_by) |
| GET | /api/archive |
List archived (suppressed) results |
| POST | /api/archive/bulk |
Bulk-import archive entries |
| GET | /api/archive/export |
Export archived entries as CSV |
| GET | /api/archive/{tx_hash} |
Single archive entry |
| DELETE | /api/archive/{tx_hash} |
Restore (un-archive) an entry |
| Method | Path | Description |
|---|---|---|
| GET | /api/entities/{type}/{id} |
Entity state |
| PUT | /api/entities/{type}/{id} |
Set entity state |
| GET | /health |
Minimal unauthenticated liveness probe: {"status":"healthy"} |
| GET | /health/ready |
Unauthenticated readiness probe; returns 503 while the ingestion pipeline is DOWN (the intended load-balancer gate) |
| GET | /health/detail |
Full pipeline + Ogmios state (requires API key) |
| WS | /ws |
Real-time lifecycle events (TX_PENDING, TX_CONFIRMED, TX_ROLLED_BACK) |
| GET | / |
Operator dashboard (HTML) |
# List recent transactions
curl -H "TMS-API-Key: your-key" "http://localhost:8000/api/transactions/?limit=20&network=preprod"
# Check mempool
curl -H "TMS-API-Key: your-key" "http://localhost:8000/api/lifecycle?status=PENDING"
# Health
curl http://localhost:8000/healthSingle-process FastAPI application. Three async background tasks run in the same event loop:
- ChainSync: streams new blocks via Ogmios, enriches transactions with cached UTxO input data, persists to ClickHouse, updates lifecycle to CONFIRMED
- Mempool Monitor: polls
LocalTxMonitorvia Ogmios, resolves input UTxOs viaqueryLedgerState/utxo, writes PENDING lifecycle records to PostgreSQL, broadcasts TX_PENDING over WebSocket - Analysis Engine: polls ClickHouse for unscored transactions, enriches with cross-tx data (mempool collisions, transfer graph cycles, sandwich patterns), runs 9 attack-class scorers (Polimi spec), writes score vectors to ClickHouse
| Store | Purpose |
|---|---|
| ClickHouse | Analytics Warehouse: transactions, inputs, outputs, 9-class score vectors, baselines |
| PostgreSQL | Lifecycle state, sync checkpoint, entity state, mempool collisions, audit logs, and the auth tables (users, magic_link_tokens, user_sessions) |
| Filesystem | Data Lake: write-once gzip JSON blobs of raw Ogmios payloads |
See docs/ARCHITECTURE.md, docs/C4-ARCHITECTURE.md, and docs/TECHNOLOGY-DECISIONS.md for details. The full documentation index is in docs/README.md.
The analysis engine scores every transaction against nine supervised attack classes, each on a continuous 0-100 risk scale:
| Class | What it flags |
|---|---|
| Token Dust | A script-address UTxO stuffed with many distinct native assets, bloating the Value-field CBOR (a denial-of-service vector) |
| Large Value | A script-address UTxO holding one asset with an astronomically large quantity (CBOR magnitude bloat) |
| Large Datum | Oversized inline datums used to bloat or grief |
| Multiple Satisfaction | One payment reused to satisfy several script conditions (double satisfaction) |
| Front-Running | A transaction racing ahead of a victim's pending transaction |
| Sandwich | Bracketing a victim transaction with a before-and-after pair |
| Circular Transfers | Funds cycling through a ring of addresses |
| Fake Token | Tokens impersonating a known policy or asset |
| Phishing | Malicious intent signalled through transaction metadata |
Scores roll up into four risk bands (Informational, Moderate, High, Critical). With the optional clustering module enabled, a tenth class, contract_anomaly, adds unsupervised per-contract profiling.
The full reference, covering the features extracted per transaction, the scoring framework, and the per-class thresholds, is in docs/TMS_DETECTION_SPEC.md. For how data moves through the pipeline see docs/DATA-FLOW.md, or the plain-English docs/DATA-FLOW-EXPLAINED.md.
./scripts/db.sh start # start containers
./scripts/db.sh stop # stop containers
./scripts/db.sh logs # view logs
./scripts/db.sh psql # PostgreSQL shell
./scripts/db.sh clickhouse # ClickHouse shell
./scripts/db.sh reset # reset all data (destructive)To wipe data for a single network rather than everything, prefer the network-aware ./scripts/reset.sh (honours TMS_ENV / --network, with --all and --yes flags). See README_DOCKER.md for connection details and troubleshooting, and RUNBOOK.md §Reset all data for the full reset procedure.
Contributions are welcome. Please read CONTRIBUTING.md first: it covers the development setup, how to run the tests, and the rules every change must follow (recall-first detection, no magic numbers, and the documentation style). To report a security issue, see SECURITY.md.
Licensed under the Apache License 2.0. Third-party dependency and bundled-data licenses are listed in docs/LICENSES.md.