merge: chore/cleanup-remove-bloat-and-secrets into main
Some checks are pending
RMI CI / heartbeat (push) Waiting to run
RMI CI / lint (push) Waiting to run
RMI CI / typecheck (push) Waiting to run
RMI CI / test (push) Waiting to run
RMI CI / security (push) Waiting to run
RMI CI / openapi (push) Waiting to run
RMI CI / qdrant-cleanup (push) Waiting to run
Deploy RMI Backend / deploy (push) Waiting to run
Some checks are pending
RMI CI / heartbeat (push) Waiting to run
RMI CI / lint (push) Waiting to run
RMI CI / typecheck (push) Waiting to run
RMI CI / test (push) Waiting to run
RMI CI / security (push) Waiting to run
RMI CI / openapi (push) Waiting to run
RMI CI / qdrant-cleanup (push) Waiting to run
Deploy RMI Backend / deploy (push) Waiting to run
This commit is contained in:
commit
e5133f91d4
449 changed files with 91 additions and 120537 deletions
27
.gitignore
vendored
27
.gitignore
vendored
|
|
@ -60,3 +60,30 @@ data/*.sqlite
|
|||
*.pkl
|
||||
data/papers/
|
||||
.envrc
|
||||
|
||||
|
||||
# === SECURITY: never commit secrets ===
|
||||
.secrets/
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
*.p12
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# === DATA: don't commit binary data blobs ===
|
||||
*.zip
|
||||
*.tar.gz
|
||||
*.tar
|
||||
*.parquet
|
||||
*.duckdb
|
||||
*.sqlite
|
||||
*.bin
|
||||
*.safetensors
|
||||
*.pt
|
||||
*.pth
|
||||
*.onnx
|
||||
*.gguf
|
||||
*.h5
|
||||
*.hdf5
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
# Netscape HTTP Cookie File
|
||||
# https://curl.se/docs/http-cookies.html
|
||||
# This file was generated by libcurl! Edit at your own risk.
|
||||
|
||||
#HttpOnly_blog.rugmunch.io FALSE /ghost TRUE 1795687044 ghost-admin-api-session s%3ABGoSwf8Oax8WvUeqjtfseBlJFSToM-0-.qtb6flmTMlOFDfoxgQDPR%2FI7YmWYmVIcsxD4y6Kq3kw
|
||||
0
5s}
0
5s}
0
7s}
0
7s}
0
=2.0.0
0
=2.0.0
13
AGENTS.md
13
AGENTS.md
|
|
@ -1,3 +1,16 @@
|
|||
## 🚨 CRITICAL RULES (enforced by gitleaks pre-commit hook)
|
||||
|
||||
1. **No nested repos.** Don't commit other complete project trees inside this one.
|
||||
2. **No secrets.** Never commit `.secrets/`, `.env`, `*.pem`, `*.key`, `*.crt`, cookie jars.
|
||||
3. **No data blobs.** Don't commit `*.zip`, `*.parquet`, model weights, sqlite files.
|
||||
4. **No broken files.** Shell heredoc accidents must be caught before commit.
|
||||
5. **No duplicate scaffolds.** If a `backend/` or `frontend/` subdir exists at root,
|
||||
it's either THE app or bloat — investigate before adding more.
|
||||
|
||||
See [CLEANUP.md](CLEANUP.md) for cleanup history.
|
||||
|
||||
---
|
||||
|
||||
# AGENTS.md — rmi-backend
|
||||
# Every AI agent working on backend code reads this first.
|
||||
# Last updated: 2026-06-29
|
||||
|
|
|
|||
51
CLEANUP.md
Normal file
51
CLEANUP.md
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# CLEANUP.md — rmi-backend
|
||||
|
||||
> Audit + cleanup log. Most recent first.
|
||||
|
||||
## 2026-07-02 — bloat + secrets removal
|
||||
|
||||
**Branch**: `chore/cleanup-remove-bloat-and-secrets`
|
||||
|
||||
### 🚨 SECURITY: removed `.secrets/`
|
||||
- `.secrets/ghost_session_cookies` was committed to main. Content was a Netscape cookie jar.
|
||||
- **CRITICAL ACTION REQUIRED: rotate any cookies/secrets that were in this file.**
|
||||
- The file may have been browsable by anyone with repo read access.
|
||||
- Added `.secrets/` to `.gitignore` (was missing).
|
||||
|
||||
### 🗑️ Bloat removed
|
||||
- `rmi-frontend/` subtree (~2.7MB) — React app lives in its own repo `RugMunchMedia/rmi-frontend`
|
||||
- `backend/` subtree (273 files, ~1.7MB) — duplicate scaffold, NOT the source of truth
|
||||
|
||||
### 🚮 Broken files removed
|
||||
- `, r.stdout)...` — shell heredoc accident with literal newlines in filename
|
||||
- `5s}`, `7s}`, `=2.0.0` — fragment files from another shell accident
|
||||
|
||||
### 🛡️ .gitignore hardened
|
||||
Added patterns for:
|
||||
- Secrets (`.secrets/`, `*.pem`, `*.key`, `*.crt`, `.env`)
|
||||
- Data blobs (`*.zip`, `*.parquet`, `*.sqlite`, `*.duckdb`)
|
||||
- Model weights (`*.bin`, `*.safetensors`, `*.pt`, `*.onnx`)
|
||||
|
||||
## Files deliberately NOT removed
|
||||
These look "extra" but are legit:
|
||||
- `worker.py`, `main.py`, `Dockerfile.worker` — runtime entry points
|
||||
- `alembic.ini`, `alembic/` — DB migrations
|
||||
- `ruff.toml`, `mypy.ini`, `pytest.ini` — lint/type/test config
|
||||
- `safe_deploy.sh`, `databus_warm_cron.py` — ops scripts
|
||||
- `justfile`, `uv.lock`, `requirements.txt` — modern Python tooling
|
||||
- `rmi_sdk.py`, `rmi_langchain.py`, `x402_tool_builder.py` — domain modules
|
||||
- `smithery.yaml`, `huggingface.yaml` — service configs
|
||||
- `docker-compose.email.yml` — email service config
|
||||
- `supabase/*.sql` — Supabase migrations
|
||||
- `PROPRIETARY_REGISTRATION.txt` — legal doc
|
||||
- `main.py.bak` — backup, consider deleting in future PR
|
||||
- `backend.log` — log file, should be ignored (will be cleaned by better .gitignore)
|
||||
|
||||
## Pre-cleanup size
|
||||
134MB → ~70MB
|
||||
|
||||
## Lessons learned
|
||||
- Pre-commit hooks must include gitleaks (it WAS configured but this file slipped through)
|
||||
- `.secretsallow` is an allowlist — anything not on it must be blocked
|
||||
- Empty/short-lived subtrees should be deleted immediately, not left as legacy
|
||||
- A `CLEANUP.md` in each repo prevents this pattern from recurring
|
||||
86
backend/.github/workflows/ci.yml
vendored
86
backend/.github/workflows/ci.yml
vendored
|
|
@ -1,86 +0,0 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: pip
|
||||
- run: pip install ruff
|
||||
- run: ruff check app/ --select E,F,W --ignore E501,E402,W503
|
||||
- run: ruff format app/ --check --diff
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports: ["6379:6379"]
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
env:
|
||||
REDIS_URL: redis://localhost:6379/0
|
||||
PYTHONPATH: /app
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: pip
|
||||
- run: pip install -r requirements.txt pytest pytest-asyncio pytest-cov 2>/dev/null || pip install fastapi uvicorn redis httpx pydantic pytest pytest-asyncio pytest-cov
|
||||
- run: |
|
||||
python -m pytest tests/ -x --tb=short --cov=app --cov-report=term-missing 2>&1 || \
|
||||
echo "::warning::Some tests failed — non-blocking for initial CI setup"
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: test-results-${{ github.sha }}
|
||||
path: |
|
||||
.coverage
|
||||
coverage.xml
|
||||
retention-days: 7
|
||||
|
||||
deploy:
|
||||
needs: [lint, test]
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.VPS_HOST }}
|
||||
username: root
|
||||
key: ${{ secrets.VPS_SSH_KEY }}
|
||||
script: |
|
||||
cd /root/backend
|
||||
git pull origin main
|
||||
docker compose -f /srv/rugmuncher-backend/docker-compose.yml up -d --build backend
|
||||
sleep 10
|
||||
for i in 1 2 3 4 5; do
|
||||
if curl -fsS http://localhost:8000/health | python3 -c "import json,sys; exit(0 if json.load(sys.stdin).get('status')=='healthy' else 1)"; then
|
||||
echo "DEPLOY OK — health check passed"
|
||||
exit 0
|
||||
fi
|
||||
echo "Health check attempt $i failed, retrying..."
|
||||
sleep 5
|
||||
done
|
||||
echo "HEALTH CHECK FAILED — rolling back"
|
||||
cd /srv/rugmuncher-backend
|
||||
docker compose up -d --build backend
|
||||
exit 1
|
||||
61
backend/.github/workflows/security.yml
vendored
61
backend/.github/workflows/security.yml
vendored
|
|
@ -1,61 +0,0 @@
|
|||
name: Security
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: "0 6 * * *" # nightly scan of main
|
||||
|
||||
jobs:
|
||||
trivy:
|
||||
name: Container scan (Trivy)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
tags: rmi:scan
|
||||
push: false
|
||||
load: true
|
||||
- name: Run Trivy
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
image-ref: rmi:scan
|
||||
format: sarif
|
||||
output: trivy-results.sarif
|
||||
severity: CRITICAL,HIGH
|
||||
exit-code: 1
|
||||
- uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
|
||||
semgrep:
|
||||
name: SAST (Semgrep)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: returntocorp/semgrep-action@v1
|
||||
with:
|
||||
config: >-
|
||||
p/owasp-top-ten
|
||||
p/python
|
||||
p/sql-injection
|
||||
p/xss
|
||||
p/command-injection
|
||||
p/secrets
|
||||
|
||||
gitleaks:
|
||||
name: Secret scan (gitleaks)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: gitleaks/gitleaks-action@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
3
backend/.gitignore
vendored
3
backend/.gitignore
vendored
|
|
@ -1,3 +0,0 @@
|
|||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
# .pre-commit-config.yaml — RMI v4.0
|
||||
# Install: pip install pre-commit && pre-commit install
|
||||
# Run manually: pre-commit run --all-files
|
||||
|
||||
repos:
|
||||
# ── Generic checks ──
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.6.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
args: [--allow-multiple-documents]
|
||||
- id: check-json
|
||||
- id: check-toml
|
||||
- id: check-added-large-files
|
||||
args: [--maxkb=5000]
|
||||
- id: check-merge-conflict
|
||||
- id: detect-private-key
|
||||
- id: mixed-line-ending
|
||||
args: [--fix=lf]
|
||||
|
||||
# ── Python: ruff lint + format ──
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.6.9
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix, --exit-non-zero-on-fix]
|
||||
- id: ruff-format
|
||||
|
||||
# ── Secrets scanning ──
|
||||
- repo: https://github.com/gitleaks/gitleaks
|
||||
rev: v8.19.0
|
||||
hooks:
|
||||
- id: gitleaks
|
||||
|
||||
# ── Conventional commits ──
|
||||
- repo: https://github.com/compilerla/conventional-pre-commit
|
||||
rev: v3.2.0
|
||||
hooks:
|
||||
- id: conventional-pre-commit
|
||||
stages: [commit-msg]
|
||||
args: [feat, fix, security, docs, style, refactor, perf, test, chore, ci, build, revert]
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
# /root/backend/ — RMI CANONICAL BACKEND
|
||||
|
||||
## ⚠️ THIS IS THE ONE AND ONLY BACKEND
|
||||
|
||||
All other copies are dead. If you find code at `/srv/rugmuncher-backend/main.py`,
|
||||
`/root/rmi/backend/`, or anywhere else — it's STALE. Work here ONLY.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
/root/backend/
|
||||
├── main.py # FastAPI app (5040 lines) — entry point
|
||||
├── Dockerfile # Backend container build
|
||||
├── Dockerfile.worker # Worker container build
|
||||
├── requirements.txt # Python dependencies
|
||||
├── .env.example # All required env vars documented
|
||||
├── generate_env.py # Auto-generate .env from Hermes config
|
||||
├── app/ # All application modules
|
||||
│ ├── news_service.py # 15+ source news aggregator
|
||||
│ ├── rag_service.py # Redis-based RAG vector store
|
||||
│ ├── auth.py # Authentication
|
||||
│ ├── payments.py # Payment processing
|
||||
│ ├── content_syndicate.py # Multi-platform content publishing
|
||||
│ └── ... (80+ modules)
|
||||
```
|
||||
|
||||
## How to Use
|
||||
|
||||
### Backend changes (Python):
|
||||
```bash
|
||||
# Edit files here. Volume mount means changes are LIVE:
|
||||
docker restart rmi-backend
|
||||
|
||||
# Or for dependency changes, rebuild:
|
||||
cd /srv/rugmuncher-backend
|
||||
docker compose build backend --no-cache
|
||||
docker compose up -d backend
|
||||
```
|
||||
|
||||
### Environment setup:
|
||||
```bash
|
||||
python3 generate_env.py --force
|
||||
# Then edit .env to fill in missing values
|
||||
```
|
||||
|
||||
### API documentation:
|
||||
- Swagger: http://localhost:8000/docs
|
||||
- Health: http://localhost:8000/health
|
||||
|
||||
## Docker Compose
|
||||
|
||||
Compose file: `/srv/rugmuncher-backend/docker-compose.yml`
|
||||
Context: `context: /root/backend` (builds from here)
|
||||
Mount: `/root/backend:/app` (live code, no rebuild needed)
|
||||
|
||||
All container names use hyphens: `rmi-backend`, `rmi-worker`, `rmi-n8n`, etc.
|
||||
|
||||
## Development Rules
|
||||
|
||||
1. **Never edit files outside this directory** for backend work
|
||||
2. **Always `python3 generate_env.py`** after adding new env vars
|
||||
3. **`.env` never committed** — use `.env.example` as template
|
||||
4. **Test with `curl localhost:8000/health`** after changes
|
||||
5. **Volume mount = live reload** — just restart the container
|
||||
|
||||
## Related Systems
|
||||
|
||||
| System | Location | Container |
|
||||
|--------|----------|-----------|
|
||||
| n8n workflows | /root/n8n-data/ | rmi-n8n |
|
||||
| Orchestrator | /srv/rugmuncher-backend/orchestrator/ | rmi-orchestrator |
|
||||
| Telegram bot | /srv/rugmuncher-backend/bots/telegram/ | rmi-telegram-bot |
|
||||
| Frontend | /srv/rugmuncher-backend/rmi-frontend/ | Vercel/Cloudflare |
|
||||
| Hermes AI | /root/.hermes/ | CLI process |
|
||||
| Langfuse | /srv/langfuse/ | Separate compose |
|
||||
| Redis | Composed | rmi-redis |
|
||||
|
|
@ -1,519 +0,0 @@
|
|||
# RMI Darkroom — Complete Backend Documentation
|
||||
## RugMunch Intelligence Platform — Admin Backend v2
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The RMI Darkroom is a comprehensive, enterprise-grade admin backend for the RugMunch Intelligence crypto security platform. It provides full control over users, content, wallets, payments, security, analytics, and token deployment across multiple blockchains.
|
||||
|
||||
**Status:** Production-ready | **Version:** 2.0 | **Date:** May 31, 2026
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ RMI Darkroom Backend │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Admin SPA (/admin) │ Darkroom UI (/darkroom) │
|
||||
│ ├─ Dashboard │ ├─ Token Deployer │
|
||||
│ ├─ User Management │ ├─ Airdrop Manager │
|
||||
│ ├─ Security Center │ ├─ Multi-chain Launch │
|
||||
│ ├─ Wallet Manager │ └─ Custom Snapshots │
|
||||
│ ├─ Analytics │ │
|
||||
│ ├─ Bulletin Board │ │
|
||||
│ ├─ Financial │ │
|
||||
│ └─ Configuration │ │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ API Layer (757+ endpoints) │
|
||||
│ ├─ /api/v1/admin/backend/* — Admin operations │
|
||||
│ ├─ /api/v1/wallets/v2/* — Wallet management │
|
||||
│ ├─ /api/v1/analytics/* — Real-time analytics │
|
||||
│ ├─ /api/v1/admin/bulletin/* — Content management │
|
||||
│ ├─ /api/v1/admin/tokens/* — Token deployment │
|
||||
│ └─ /api/v1/bulletin/* — Public content │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Core Engines │
|
||||
│ ├─ Admin Backend (RBAC, Audit, Sessions) │
|
||||
│ ├─ Wallet Manager v2 (25+ chains, HD, Rotation) │
|
||||
│ ├─ Security Defense (Bot Detection, WAF, DDoS) │
|
||||
│ ├─ Analytics Engine (Real-time, Prometheus, Grafana) │
|
||||
│ ├─ Bulletin Board (CMS, Moderation, SEO) │
|
||||
│ ├─ Token Deployer (5 chains, Blacklist, Anti-bot) │
|
||||
│ └─ Plugin System (Extensible architecture) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Data Layer │
|
||||
│ ├─ Redis — Sessions, rate limits, caching │
|
||||
│ ├─ Supabase — Users, audit logs, wallet data │
|
||||
│ ├─ File System — Vault, configs, backups │
|
||||
│ └─ ClickHouse — Analytics time-series (optional) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
### 1. Role-Based Access Control (RBAC)
|
||||
- **5 roles:** superadmin, admin, moderator, viewer, support
|
||||
- **Permission matrix** with 30+ granular permissions
|
||||
- **IP allowlists** for admin access
|
||||
- **Session management** with 8-hour timeout, max 3 concurrent
|
||||
- **2FA support** (TOTP-ready)
|
||||
|
||||
### 2. Wallet Manager v2
|
||||
- **25+ chains:** Bitcoin, Ethereum, Solana, TRON, Base, BSC, Polygon, Arbitrum, Optimism, Avalanche, Fantom, Gnosis, Dogecoin, Litecoin, and more
|
||||
- **HD Wallets:** BIP39/BIP44/BIP49/BIP84 mnemonic support
|
||||
- **Key Rotation:** Scheduled automatic rotation with notifications
|
||||
- **Payment Integration:** x402 micropayments, subscription tiers
|
||||
- **Balance Monitoring:** Real-time tracking across all chains
|
||||
- **AES-256-GCM encryption** with Argon2id key derivation
|
||||
- **Multi-signature ready** architecture
|
||||
|
||||
### 3. Security Defense System
|
||||
- **Bot Detection:** Behavioral analysis, fingerprinting, heuristics
|
||||
- **Anomaly Detection:** Statistical analysis on request patterns
|
||||
- **Honeypot Endpoints:** 10 trap endpoints that auto-ban attackers
|
||||
- **DDoS Protection:** Circuit breaker pattern, rate limiting
|
||||
- **IP Reputation:** Integration-ready for AbuseIPDB
|
||||
- **Geo-blocking:** Country-based access control
|
||||
- **Request Fingerprinting:** Canvas, WebGL, font analysis
|
||||
|
||||
### 4. Analytics Engine
|
||||
- **Real-time Metrics:** CPU, memory, requests, errors, latency
|
||||
- **4 Default Dashboards:** System Health, Financial, Security, Users
|
||||
- **Trend Detection:** Automatic anomaly detection with 3-sigma analysis
|
||||
- **Prometheus Export:** Compatible with Prometheus/Grafana stack
|
||||
- **WebSocket-ready:** Real-time streaming data
|
||||
- **Custom Dashboards:** Configurable widget layouts
|
||||
|
||||
### 5. Bulletin Board / CMS
|
||||
- **Post Management:** CRUD with versioning, scheduling, expiry
|
||||
- **Categories:** news, alert, update, promo, system, community, announcement, tutorial
|
||||
- **Targeting:** Audience segmentation (free, premium, pro, admins)
|
||||
- **Moderation:** Draft/review/published/archived workflow
|
||||
- **SEO:** Meta tags, OpenGraph, slug generation
|
||||
- **Comments:** Threaded discussions with moderation
|
||||
|
||||
### 6. Token Deployer (Darkroom)
|
||||
- **5 Chains:** Ethereum, Base, BSC, Solana, TRON
|
||||
- **Features:** Blacklist, anti-bot, anti-sniper, team allocation, vesting
|
||||
- **Airdrop System:** 1:1 exact-match airdrop, multi-chain snapshots
|
||||
- **Custom Snapshots:** JSON, CSV, manual upload
|
||||
- **Anti-gaming:** Sybil detection, multi-account filtering
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints Summary
|
||||
|
||||
| Category | Endpoints | Auth |
|
||||
|----------|-----------|------|
|
||||
| Admin Auth | 5 | Public/Session |
|
||||
| Dashboard | 2 | dashboard.read |
|
||||
| Users | 5 | users.read/write |
|
||||
| Security | 8 | security.read/write |
|
||||
| System | 5 | system.read/write |
|
||||
| Content | 3 | content.read/write |
|
||||
| Financial | 3 | financial.read |
|
||||
| API Keys | 3 | api_keys.read/write |
|
||||
| Admin Mgmt | 4 | superadmin only |
|
||||
| Backups | 2 | superadmin only |
|
||||
| Webhooks | 2 | webhooks.read/write |
|
||||
| **Wallet Manager v2** | **19** | token_deploy.read/write |
|
||||
| **Analytics** | **11** | analytics.read |
|
||||
| **Bulletin Board** | **18** | content.read/write |
|
||||
| **Token Deployer** | **24** | X-Admin-Key |
|
||||
| **Public Bulletin** | **5** | None |
|
||||
| **Total** | **757+** | Mixed |
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
/root/backend/
|
||||
├── app/
|
||||
│ ├── admin_backend.py # Core admin engine (RBAC, audit, sessions)
|
||||
│ ├── wallet_manager_v2.py # Wallet management engine
|
||||
│ ├── security_defense.py # Bot detection, WAF, DDoS protection
|
||||
│ ├── analytics_engine.py # Real-time metrics and dashboards
|
||||
│ ├── bulletin_board.py # CMS engine
|
||||
│ ├── plugin_system.py # Plugin architecture
|
||||
│ ├── token_deployer.py # Multi-chain token deployer
|
||||
│ ├── multichain_airdrop.py # Airdrop engine
|
||||
│ └── routers/
|
||||
│ ├── admin_backend.py # Admin API (36 endpoints)
|
||||
│ ├── wallet_manager_v2.py # Wallet API (19 endpoints)
|
||||
│ ├── analytics.py # Analytics API (11 endpoints)
|
||||
│ ├── bulletin_board.py # Bulletin API (18 endpoints)
|
||||
│ ├── darkroom_tokens.py # Token deployer API
|
||||
│ ├── darkroom_airdrop.py # Airdrop API
|
||||
│ └── darkroom_multichain.py # Multi-chain API
|
||||
├── static/
|
||||
│ ├── admin.html # Admin SPA (52KB)
|
||||
│ └── darkroom.html # Token deployer UI (43KB)
|
||||
└── main.py # FastAPI app (757+ routes)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Features
|
||||
|
||||
### Authentication
|
||||
- bcrypt password hashing
|
||||
- JWT session tokens with expiry
|
||||
- Rate limiting on all endpoints
|
||||
- IP blocking with auto-ban
|
||||
- Failed login tracking (auto-ban after 5 attempts)
|
||||
- Session invalidation on logout
|
||||
- Concurrent session limits
|
||||
|
||||
### Authorization
|
||||
- Role-based access control
|
||||
- Permission matrix per endpoint
|
||||
- Admin-only endpoints for sensitive operations
|
||||
- Audit logging of all actions
|
||||
- Before/after state tracking
|
||||
|
||||
### Data Protection
|
||||
- AES-256-GCM encryption for wallet keys
|
||||
- Argon2id key derivation
|
||||
- File permissions (chmod 600) on vault files
|
||||
- No private keys in memory longer than necessary
|
||||
- Secure session storage in Redis
|
||||
|
||||
### Network Security
|
||||
- Bot detection with behavioral analysis
|
||||
- Honeypot endpoints (auto-ban on trigger)
|
||||
- DDoS circuit breaker
|
||||
- Request fingerprinting
|
||||
- Anomaly detection on traffic patterns
|
||||
- Geo-blocking capability
|
||||
|
||||
---
|
||||
|
||||
## Wallet Manager v2
|
||||
|
||||
### Supported Chains
|
||||
|
||||
| Chain | Family | Address Pattern | HD Path |
|
||||
|-------|--------|----------------|---------|
|
||||
| Bitcoin | Bitcoin | 1/3/bc1... | m/44'/0'/0'/0/0 |
|
||||
| Bitcoin SegWit | Bitcoin | 3/bc1... | m/49'/0'/0'/0/0 |
|
||||
| Bitcoin Native SegWit | Bitcoin | bc1... | m/84'/0'/0'/0/0 |
|
||||
| Ethereum | EVM | 0x... | m/44'/60'/0'/0/0 |
|
||||
| Base | EVM | 0x... | m/44'/60'/0'/0/0 |
|
||||
| Polygon | EVM | 0x... | m/44'/60'/0'/0/0 |
|
||||
| Arbitrum | EVM | 0x... | m/44'/60'/0'/0/0 |
|
||||
| Optimism | EVM | 0x... | m/44'/60'/0'/0/0 |
|
||||
| Avalanche | EVM | 0x... | m/44'/60'/0'/0/0 |
|
||||
| BSC | EVM | 0x... | m/44'/60'/0'/0/0 |
|
||||
| Fantom | EVM | 0x... | m/44'/60'/0'/0/0 |
|
||||
| Gnosis | EVM | 0x... | m/44'/60'/0'/0/0 |
|
||||
| Solana | Solana | Base58 | m/44'/501'/0'/0' |
|
||||
| TRON | TRON | T... | m/44'/195'/0'/0/0 |
|
||||
| Dogecoin | Secp256k1 | D... | m/44'/3'/0'/0/0 |
|
||||
| Litecoin | Secp256k1 | L/M/ltc1... | m/44'/2'/0'/0/0 |
|
||||
|
||||
### Wallet Tiers
|
||||
|
||||
| Tier | Use Case | Security |
|
||||
|------|----------|----------|
|
||||
| hot | Active trading | Standard |
|
||||
| warm | Regular operations | Enhanced |
|
||||
| cold | Long-term storage | High |
|
||||
| vault | Maximum security | Multi-sig ready |
|
||||
|
||||
### Payment Integration
|
||||
|
||||
- **x402:** Enable per-wallet with price in USD
|
||||
- **Subscriptions:** Tier-based (free, basic, pro, enterprise)
|
||||
- **Payment Types:** x402, subscription, one-time, marketplace, refund, withdrawal, deposit, fee, reward
|
||||
|
||||
---
|
||||
|
||||
## Analytics Dashboards
|
||||
|
||||
### System Health Dashboard
|
||||
- CPU Usage (gauge + line chart)
|
||||
- Memory Usage (gauge + line chart)
|
||||
- Disk Usage (gauge)
|
||||
- Requests/minute (counter)
|
||||
- Response Latency (line chart)
|
||||
- Error Rate (line chart)
|
||||
|
||||
### Financial Dashboard
|
||||
- Total Revenue (counter)
|
||||
- MRR (counter)
|
||||
- ARPU (counter)
|
||||
- Churn Rate (gauge)
|
||||
- Revenue Trend (line chart)
|
||||
- Payment Count (line chart)
|
||||
|
||||
### Security Dashboard
|
||||
- Threats Blocked (counter)
|
||||
- Bot Requests (counter)
|
||||
- Attacks Detected (counter)
|
||||
- Blocked IPs (counter)
|
||||
- Threat Types (pie chart)
|
||||
- Attack Timeline (line chart)
|
||||
|
||||
### User Analytics Dashboard
|
||||
- DAU (counter)
|
||||
- MAU (counter)
|
||||
- New Users (counter)
|
||||
- Retention Rate (gauge)
|
||||
- User Growth (line chart)
|
||||
- User Tiers (pie chart)
|
||||
|
||||
---
|
||||
|
||||
## Plugin System
|
||||
|
||||
### Plugin Types
|
||||
- **connector** — Data sources (exchanges, APIs, oracles)
|
||||
- **scanner** — Security scanners (contract, wallet, token)
|
||||
- **analyzer** — Analysis engines (risk, sentiment, on-chain)
|
||||
- **notifier** — Alert channels (email, telegram, webhook)
|
||||
- **exporter** — Data export (CSV, PDF, API, webhook)
|
||||
- **wallet** — Wallet integrations (hardware, custodial)
|
||||
- **payment** — Payment processors (x402, stripe, crypto)
|
||||
- **ml** — ML models (fraud detection, prediction)
|
||||
- **security** — Security tools (WAF, firewall)
|
||||
- **analytics** — Analytics integrations (Grafana, Prometheus)
|
||||
|
||||
### Built-in Plugins
|
||||
- PrometheusExporter — Export metrics to Prometheus format
|
||||
- WebhookNotifier — Send notifications to webhooks
|
||||
- RedisCache — Redis caching and pub/sub connector
|
||||
|
||||
### Plugin Directory
|
||||
```
|
||||
/root/backend/plugins/
|
||||
├── connector/
|
||||
├── scanner/
|
||||
├── analyzer/
|
||||
├── notifier/
|
||||
├── exporter/
|
||||
├── wallet/
|
||||
├── payment/
|
||||
├── ml/
|
||||
├── security/
|
||||
└── analytics/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
### Requirements
|
||||
- Python 3.10+
|
||||
- Redis 6.0+
|
||||
- FastAPI + Uvicorn
|
||||
- Optional: Supabase, ClickHouse, Prometheus, Grafana
|
||||
|
||||
### Environment Variables
|
||||
```bash
|
||||
# Core
|
||||
JWT_SECRET=your-jwt-secret
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=your-redis-password
|
||||
SUPABASE_URL=https://your-project.supabase.co
|
||||
SUPABASE_SERVICE_KEY=your-service-key
|
||||
|
||||
# Wallet Vault
|
||||
WALLET_VAULT_PASSWORD=your-vault-password
|
||||
|
||||
# Admin
|
||||
ADMIN_API_KEY=your-admin-key
|
||||
|
||||
# x402
|
||||
X402_EVM_PAY_TO=your-wallet-address
|
||||
|
||||
# Security
|
||||
ABUSEIPDB_API_KEY=your-abuseipdb-key # optional
|
||||
```
|
||||
|
||||
### Startup
|
||||
```bash
|
||||
cd /root/backend
|
||||
uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4
|
||||
```
|
||||
|
||||
### Health Check
|
||||
```bash
|
||||
curl http://localhost:8000/health
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Admin Access
|
||||
|
||||
### Default Admin
|
||||
- **Email:** admin@rugmunch.io
|
||||
- **Password:** Darkroom2025!
|
||||
- **Role:** superadmin
|
||||
|
||||
### Admin UI
|
||||
- **URL:** https://your-domain.com/admin
|
||||
- **Login:** Email + Password + optional 2FA
|
||||
- **Session:** 8-hour expiry, max 3 concurrent
|
||||
|
||||
### Darkroom (Token Deployer)
|
||||
- **URL:** https://your-domain.com/darkroom
|
||||
- **Auth:** X-Admin-Key header
|
||||
|
||||
---
|
||||
|
||||
## API Usage Examples
|
||||
|
||||
### Generate Wallet
|
||||
```bash
|
||||
curl -X POST https://api.rugmunch.io/api/v1/wallets/v2/generate \
|
||||
-H "X-Admin-Session: sess_xxx" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"chain": "eth", "purpose": "payments", "tier": "hot"}'
|
||||
```
|
||||
|
||||
### Record Payment
|
||||
```bash
|
||||
curl -X POST https://api.rugmunch.io/api/v1/wallets/v2/payments \
|
||||
-H "X-Admin-Session: sess_xxx" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"wallet_id": "wal_eth_123",
|
||||
"wallet_address": "0x...",
|
||||
"chain": "eth",
|
||||
"payment_type": "x402",
|
||||
"amount": 0.01,
|
||||
"amount_usd": 25.00,
|
||||
"user_id": "user_123"
|
||||
}'
|
||||
```
|
||||
|
||||
### Get Analytics
|
||||
```bash
|
||||
curl https://api.rugmunch.io/api/v1/analytics/dashboards/system \
|
||||
-H "X-Admin-Session: sess_xxx"
|
||||
```
|
||||
|
||||
### Prometheus Metrics
|
||||
```bash
|
||||
curl https://api.rugmunch.io/api/v1/analytics/prometheus
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Monitoring & Alerting
|
||||
|
||||
### Prometheus Metrics
|
||||
All system metrics are exportable in Prometheus format at `/api/v1/analytics/prometheus`.
|
||||
|
||||
### Key Metrics
|
||||
- `rmi_cpu_percent` — CPU usage
|
||||
- `rmi_memory_percent` — Memory usage
|
||||
- `rmi_requests_per_minute` — Request rate
|
||||
- `rmi_response_time_ms` — Response latency
|
||||
- `rmi_error_rate` — Error percentage
|
||||
- `rmi_revenue_usd` — Total revenue
|
||||
- `rmi_threats_blocked` — Threats blocked
|
||||
- `rmi_active_users` — Active users
|
||||
|
||||
### Grafana Integration
|
||||
Import the Prometheus endpoint into Grafana for visualization.
|
||||
|
||||
---
|
||||
|
||||
## Backup & Recovery
|
||||
|
||||
### Wallet Vault
|
||||
- Encrypted JSON file at `/root/.rmi/wallets/vault_v2.json`
|
||||
- Keystore at `/root/.rmi/wallets/keystore.enc`
|
||||
- Payment log at `/root/.rmi/wallets/payments.jsonl`
|
||||
|
||||
### Backup Strategy
|
||||
1. Daily encrypted backups to secure storage
|
||||
2. Seed phrase recovery for HD wallets
|
||||
3. Multi-signature backup for vault wallets
|
||||
4. Audit log retention: 90 days
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
### Adding a New Plugin
|
||||
```python
|
||||
from app.plugin_system import Plugin, PluginType
|
||||
|
||||
class MyPlugin(Plugin):
|
||||
@property
|
||||
def name(self): return "my_plugin"
|
||||
@property
|
||||
def version(self): return "1.0.0"
|
||||
@property
|
||||
def plugin_type(self): return PluginType.ANALYZER
|
||||
@property
|
||||
def description(self): return "My custom analyzer"
|
||||
|
||||
def _setup(self):
|
||||
# Initialize your plugin
|
||||
pass
|
||||
```
|
||||
|
||||
### Adding a Dashboard Widget
|
||||
```python
|
||||
from app.analytics_engine import DashboardWidget
|
||||
|
||||
widget = DashboardWidget(
|
||||
widget_id="my_widget",
|
||||
widget_type="line",
|
||||
title="My Metric",
|
||||
metric_name="my_metric",
|
||||
width=6,
|
||||
height=4,
|
||||
)
|
||||
engine.add_widget("system", widget)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Checklist
|
||||
|
||||
- [ ] Change default admin password
|
||||
- [ ] Set strong WALLET_VAULT_PASSWORD
|
||||
- [ ] Enable Redis AUTH
|
||||
- [ ] Configure IP allowlists for admin access
|
||||
- [ ] Set up AbuseIPDB API key
|
||||
- [ ] Enable 2FA for superadmin accounts
|
||||
- [ ] Configure backup schedule
|
||||
- [ ] Set up Prometheus/Grafana monitoring
|
||||
- [ ] Enable HTTPS only
|
||||
- [ ] Review audit logs weekly
|
||||
- [ ] Rotate wallet keys quarterly
|
||||
- [ ] Test disaster recovery plan
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
- **Email:** admin@rugmunch.io
|
||||
- **Docs:** https://docs.rugmunch.io
|
||||
- **API:** https://api.rugmunch.io/docs
|
||||
- **Status:** https://status.rugmunch.io
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Proprietary and confidential. Unauthorized use, distribution, or reproduction is strictly prohibited.
|
||||
|
||||
Copyright (c) 2026 RugMunch Intelligence. All rights reserved.
|
||||
|
||||
---
|
||||
|
||||
**Built with:** FastAPI, Redis, Supabase, Python 3.12, love for crypto security.
|
||||
|
||||
**The Bloomberg Terminal of Shitcoins.**
|
||||
|
|
@ -1,625 +0,0 @@
|
|||
# RMI Backend — 2026 Architecture Design
|
||||
|
||||
## Why this exists
|
||||
|
||||
The current backend (`/root/backend/`) has grown organically:
|
||||
- `main.py` is 10,305 lines
|
||||
- 124 router files flat in `app/routers/`
|
||||
- 14 RAG modules scattered at top of `app/`
|
||||
- `token_scanner.py` is 4,109 lines
|
||||
- `x402_tools.py` is 5,817 lines
|
||||
- Cross-cutting concerns (redis, auth, errors) duplicated across modules
|
||||
- Domain logic entangled with FastAPI
|
||||
- No tests, no type safety, no clear boundaries
|
||||
|
||||
15 mechanical refactor tasks would patch symptoms. This design fixes the architecture.
|
||||
|
||||
## Target Layout
|
||||
|
||||
```
|
||||
/root/backend/
|
||||
├── pyproject.toml uv + ruff + mypy + pytest config (single source)
|
||||
├── .pre-commit-config.yaml ruff + mypy + size cap + gitleaks
|
||||
├── Dockerfile
|
||||
├── alembic/ async migrations
|
||||
│
|
||||
├── app/
|
||||
│ ├── main.py <100 lines: app factory + lifespan + middleware ONLY
|
||||
│ ├── config.py pydantic-settings, env loading
|
||||
│ │
|
||||
│ ├── core/ cross-cutting, NO business logic
|
||||
│ │ ├── logging.py structlog JSON + correlation ID
|
||||
│ │ ├── errors.py AppError hierarchy + FastAPI handlers
|
||||
│ │ ├── redis.py async client + get_redis() Depends()
|
||||
│ │ ├── db.py async SQLAlchemy session
|
||||
│ │ ├── auth.py JWT decode + role guards
|
||||
│ │ ├── lifespan.py startup/shutdown
|
||||
│ │ ├── middleware.py CORS, rate limit, correlation ID
|
||||
│ │ ├── websocket.py WS connection manager
|
||||
│ │ ├── tracing.py OpenTelemetry + Langfuse v4 init
|
||||
│ │ ├── http.py async httpx client
|
||||
│ │ ├── pagination.py cursor-based
|
||||
│ │ ├── metrics.py Prometheus /metrics endpoint (P0 v3)
|
||||
│ │ └── legacy.py @legacy decorator for deprecation tracking
|
||||
│ │
|
||||
│ ├── api/ HTTP transport, thin routes
|
||||
│ │ ├── deps.py shared Depends (current_user, redis, etc)
|
||||
│ │ ├── v1/
|
||||
│ │ │ ├── public/ no auth — scanner, wallet, token, pricing, health
|
||||
│ │ │ ├── auth/ JWT — portfolio, alerts, intel, profile
|
||||
│ │ │ ├── admin/ admin — users, system, ops
|
||||
│ │ │ ├── x402/ paid — tools, tokens, wallets, defi, security
|
||||
│ │ │ └── mcp/ MCP — tools.py
|
||||
│ │ └── ws/ WebSocket
|
||||
│ │ └── alerts.py
|
||||
│ │
|
||||
│ ├── domain/ pure business logic, NO FastAPI imports
|
||||
│ │ ├── scanner/ core + honeypot + rugcheck + holders + contract + deployer + models + service
|
||||
│ │ ├── wallet/ analyzer + labels + behavior + models + service
|
||||
│ │ ├── token/ discovery + supply + models + service
|
||||
│ │ ├── rag/ embeddings + chunking + search + ingest + firehose + feedback + agentic + evaluation + tracing + router + permanence + models + service
|
||||
│ │ ├── x402/ facilitator + tokens + enforcement + settlement + models + service
|
||||
│ │ ├── intel/ feeds + narratives + graph + models + service
|
||||
│ │ ├── scam/ classifier + patterns + models + service
|
||||
│ │ ├── databus/ client + chains(96) + models + service
|
||||
│ │ └── bulletin/ board + models + service
|
||||
│ │
|
||||
│ ├── infra/ external integrations
|
||||
│ │ ├── ollama.py
|
||||
│ │ ├── langfuse.py
|
||||
│ │ ├── vector_store.py
|
||||
│ │ ├── chains/ evm + solana + bitcoin + base + ...
|
||||
│ │ ├── apis/ coingecko + etherscan + birdeye + goplus + arkham + dune + ...
|
||||
│ │ └── providers/ ollama + openrouter + huggingface + ...
|
||||
│ │
|
||||
│ ├── agents/ v3 NEW — bounded-task AI agent loops (M1)
|
||||
│ │ ├── loop.py frozen: TaskInput/LoopBudget/TaskOutput Pydantic models
|
||||
│ │ ├── fact_store.py Redis-backed long-term memory
|
||||
│ │ ├── kill_switches.py budget enforcers (iterations, tokens, wall, spend)
|
||||
│ │ └── executor.py bounded loop runner
|
||||
│ │
|
||||
│ ├── mcp/ v3 NEW — MCP mesh v2 (M2)
|
||||
│ │ ├── manifest.py frozen: MCPToolManifest Pydantic model
|
||||
│ │ ├── registry.py FastAPI on :8643 — list/resolve tools
|
||||
│ │ ├── auth.py gopass-backed auth scopes
|
||||
│ │ └── servers/ opencti + dify + n8n FastMCP servers
|
||||
│ │
|
||||
│ ├── middleware/ v3 NEW — Starlette middleware classes (M7)
|
||||
│ │ ├── cost_tracking.py frozen: per-tenant/per-route cost middleware
|
||||
│ │ ├── rate_limit_v2.py per-tier sliding window
|
||||
│ │ ├── auth_deps.py JWT/API key on legacy routes
|
||||
│ │ ├── security_headers.py CSP, HSTS, X-Frame-Options
|
||||
│ │ └── shadow_traffic.py v1/v2 response diffing
|
||||
│ │
|
||||
│ └── workers/ background jobs (separate from API)
|
||||
│ ├── firehose.py
|
||||
│ ├── scanner_queue.py
|
||||
│ ├── ingest_cron.py
|
||||
│ └── cleanup.py
|
||||
│
|
||||
├── docs/
|
||||
│ ├── adr/ v3 NEW — Architecture Decision Records (M6)
|
||||
│ ├── runbooks/ v3 NEW — incident response (M6)
|
||||
│ ├── postmortems/ v3 NEW — incident learnings (M6)
|
||||
│ ├── oncall/ v3 NEW — rotation doc (M6)
|
||||
│ ├── load/ v3 NEW — k6 baseline reports (P2 #26)
|
||||
│ ├── contract/ v3 NEW — Pact contract specs (P2 #27)
|
||||
│ └── plans/
|
||||
│ ├── UNFUCK-V1.md Frozen — superseded
|
||||
│ ├── UNFUCK-V2.md Frozen — superseded
|
||||
│ ├── UNFUCK-V3.md ACTIVE — AI-Forward Ship Edition (this document's parent)
|
||||
│ └── INTEGRATION-LOG.md append-only worklog of v3 execution
|
||||
│
|
||||
└── tests/
|
||||
├── conftest.py
|
||||
├── unit/
|
||||
│ ├── core/ 33+ core tests
|
||||
│ └── domain/ 58+ domain tests
|
||||
├── e2e/ v3 NEW — Playwright (P2 #25)
|
||||
├── contract/ v3 NEW — Pact (P2 #25)
|
||||
├── load/ v3 NEW — k6 scripts (P2 #25)
|
||||
└── rag/
|
||||
├── eval_suite.py v3 NEW — retrieval quality regression (M3)
|
||||
└── prompts/
|
||||
└── test_snapshots.py v3 NEW — prompt regression
|
||||
```
|
||||
|
||||
## Key Design Principles
|
||||
|
||||
1. **STRICT LAYERING.** `api → domain → infra`. Never reverse. Domain knows nothing about HTTP.
|
||||
2. **ONE SOURCE OF TRUTH for cross-cutting.** redis/auth/errors/logging live in `core/` exactly once. Routes import, never redefine.
|
||||
3. **HARD SIZE CAP.** 500 lines per file. Enforced in pre-commit. No 4,109-line `token_scanner.py` ever again.
|
||||
4. **THIN ROUTES.** Routes parse → call service → return. No business logic in HTTP layer.
|
||||
5. **DOMAIN = PURE PYTHON.** `domain/scanner/` can be unit tested without spinning up FastAPI. This is the test that proves the architecture.
|
||||
6. **WORKERS SEPARATED.** Background jobs don't pollute the API. firehose, scanner_queue, ingest_cron live in `workers/`.
|
||||
7. **PYDANTIC V2 EVERYWHERE.** Every domain has `models.py`. No `dict` types crossing boundaries.
|
||||
8. **ASYNC-ONLY.** No sync I/O in handlers. Same shape for the whole codebase.
|
||||
9. **OBSERVABILITY BY DEFAULT.** structlog JSON + correlation ID + OTel + Langfuse in `core/tracing.py`. Every endpoint instrumented without opt-in.
|
||||
10. **STRANGLER FIG MIGRATION.** New skeleton co-exists with old code. Old `main.py` keeps importing the old routers. New routes added alongside. Per-domain cutover, not big-bang.
|
||||
|
||||
## Migration Order
|
||||
|
||||
| Order | Domain | Why |
|
||||
|-------|--------|-----|
|
||||
| 0 | `rag_engine` shim | unblock prod crash, temp until `app/rag/` lands |
|
||||
| 1 | `core/` | foundation everyone depends on |
|
||||
| 2 | `infra/` | external integrations domain depends on |
|
||||
| 3 | `alerts` | smallest, well-bounded, has WS + JWT + redis — proves full pattern |
|
||||
| 4 | `wallet` | high-value, used by frontend |
|
||||
| 5 | `token` | high-value |
|
||||
| 6 | `scanner` | biggest (4,109 lines), do last when pattern is mature |
|
||||
| 7 | `x402` | payment system, critical, mature pattern by then |
|
||||
| 8 | `intel`, `scam`, `databus`, `bulletin` | long tail |
|
||||
| 9 | `rag` consolidation (was 14 files) | last because it's the most coupled |
|
||||
|
||||
---
|
||||
|
||||
# THE 14-POINT MODERN BUILDER BAR (v3 — non-negotiable quality gate)
|
||||
|
||||
Every file that touches the unfuck MUST clear this. Pre-commit enforces what can be enforced.
|
||||
|
||||
```
|
||||
# Standard Status Enforcement
|
||||
1 Pydantic v2 strict (ConfigDict) PARTIAL pre-commit mypy
|
||||
2 async-only handlers DONE pre-commit AST scan
|
||||
3 <500 lines/file (pre-commit) DONE pre-commit file-size-cap
|
||||
4 pre-commit hooks (ruff+mypy+gitleaks+
|
||||
detect-secrets+bandit+file-size+pytest) PARTIAL install on dev + CI
|
||||
5 typed errors (18/18) — currently 4/18 4/18 pre-commit AST scan
|
||||
6 1 type per layer (api→domain→core→infra) PARTIAL architecture test
|
||||
7 structlog everywhere DONE pre-commit no-print()
|
||||
8 event bus (Redis pub/sub) PARTIAL manual review
|
||||
9 per-domain /health/{domain} PARTIAL manual review
|
||||
10 per-route cost tracking NONE M7 middleware (P2)
|
||||
11 no from-main-import DONE pre-commit AST scan
|
||||
12 single get_redis() DONE pre-commit no-new-get-redis
|
||||
13 OTel trace coverage >85% 0% M4 wiring (P1)
|
||||
14 Langfuse span coverage >95% LLM calls PARTIAL M4 wiring (P1)
|
||||
```
|
||||
|
||||
**Items 5, 10, 13, 14 are the v3 additions.**
|
||||
|
||||
---
|
||||
|
||||
# THE 30 MUST-DO ITEMS (v3 — ACTIVE PLAN)
|
||||
|
||||
## P0 — Week 1 (32h total — 16h human + 16h M3)
|
||||
|
||||
| # | Item | Hours | Owner | Status | File |
|
||||
|---|------|-------|-------|--------|------|
|
||||
| 1 | main.py lifespan merge crash fixed | 1 | human | ✅ DONE | `main.py` (94 lines) |
|
||||
| 2 | Audit 53 uncommitted modifications | 3 | human | ⏳ TODO | `git diff HEAD` |
|
||||
| 3 | 65 broken crons → Tailscale IP | 4 | human | ⏳ TODO | crons 167.86.116.51 → 100.100.18.18 |
|
||||
| 4 | /metrics endpoint | 2 | M3 | ⏳ TODO | `app/core/metrics.py` (NEW) |
|
||||
| 5 | AlertManager → Telegram | 2 | M3 | ⏳ TODO | `docker-compose.yml` + `alertmanager.yml` |
|
||||
| 6 | Embedder dual-dim wrapper (M3) | 8 | M3 | ⏳ TODO | `app/rag/embedder.py` (NEW) |
|
||||
| 7 | WalletService proper async impl | 6 | M3 | ⏳ STUBS DONE, REAL PENDING | `app/domain/wallet/service.py` |
|
||||
| 8 | Frontend SPA migrate Contabo→netcup | 2 | human | ⏳ TODO | `/var/www/rmi/` |
|
||||
| 9 | GHOST_ADMIN_KEY from gopass | 1 | human | ⏳ TODO | `.env` |
|
||||
| 10 | Load avg 14.19 → identify cause | 3 | human | ⏳ TODO | Erigon or Ollama? |
|
||||
|
||||
## P1 — Week 2 (37h total — 3h human + 34h M3)
|
||||
|
||||
| # | Item | Hours | Owner | Status | File |
|
||||
|---|------|-------|-------|--------|------|
|
||||
| 11 | GlitchTip self-host + wire SDK | 4 | M3 | ⏳ TODO | `docker-compose.glitchtip.yml` (NEW) |
|
||||
| 12 | OTel auto-instrumentation (M4) | 6 | M3 | ⏳ TODO | `app/core/tracing.py` wire into lifespan |
|
||||
| 13 | GitHub Actions CI | 8 | M3 | ⏳ TODO | `.github/workflows/ci.yml` (NEW) |
|
||||
| 14 | Renovate + Dependabot | 2 | M3 | ⏳ TODO | `.github/dependabot.yml` (NEW) |
|
||||
| 15 | Trivy in CI | 2 | M3 | ⏳ TODO | `.github/workflows/ci.yml` |
|
||||
| 16 | Semgrep SAST in CI | 2 | M3 | ⏳ TODO | `.github/workflows/ci.yml` |
|
||||
| 17 | Gitleaks + Trufflehog | 2 | M3 | ⏳ TODO | `.pre-commit-config.yaml` + CI |
|
||||
| 18 | Vault rotation script (gopass) | 3 | human | ⏳ TODO | `scripts/rotate_secrets.sh` (NEW) |
|
||||
| 19 | Rate limit middleware on all /api/v1/* | 4 | M3 | ⏳ TODO | `app/core/middleware.py` |
|
||||
| 20 | Auth Depends() on legacy routes | 4 | M3 | ⏳ TODO | `app/core/middleware.py` |
|
||||
|
||||
## P2 — Week 3 (46h total — 12h human + 34h M3)
|
||||
|
||||
| # | Item | Hours | Owner | Status | File |
|
||||
|---|------|-------|-------|--------|------|
|
||||
| 21 | Security headers (CSP, HSTS, X-Frame) | 3 | M3 | ⏳ TODO | `app/core/middleware.py` |
|
||||
| 22 | Backup restore drill | 3 | human | ⏳ TODO | R2 bucket test |
|
||||
| 23 | 4 Grafana dashboards | 5 | M3 | ⏳ TODO | `grafana/dashboards/*.json` |
|
||||
| 24 | Container memory limits (all 36) | 3 | M3 | ⏳ TODO | `docker-compose.yml` |
|
||||
| 25 | tests/{e2e,contract,load}/ scaffolding | 4 | M3 | ⏳ TODO | `tests/e2e/` `tests/contract/` `tests/load/` |
|
||||
| 26 | k6 baselines (top 50 routes) | 6 | M3 | ⏳ TODO | `tests/load/baseline.js` |
|
||||
| 27 | Pact contracts + openapi-generator SDK | 5 | M3 | ⏳ TODO | `tests/contract/` |
|
||||
| 28 | Top 10 ADRs | 5 | human | ⏳ TODO | `docs/adr/0001-0010.md` |
|
||||
| 29 | Runbooks + postmortem + oncall | 4 | human | ⏳ TODO | `docs/runbooks/` `docs/postmortems/` `docs/oncall/` |
|
||||
| 30 | Agent Loop Spec productionization (M1) | 8 | M3 | ⏳ TODO | `app/agents/loop.py` (NEW, frozen on creation) |
|
||||
|
||||
## P3 — Defer until revenue
|
||||
|
||||
Multi-region read replicas. SOC2 audit. Paid third-party pen-test. SBOM distribution. AsyncAPI WebSocket spec. Frontend RUM (Sentry browser SDK, web vitals). Pyroscope continuous profiling. OWASP ZAP.
|
||||
|
||||
---
|
||||
|
||||
# THE 8 AI-FORWARD MODULES (v3)
|
||||
|
||||
## M1: Agent Loop Productionization (P2 #30)
|
||||
|
||||
**Deliverable: `app/agents/loop.py` (frozen on creation)**
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
|
||||
class TaskInput(BaseModel):
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
task_id: str = Field(..., pattern=r"^\d+-[a-z0-9-]+$")
|
||||
description: str = Field(..., max_length=500)
|
||||
context_budget_tokens: int = Field(default=8000, le=32000)
|
||||
allowed_tools: list[str] = Field(..., min_length=1)
|
||||
success_criteria: str = Field(..., max_length=300)
|
||||
verify_command: str = Field(..., max_length=200)
|
||||
|
||||
class LoopBudget(BaseModel):
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
max_iterations: int = Field(default=20, le=100)
|
||||
max_tokens: int = Field(default=50_000, le=500_000)
|
||||
max_wallclock_seconds: int = Field(default=900, le=3600)
|
||||
max_spend_usd: float = Field(default=5.0, le=50.0)
|
||||
|
||||
class TaskOutput(BaseModel):
|
||||
task_id: str
|
||||
files_touched: list[str]
|
||||
lines_added: int
|
||||
lines_removed: int
|
||||
verify_passed: bool
|
||||
worklog_entry: str
|
||||
spend_usd: float
|
||||
iterations_used: int
|
||||
```
|
||||
|
||||
**Kill switches** (every iteration):
|
||||
- max_iterations ≤ 20 (max 100)
|
||||
- max_tokens ≤ 50K (max 500K)
|
||||
- max_wallclock ≤ 900s (max 3600s)
|
||||
- max_spend ≤ $5 (max $50)
|
||||
- verify_command_failed_3x → human review
|
||||
|
||||
**fact_store companion** (`app/agents/fact_store.py`):
|
||||
- Redis key: `fact:{namespace}:{key}`
|
||||
- TTL: 86400s
|
||||
- Loaded at loop start, written at loop end
|
||||
- Prevents re-discovery of known facts
|
||||
|
||||
**Acceptance gate: 7 consecutive days zero runaway.**
|
||||
|
||||
## M2: MCP Mesh v2 (P2 #24, D9)
|
||||
|
||||
**Deliverable: `app/mcp/manifest.py` (frozen on creation)**
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Any
|
||||
|
||||
class MCPToolManifest(BaseModel):
|
||||
name: str = Field(..., pattern=r"^[a-z_]+:[a-z_]+$") # e.g. "rmi-netcup:docker_ps"
|
||||
version: str = Field(..., pattern=r"^\d+\.\d+\.\d+$")
|
||||
server: str = Field(..., pattern=r"^[a-z-]+$")
|
||||
description: str = Field(..., max_length=200)
|
||||
input_schema: dict[str, Any]
|
||||
output_schema: dict[str, Any]
|
||||
auth_scope: str = Field(..., pattern=r"^[a-z_]+:[a-z_]+$") # gopass scope
|
||||
deprecated: bool = False
|
||||
successor: str | None = None
|
||||
```
|
||||
|
||||
**Mesh registry on netcup `:8643`** with:
|
||||
- GET /tools (list all with filters)
|
||||
- GET /tools/{name} (full manifest)
|
||||
- POST /resolve (name+MAJOR version → executor endpoint)
|
||||
|
||||
**3 unwired servers to wire in D9:**
|
||||
- OpenCTI (40 tools) — wire with FastMCP
|
||||
- Dify (18 tools) — wire with FastMCP
|
||||
- n8n (20 tools) — wire with FastMCP
|
||||
|
||||
**Acceptance gate: 332+ tools versioned, all gopass-backed auth, OpenCTI+Dify+n8n wired.**
|
||||
|
||||
## M3: RAG Dual-Dim Wrapper (P0 #6)
|
||||
|
||||
**Deliverable: `app/rag/embedder.py` (wraps frozen `app/crypto_embeddings.py`)**
|
||||
|
||||
```python
|
||||
from enum import Enum
|
||||
|
||||
class EmbedderBackend(Enum):
|
||||
BGE_M3 = "bge-m3" # 1024d, legacy
|
||||
QWEN3_4B = "qwen3-embedding:4b" # 2048d, target
|
||||
|
||||
class DualDimEmbedder:
|
||||
def __init__(self, backend: EmbedderBackend):
|
||||
self.backend = backend
|
||||
self.dim = 1024 if backend == EmbedderBackend.BGE_M3 else 2048
|
||||
|
||||
async def embed(self, texts: list[str]) -> list[list[float]]:
|
||||
return await self._ollama_embed(texts, self.backend.value)
|
||||
|
||||
async def reindex_collection(self, name: str, target: EmbedderBackend) -> bool:
|
||||
old = DualDimEmbedder(EmbedderBackend.BGE_M3)
|
||||
new = DualDimEmbedder(target)
|
||||
# 1. Read all docs from collection
|
||||
# 2. Re-embed with new backend
|
||||
# 3. Write to new collection {name}_v2
|
||||
# 4. Atomic swap: {name} → {name}_legacy, {name}_v2 → {name}
|
||||
# 5. Verify: query 10 known docs, confirm retrieval
|
||||
return True
|
||||
```
|
||||
|
||||
**The math:**
|
||||
| Metric | bge-m3 | qwen3-4b | Delta |
|
||||
|--------|--------|----------|-------|
|
||||
| Dimensions | 1024 | 2048 | +2x |
|
||||
| RAM (loaded) | 1.2GB | 2.0GB | +800MB |
|
||||
| Embed latency | ~80ms | ~140ms | +60ms |
|
||||
| Retrieval (nDCG@10) | 0.71 | 0.83 (est) | +12% |
|
||||
| Reindex time | — | 6h overnight | one-time |
|
||||
| Storage/coll | ~50MB | ~100MB | +650MB total |
|
||||
|
||||
**Strategy: collection-by-collection overnight, smallest first, verify each morning.** If <8%, defer.
|
||||
|
||||
**Acceptance gate: All 13 collections reindexed, eval harness +8% nDCG@10, bge-m3 unloaded.**
|
||||
|
||||
## M4: Observability Triangle (P1 #11, #12, P2 #21)
|
||||
|
||||
**Three pillars wired into the 94-line lifespan:**
|
||||
|
||||
```python
|
||||
# main.py additions (~15 lines to existing 94)
|
||||
from contextlib import asynccontextmanager
|
||||
from app.core.tracing import setup_otel
|
||||
from app.core.langfuse import langfuse
|
||||
from app.core.errors import init_glitchtip
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
_otel = setup_otel() # OTel: httpx, redis, asyncpg, neo4j
|
||||
init_glitchtip() # Sentry-compatible SDK
|
||||
await app.state.redis.initialize()
|
||||
yield
|
||||
_otel.shutdown()
|
||||
langfuse.flush()
|
||||
await app.state.redis.close()
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
```
|
||||
|
||||
**Pillars:**
|
||||
- **OTel**: auto-instrument httpx/redis/asyncpg/neo4j, export to local OTel collector
|
||||
- **Langfuse**: `@observe()` decorator on every LLM call, >95% coverage
|
||||
- **GlitchTip**: Sentry-compatible SDK, DSN from gopass, captures every unhandled exception
|
||||
|
||||
**Acceptance gate: 7 consecutive days triangle lit.**
|
||||
|
||||
## M5: CI/CD Standards (P1 #13-17, P1 #19, P1 #20)
|
||||
|
||||
**Pipeline stages:**
|
||||
```
|
||||
PRE-COMMIT (local): ruff + mypy + gitleaks + detect-secrets + bandit + file-size + pytest-smoke
|
||||
CI (GH Actions): lint + test-full + build + trivy + semgrep + gitleaks-history + grype
|
||||
DEPLOY (blue-green): build-green → start → health-gate → swap-router → verify-5xx → keep-blue-10min
|
||||
ROLLBACK (auto): swap-back-to-blue on 5xx spike → page → freeze-deploys-1h
|
||||
```
|
||||
|
||||
**Justfile deploy target:**
|
||||
```makefile
|
||||
deploy:
|
||||
docker compose -f docker-compose.green.yml build
|
||||
docker compose -f docker-compose.green.yml up -d
|
||||
./scripts/health-check.sh http://127.0.0.1:8001/health 60
|
||||
./scripts/swap-upstream.sh green
|
||||
./scripts/watch-errors.sh 600 0.01
|
||||
docker compose -f docker-compose.blue.yml stop
|
||||
```
|
||||
|
||||
## M6: ADRs + Runbooks + Postmortems + Oncall (P2 #28, #29)
|
||||
|
||||
**Top 10 ADRs (write these first):**
|
||||
1. Why FastAPI over Litestar/Flask/Django
|
||||
2. Why 5 DBs (Postgres+Redis+CH+Neo4j+Qdrant)
|
||||
3. Why strangler-fig over rewrite
|
||||
4. Why bge-m3 → qwen3-embedding:4b (M3)
|
||||
5. Why self-host GlitchTip over Sentry SaaS
|
||||
6. Why single-VPS blue-green over k3s
|
||||
7. Why gopass over Vault/Doppler/AWS SM
|
||||
8. Why Hermes cron over systemd/Airflow
|
||||
9. Why MCP mesh over direct API calls
|
||||
10. Why Tailscale over WireGuard/ZeroTier
|
||||
|
||||
**ADR Template:**
|
||||
```
|
||||
# ADR-XXXX: [Title]
|
||||
## Status: Accepted | Rejected | Superseded
|
||||
## Date: YYYY-MM-DD
|
||||
## Decider: @cryptorugmunch
|
||||
## Context: What we're solving
|
||||
## Decision: What we chose
|
||||
## Alternatives Considered: What we rejected
|
||||
## Consequences: Easier / Harder / Mitigations
|
||||
```
|
||||
|
||||
## M7: Cost Tracking + SLOs + Error Budgets (P2 #21)
|
||||
|
||||
**Per-tenant cost middleware (`app/middleware/cost_tracking.py`):**
|
||||
```python
|
||||
class CostTrackingMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request, call_next):
|
||||
tenant = request.headers.get("X-Tenant-ID", "anonymous")
|
||||
t0 = time.monotonic()
|
||||
response = await call_next(request)
|
||||
elapsed_ms = (time.monotonic() - t0) * 1000
|
||||
cost = self._estimate_cost(route, response, elapsed_ms)
|
||||
await self._buffer_cost(tenant, route, cost, elapsed_ms)
|
||||
return response
|
||||
```
|
||||
|
||||
**SLO Targets:**
|
||||
| Service | SLO | Error budget |
|
||||
|---------|-----|--------------|
|
||||
| /api/v1/* p99 latency | < 500ms | 43min/mo |
|
||||
| /api/v2/* p99 latency | < 200ms | 43min/mo |
|
||||
| /health uptime | ≥ 99.5% | 3.6h/mo |
|
||||
| /api/* error rate (5xx) | < 0.1% | 43min of 5xx/mo |
|
||||
| RAG retrieval success | > 95% | 43min of failures/mo |
|
||||
| LLM calls timeout rate | < 1% | 7.2h of timeouts/mo |
|
||||
|
||||
**Auto-freeze deploys on 4x burn:**
|
||||
```yaml
|
||||
- alert: SLOBurnRate4x
|
||||
expr: (sum(rate(http_requests_total{status=~"5.."}[1h])) / sum(rate(http_requests_total[1h]))) > 0.02
|
||||
for: 2m
|
||||
annotations:
|
||||
webhook: "http://127.0.0.1:8642/ci/freeze"
|
||||
```
|
||||
|
||||
## M8: AI Tool Wiring Matrix
|
||||
|
||||
| Task | Best tool | Worst tool |
|
||||
|------|-----------|------------|
|
||||
| Bounded code edit (1 file, <50 lines) | **aider** | claude-code |
|
||||
| Multi-file refactor (3–10 files) | **claude-code** | aider |
|
||||
| Codebase search | **Bloop** (when installed) | grep |
|
||||
| Test generation | **claude-code** | aider |
|
||||
| Doc generation (ADR, runbook) | **GLM-5.2** | aider |
|
||||
| Security review | **Semgrep** (not LLM) | any LLM |
|
||||
| Dependency upgrade | **Renovate** (not LLM) | any LLM |
|
||||
| Prompt engineering | **GLM-5.2 + DSPy** | claude-code |
|
||||
| RAG eval | Custom harness + LLM-as-judge | any LLM alone |
|
||||
| Long-context (≥100K) | **Kimi K2.7** (256K) | DeepSeek (slow) |
|
||||
| Vision | **Gemini 2.5-pro** | text-only LLM |
|
||||
| Fast small (≤500 tokens) | **Groq** | DeepSeek |
|
||||
| Autonomous (bounded loop) | **Hermes** | claude-code |
|
||||
|
||||
---
|
||||
|
||||
# 10 INCIDENT SCENARIOS (v3)
|
||||
|
||||
## v2's 7 (preserved)
|
||||
1. rmi-redis crashes at 3am → auto-restart
|
||||
2. Disk full on netcup → AlertManager page at 80%
|
||||
3. Postgres corruption → restore from nightly backup
|
||||
4. SSL cert expiry → Cloudflare manages, Tailscale renew
|
||||
5. OOM kill on rmi-backend → Docker limit + py-spy/memray
|
||||
6. DDoS on rugmunch.io → Cloudflare "Under Attack" mode
|
||||
7. DeepSeek balance hits $0 → failover to Ollama Cloud/Gemini
|
||||
|
||||
## v3's 3 NEW (AI-specific)
|
||||
|
||||
### Scenario 8 — Agent Loop Runaway
|
||||
**Symptom:** LLM spend spikes ($50+/h). Hermes logs show task iterating >50×.
|
||||
**Detect:** Daily spend alert (M7) at $20/h. Hermes /cron/health shows iterations >20.
|
||||
**Mitigate:** `pkill -f "hermes.*task_id=X"`. Disable cron job.
|
||||
**Recover:** Audit input contract — likely missing success_criteria or verify_command.
|
||||
**Root cause:** vague description or missing verify_command.
|
||||
|
||||
### Scenario 9 — RAG Poisoning
|
||||
**Symptom:** RAG retrieval returns adversarial content. User reports "the AI told me to send funds to X."
|
||||
**Detect:** Langfuse span shows suspicious source. Eval harness flags quality regression.
|
||||
**Mitigate:** Disable affected collection. Roll back FAISS index.
|
||||
**Recover:** Add source allowlist to ingest step.
|
||||
**Root cause:** open ingestion endpoint or untrusted RSS feed.
|
||||
|
||||
### Scenario 10 — MCP Tool Compromise
|
||||
**Symptom:** Unusual gopass access pattern. MCP tool called from unexpected IP. Lateral movement.
|
||||
**Detect:** gopass audit log. CrowdSec flags anomalous SSH. Tailscale ACL violation.
|
||||
**Mitigate:** `gopass rm rmi/infra/compromised/scope` + rotate. Revoke Tailscale node. Block IP.
|
||||
**Recover:** Audit all gopass accesses in last 30 days. Rotate ALL keys.
|
||||
**Root cause:** over-broad auth_scope or leaked Tailscale key. SEV1.
|
||||
|
||||
---
|
||||
|
||||
# FROZEN FILE MANIFEST v3 (14 entries)
|
||||
|
||||
| # | File | Why frozen |
|
||||
|---|------|------------|
|
||||
| 1 | `main.py` | Front door (was 8475, now 94) |
|
||||
| 2 | `_legacy_main.py` | Strangler-fig target |
|
||||
| 3 | `app/core/redis.py` | Single Redis source |
|
||||
| 4 | `app/core/config.py` | Env var loader |
|
||||
| 5 | `app/databus/core.py` | 96-chain fetch interface |
|
||||
| 6 | `app/rag/pipeline.py` | RAG pipeline (frozen for M3) |
|
||||
| 7 | `app/crypto_embeddings.py` | bge-m3 embedder (replaced via wrapper) |
|
||||
| 8 | `app/api/v1/__init__.py` | Router registry |
|
||||
| 9 | `docker-compose.yml` | All 36 containers |
|
||||
| 10 | `app/core/tracing.py` | OTel (not wired — M4) |
|
||||
| 11 | `app/agents/loop.py` | M1 deliverable (frozen on creation) |
|
||||
| 12 | `app/mcp/server.py` | M2 deliverable (frozen on creation) |
|
||||
| 13 | `app/core/metrics.py` | P0 deliverable (frozen on creation) |
|
||||
| 14 | `app/middleware/cost_tracking.py` | M7 deliverable (frozen on creation) |
|
||||
|
||||
**Rule:** Modified only via bounded-task delegation with Task ID in `/home/z/my-project/worklog.md`. Out-of-process edits reverted.
|
||||
|
||||
---
|
||||
|
||||
# THE MATH (v3 BRUTAL TRUTH)
|
||||
|
||||
```
|
||||
Solo With M3
|
||||
P0 + P1 + P2 hours 115h 115h
|
||||
Available budget (21d×8h) 168h 168h human + 152h M3 = 320h
|
||||
Buffer (30% incident tax) −50h −50h human, −46h M3
|
||||
Effective capacity 118h 224h
|
||||
NET (capacity − work) +3h +109h
|
||||
↑ Phase 4 fits here
|
||||
|
||||
Plus 20h passive waits (eval soaks, mesh verifications)
|
||||
→ Solo: 138h needed vs 118h available — slips to Day 25
|
||||
→ M3: 138h needed vs 224h available — fits comfortably
|
||||
```
|
||||
|
||||
**Verdict: Solo, v3 is a 25-day plan. With M3 parallel, v3 is a 21-day plan.**
|
||||
|
||||
---
|
||||
|
||||
# 90-DAY ROADMAP (after the unfuck)
|
||||
|
||||
## Month 2 — Agent Mesh + RAG Eval + SDK
|
||||
- Agent mesh: Hermes orchestrating multiple claude-code/aider in parallel
|
||||
- RAG eval harness: automated retrieval-quality regression on every push
|
||||
- SDK v1: TypeScript + Python clients from OpenAPI spec
|
||||
|
||||
## Month 3 — Multi-Region + SOC2 + Paid Pilots
|
||||
- Multi-region read replicas (Contabo → Cloudflare LB)
|
||||
- SOC2 prep (ADRs + runbooks = 60% of evidence)
|
||||
- 3 paid pilots, $500/mo each, 60-day commitment
|
||||
|
||||
## Month 4+ — Hire or Stay Solo
|
||||
- If 3 pilots + 1 conversion → hire senior backend engineer
|
||||
- If pilots but no conversion → focus on conversion
|
||||
- If no pilots → pivot
|
||||
|
||||
---
|
||||
|
||||
# What Ships This Pass (Foundation)
|
||||
|
||||
1. ✅ Fix crash — `rag_engine` re-export shim, backend healthy (Phase 0)
|
||||
2. ✅ `pyproject.toml` — uv + ruff + mypy strict + pytest
|
||||
3. ✅ `.pre-commit-config.yaml` — ruff + mypy + size cap (500) + gitleaks
|
||||
4. ✅ `app/core/` — 11 modules, each <200 lines
|
||||
5. ✅ `app/api/v1/__init__.py` — router aggregator
|
||||
6. ✅ `main.py` — 94 lines (Phase 0 just restored)
|
||||
7. ✅ Verify: backend boots, all 1249 routes respond, health 200
|
||||
8. ✅ Commit + deploy (`4a8a16d`)
|
||||
|
||||
## What Does NOT Ship This Pass (v3 P0-P2)
|
||||
|
||||
- Migrating alerts/wallet/token/scanner to new `domain/`. That's the current unfuck.
|
||||
- The 15 mechanical refactors. Replaced with the layered architecture.
|
||||
- Deleting old code. Strangler fig — old stays until domain is migrated.
|
||||
- **v3 new**: M1 Agent Loop, M2 MCP Mesh, M3 RAG dual-dim, M4 Observability Triangle, M5 CI/CD, M6 ADRs, M7 Cost+SLOs, M8 AI Tool Matrix.
|
||||
|
||||
## Phase 2: Alerts Vertical Slice (proves the pattern) — COMPLETE
|
||||
|
||||
After foundation lands, migrate `alerts` end-to-end as the reference:
|
||||
```
|
||||
app/domain/alerts/
|
||||
├── models.py # Alert, AlertRule, Notification — Pydantic v2
|
||||
├── repository.py # async SQLAlchemy queries
|
||||
├── service.py # business logic, pure Python
|
||||
└── broadcaster.py # WebSocket broadcast helper
|
||||
|
||||
app/api/v1/auth/alerts.py # thin route: parse → call service → return
|
||||
```
|
||||
|
||||
This proves the pattern works: domain is pure Python, route is <100 lines, can be unit tested without HTTP.
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# System deps
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc libpq-dev curl && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Python deps
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy app
|
||||
COPY . .
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||
CMD curl -f http://localhost:8000/health || exit 1
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["python", "-u", "main.py"]
|
||||
|
||||
|
|
@ -1,160 +0,0 @@
|
|||
# Email Forwarding Setup Guide
|
||||
# =============================
|
||||
#
|
||||
# This system forwards emails from your domains to a Gmail inbox,
|
||||
# then polls that inbox and forwards emails to your Telegram admin bot.
|
||||
#
|
||||
# NO AUTO-REPLIES - emails appear in Telegram for admin review.
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
SETUP STEPS
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
1. CREATE GMAIL RECEIVER ACCOUNT
|
||||
------------------------------
|
||||
Create a new Gmail account (or use existing):
|
||||
|
||||
Example: rugmunch.admin@gmail.com
|
||||
|
||||
IMPORTANT: Enable 2FA and create an App Password:
|
||||
- Go to: https://myaccount.google.com/security
|
||||
- Enable 2-Step Verification (if not already)
|
||||
- Go to: https://myaccount.google.com/apppasswords
|
||||
- Create a new app password (select "Mail" and your device)
|
||||
- COPY THE 16-CHARACTER PASSWORD (no spaces)
|
||||
|
||||
You'll need:
|
||||
- Email: rugmunch.admin@gmail.com (or your choice)
|
||||
- App Password: XXXX XXXX XXXX XXXX (16 chars)
|
||||
|
||||
2. CONFIGURE CLOUDFLARE EMAIL ROUTING
|
||||
-----------------------------------
|
||||
|
||||
For rugmunch.io:
|
||||
- Log in to Cloudflare dashboard
|
||||
- Go to your rugmunch.io zone
|
||||
- Click "Email" → "Email Routing"
|
||||
- Click "Add Route"
|
||||
- Create these addresses (all forward to same Gmail):
|
||||
|
||||
admin@rugmunch.io → rugmunch.admin@gmail.com
|
||||
support@rugmunch.io → rugmunch.admin@gmail.com
|
||||
contact@rugmunch.io → rugmunch.admin@gmail.com
|
||||
|
||||
For cryptorugmunch.com:
|
||||
- Go to your cryptorugmunch.com zone
|
||||
- Click "Email" → "Email Routing"
|
||||
- Click "Add Route"
|
||||
- Create these addresses:
|
||||
|
||||
admin@cryptorugmunch.com → rugmunch.admin@gmail.com
|
||||
team@cryptorugmunch.com → rugmunch.admin@gmail.com
|
||||
info@cryptorugmunch.com → rugmunch.admin@gmail.com
|
||||
|
||||
IMPORTANT: Make sure Email Routing is ENABLED (toggle ON)
|
||||
|
||||
3. CONFIGURE BACKEND ENVIRONMENT VARIABLES
|
||||
----------------------------------------
|
||||
|
||||
Add these to your /root/.secrets/project_envs/rmi-backend.env:
|
||||
|
||||
# Email Forwarding
|
||||
EMAIL_RECEIVER=rugmunch.admin@gmail.com
|
||||
EMAIL_PASSWORD=xxxx xxxx xxxx xxxx (app password, no spaces)
|
||||
TELEGRAM_ADMIN_CHAT_ID=123456789 (your admin Telegram chat ID)
|
||||
EMAIL_POLL_INTERVAL=60 # check every 60 seconds
|
||||
|
||||
# Get your Telegram Chat ID:
|
||||
- Message your bot: @userinfobot
|
||||
- Send any message
|
||||
- It will reply with your ID (e.g., 123456789)
|
||||
|
||||
4. START RESTART BACKEND
|
||||
----------------------
|
||||
Restart your backend service:
|
||||
|
||||
sudo systemctl restart rmi-backend
|
||||
|
||||
Or if running manually:
|
||||
cd /srv/rmi/backend
|
||||
source venv/bin/activate
|
||||
python main.py
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
VERIFICATION
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
Check logs for email polling startup:
|
||||
sudo journalctl -u rmi-backend -f
|
||||
|
||||
You should see:
|
||||
[RMI] 📧 Email polling enabled - starting IMAP service
|
||||
|
||||
Send a test email to:
|
||||
admin@rugmunch.io
|
||||
|
||||
Wait 1-2 minutes. You should receive a Telegram message in your admin chat:
|
||||
|
||||
📧 New Email
|
||||
|
||||
From: sender@example.com
|
||||
Domain: rugmunch.io
|
||||
Subject: Test Email
|
||||
Time: 2026-05-01 12:34:56
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
This is the email body...
|
||||
|
||||
Inbox: rugmunch.admin@gmail.com
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
FAQ
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
Q: Can users reply to these emails?
|
||||
A: Emails can be replied to (standard email), but your system
|
||||
won't auto-reply. Admins review in Telegram and respond manually
|
||||
via their email client.
|
||||
|
||||
Q: What happens if Gmail IMAP fails?
|
||||
A: The poller logs errors and retries on next interval. Emails
|
||||
are marked as read, so they won't be re-sent to Telegram.
|
||||
|
||||
Q: Can I forward to a different email?
|
||||
A: Yes, just change EMAIL_RECEIVER to any Gmail/IMAP-enabled
|
||||
address. You'll need an app password for Gmail.
|
||||
|
||||
Q: How many emails can I receive?
|
||||
A: Gmail free accounts: 15GB storage (roughly 10,000+ emails)
|
||||
Cloudflare Email Routing: Unlimited forwards
|
||||
|
||||
Q: Do I need to configure MX records?
|
||||
A: NO! Cloudflare Email Routing handles this automatically when
|
||||
you enable Email Routing for your zone.
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
TROUBLESHOOTING
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
"IMAP login failed":
|
||||
- Check EMAIL_PASSWORD is correct (use app password, not regular password)
|
||||
- Ensure IMAP is enabled in Gmail settings
|
||||
- Try logging into Gmail IMAP manually: telnet imap.gmail.com 993
|
||||
|
||||
"No emails appearing in Telegram":
|
||||
- Verify Cloudflare Email Routing is ENABLED
|
||||
- Check that emails are actually arriving in Gmail inbox
|
||||
- Look at backend logs for polling errors
|
||||
- Wait up to 2 minutes (poll interval)
|
||||
|
||||
"Duplicate emails in Telegram":
|
||||
- Emails are marked as read after first poll
|
||||
- Check Gmail isn't moving emails back to inbox
|
||||
- Reset seen_message_ids by restarting backend
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
# Backend Port Map — v3 (Jun 21 2026)
|
||||
|
||||
> **v3 reality**: The legacy multi-port dev instances (8002/8003/8005/8006/8010)
|
||||
> are gone. There is ONE backend on **port 8000** running the new clean
|
||||
> `main.py` (243 lines, no `_legacy_main` import). The legacy 8,475-line
|
||||
> monolith is preserved on disk at `/root/backend/_legacy_main.py` but is
|
||||
> no longer served.
|
||||
|
||||
## Active Services (host-level listen sockets)
|
||||
|
||||
| Port | Bind | Service | Status | Purpose |
|
||||
|------|------|---------|--------|---------|
|
||||
| 8000 | 127.0.0.1 | docker-proxy → rmi-backend | UP (healthy) | FastAPI backend (v3) |
|
||||
| 9090 | 127.0.0.1 | docker-proxy → rmi-prometheus | UP | Prometheus scrape + alert eval |
|
||||
| 9093 | 127.0.0.1 | docker-proxy (orphan) | STALE | Old alertmanager docker-proxy, no container behind it |
|
||||
| 9094 | 0.0.0.0 | docker-proxy (langfuse?) | UP | Langfuse web (alt port) |
|
||||
| 9095 | * | prometheus-alertmanager (host binary) | UP | AlertManager → Telegram routing |
|
||||
| 2368 | 127.0.0.1 | docker-proxy → rmi-ghost | UP | Ghost CMS |
|
||||
| 3000 | 127.0.0.1 | docker-proxy → rmi-grafana | UP | Grafana dashboards |
|
||||
| 3002 | 0.0.0.0 | docker-proxy → langfuse-langfuse-web | UP | Langfuse LLM observability |
|
||||
| 6379 | 127.0.0.1 | docker-proxy → rmi-redis | UP (auth) | Redis (RMI_PROD_REDIS_2026) |
|
||||
| 7474 | 127.0.0.1 | docker-proxy → rmi-neo4j | UP | Neo4j HTTP |
|
||||
| 7687 | 127.0.0.1 | docker-proxy → rmi-neo4j | UP | Neo4j Bolt |
|
||||
| 8095 | 0.0.0.0 | docker-proxy → opencti | UP | OpenCTI |
|
||||
| 8545 | 127.0.0.1 | docker-proxy → rmi-reth | UP | Reth Ethereum RPC (HTTP) |
|
||||
| 8546 | 127.0.0.1 | docker-proxy → rmi-reth | UP | Reth Ethereum RPC (WS) |
|
||||
| 8880 | 0.0.0.0 | nginx (system) | UP | Frontend SPA / public |
|
||||
| 25/143/465/587/993/995 | * | docker-proxy → rmi-mail | UP | Postfix + Dovecot |
|
||||
| 4190 | * | docker-proxy → rmi-mail | UP | Dovecot managesieve |
|
||||
|
||||
## Ports no longer in use
|
||||
|
||||
- **8002 / 8003 / 8005 / 8006 / 8010** — Legacy dev instances, gone in v3.
|
||||
- **9100** — node_exporter (target down, not currently running)
|
||||
|
||||
## Quick health checks
|
||||
|
||||
```bash
|
||||
# Backend (v3)
|
||||
curl http://localhost:8000/health
|
||||
# → {"status":"ok","service":"rmi-backend","version":"2026.06.21",
|
||||
# "deploy_mode":"new-system (no _legacy_main)"}
|
||||
|
||||
# Prometheus
|
||||
curl http://localhost:9090/-/healthy
|
||||
|
||||
# AlertManager
|
||||
curl http://localhost:9095/-/healthy
|
||||
|
||||
# v1 API surface (auto-discover)
|
||||
curl http://localhost:8000/openapi.json | jq '.paths | keys'
|
||||
```
|
||||
|
||||
## Backend ↔ Prometheus Network
|
||||
|
||||
Both run on Docker bridge `rmi_network`:
|
||||
- rmi-backend: 172.19.0.3:8000
|
||||
- rmi-prometheus: 172.19.0.8:9090
|
||||
- gateway (host): 172.19.0.1
|
||||
|
||||
iptables INPUT chain policy is DROP. Required ACCEPT rules:
|
||||
- 8880 (nginx)
|
||||
- 11434 (ollama)
|
||||
- 9095 (added Jun 21, alertmanager <-> prometheus communication)
|
||||
|
||||
## Redis access
|
||||
|
||||
Redis runs in `rmi-redis` container with auth. The v1 backend reads:
|
||||
- `REDIS_HOST=rmi-redis`
|
||||
- `REDIS_PORT=6379`
|
||||
- `REDIS_DB=0`
|
||||
- `REDIS_PASSWORD=RMI_PROD_REDIS_2026`
|
||||
|
||||
The old `redis-server --port 6379` (no auth) recipe is gone — production
|
||||
Redis is authenticated. Local development must use the same env vars.
|
||||
|
||||
## Modern port-discovery (replaces netstat)
|
||||
|
||||
```bash
|
||||
# What replaces `netstat -tlnp | grep :8000`
|
||||
ss -tlnp 'sport = :8000'
|
||||
|
||||
# Free-port picker
|
||||
python3 -c "import socket; s=socket.socket(); s.bind(('', 0)); print(s.getsockname()[1]); s.close()"
|
||||
```
|
||||
|
|
@ -1,186 +0,0 @@
|
|||
"""
|
||||
RMI Pricing & Subscription Architecture v2
|
||||
=============================================
|
||||
|
||||
INTELLIGENT SCAN ECONOMY — One Scan, Many Uses
|
||||
-------------------------------------------------
|
||||
|
||||
The key insight: When a user scans a token address, that's ONE data event.
|
||||
But it can power DOZENS of downstream analyses without re-fetching.
|
||||
|
||||
Example: User scans token 0xDEAD...BEEF
|
||||
→ 1 API call fetches: price, liquidity, holders, contract bytecode
|
||||
→ Powers: risk scan, holder analysis, bubble map, contract audit,
|
||||
funding trace, social sentiment, whale tracking, cross-chain check
|
||||
→ All from one scan input, shared across the DataBus cache
|
||||
|
||||
This means we can offer SCAN PACKS where one scan credit
|
||||
actually delivers comprehensive intelligence across ALL our tools,
|
||||
because the DataBus deduplicates and caches the underlying data.
|
||||
|
||||
COMPETITIVE ANALYSIS (June 2026)
|
||||
---------------------------------
|
||||
|
||||
| Platform | Free Tier | Pro Tier | Enterprise |
|
||||
|-----------------|-----------------------|--------------------|--------------------|
|
||||
| GoPlus Security | 150K CU/mo | $199/mo (6M CU) | $799/mo (37.5M CU)|
|
||||
| Arkham | Limited views | $99/mo | $999/mo |
|
||||
| Nansen | Basic dashboard | $150/mo (Vital) | $1,000/mo (Onchain)|
|
||||
| DexScreener | Free basic | — | Custom |
|
||||
| Bubblemaps | Free V2 | $29/mo pro | B2B custom |
|
||||
| TokenSniffer | Free basic | $99/mo (SnifferPro)| Custom |
|
||||
| Honeypot.is | Free basic | — | — |
|
||||
| Chainalysis KYT | None | — | $50K+/yr |
|
||||
| TRM Labs | None | — | $30K+/yr |
|
||||
| De.Fi | Free basic | $19.99/mo | Custom |
|
||||
| RugCheck | Free token checks | — | — |
|
||||
|
||||
KEY INSIGHT: We're the ONLY platform that gives ONE scan = ALL intelligence.
|
||||
GoPlus charges per CU. Nansen charges per month for limited chains.
|
||||
Arkham gives entity data but no risk scoring. TokenSniffer gives scores only.
|
||||
|
||||
RMI covers 38 chains, 67 data providers, real-time caching, AND risk scoring
|
||||
in a single scan. That's worth a serious premium.
|
||||
|
||||
PRICING TIERS (v2 — REVISED June 2026)
|
||||
---------------------------------------
|
||||
|
||||
FREE TIER (Anonymous / Fingerprint)
|
||||
- 3 basic scans per day (urlcheck, pulse, token_age)
|
||||
- 1 market overview per day
|
||||
- Limited data per scan (summary only, no deep analysis)
|
||||
- No wallet tracking, no real-time alerts
|
||||
- Powered-by branding on all outputs
|
||||
|
||||
SCOUT PACK — $4.99 (25 scan credits)
|
||||
- 25 scan credits, each = ONE address scanned
|
||||
- Each scan unlocks EVERY tool for that address for 24 hours
|
||||
- Includes: risk scan, holder analysis, bubble map, funding trace,
|
||||
contract audit, whale tracking, social sentiment, cross-chain
|
||||
- Smart money queries: 10 per pack
|
||||
- Market overview: unlimited
|
||||
- Credits never expire
|
||||
- PER-SCAN VALUE: $0.20 per scan (competitive with TokenSniffer's $0.01-0.05
|
||||
per basic scan, but we deliver 10-20x more data per scan)
|
||||
|
||||
HUNTER PACK — $14.99 (150 scan credits)
|
||||
- 150 scan credits, same "one scan = full intelligence" model
|
||||
- 70% discount vs Scout per scan ($0.10/scan)
|
||||
- Includes everything in Scout plus:
|
||||
- Arkham entity intelligence (5 queries)
|
||||
- Deep SENTINEL forensic scans
|
||||
- Nansen smart money labels (10 queries)
|
||||
- Prediction market signals (unlimited)
|
||||
- Real-time alerts (24h per activation)
|
||||
- Portfolio dashboard (3 wallets)
|
||||
|
||||
WHALE PACK — $49.99 (750 scan credits)
|
||||
- 750 scan credits ($0.067/scan — bulk rate)
|
||||
- 85% discount vs Scout per scan
|
||||
- Includes everything in Hunter plus:
|
||||
- Unlimited Arkham entity lookups
|
||||
- Unlimited SENTINEL forensic scans
|
||||
- Unlimited smart money queries
|
||||
- 30-day real-time alerts
|
||||
- Portfolio tracking (25 wallets)
|
||||
- Priority queue (cache bypass)
|
||||
- x402 API access for automation
|
||||
|
||||
MONTHLY SUBSCRIPTIONS
|
||||
─────────────────────
|
||||
|
||||
SCOUT MONTHLY — $19.99/mo
|
||||
- 75 scan credits/month (rolls over 1 month)
|
||||
- All Scout Pack features
|
||||
- Weekly intelligence digest email
|
||||
- Community Discord access
|
||||
|
||||
HUNTER MONTHLY — $49.99/mo
|
||||
- 350 scan credits/month (rolls over 1 month)
|
||||
- All Hunter Pack features
|
||||
- Daily watchlist alerts
|
||||
- Priority support
|
||||
|
||||
WHALE MONTHLY — $149.99/mo
|
||||
- 1,500 scan credits/month (rolls over 1 month)
|
||||
- All Whale Pack features
|
||||
- Dedicated Telegram alert channel
|
||||
- Custom webhooks
|
||||
- API access with higher rate limits
|
||||
- Account manager
|
||||
|
||||
ENTERPRISE — $499/mo (or custom)
|
||||
- Unlimited scans, all tools, all data
|
||||
- Full API access (databus.fetch with admin key)
|
||||
- WebSocket real-time streams
|
||||
- Custom data pipelines
|
||||
- White-label options
|
||||
- Dedicated support & SLA
|
||||
|
||||
COMMUNITY DISCOUNT — 50% OFF for CRM / $cryptorugmunch holders
|
||||
- Verify: Check wallet balance > 0 of CRM (Solana) or
|
||||
$cryptorugmunch (Base/Zora) at purchase time
|
||||
- Applied automatically when wallet connected
|
||||
- Works on ALL tiers (packs and subscriptions)
|
||||
- CRM Solana: 6pnitzwjumnzsvfyfejf9mijzpc4iuqh1xugfwvdf8wb
|
||||
- $cryptorugmunch Base: 0x93c4f6f6f8a14a255e78de0273d6490719d8538e17dfcc9b72907df6a0d72bf204
|
||||
|
||||
PRICE JUSTIFICATION
|
||||
────────────────────
|
||||
|
||||
Why $4.99 for 25 scans when GoPlus gives 150K calls/mo free?
|
||||
- GoPlus gives RAW API calls. Most are useless without interpretation.
|
||||
- Our 1 scan = 15-20 underlying API calls, all aggregated and scored.
|
||||
- Real value: risk assessment, not raw data. A rug pull warning saves $1K+.
|
||||
- Users don't buy API calls; they buy protection.
|
||||
|
||||
Why $14.99 for 150 scans?
|
||||
- Cheaper than Nansen ($150/mo) for a serious trader
|
||||
- More comprehensive than Arkham ($99/mo) for security
|
||||
- Deep analysis that TokenSniffer can't match
|
||||
|
||||
Why $49.99 for 750 scans?
|
||||
- Active investigators use 20-30 scans/day
|
||||
- Cheaper per-scan than any competitor at this volume
|
||||
- Priority access means better data freshness
|
||||
|
||||
Why subscriptions?
|
||||
- Recurring revenue for sustainability
|
||||
- Lower monthly cost vs. buying packs repeatedly
|
||||
- Roll-over credits reduce purchase anxiety
|
||||
|
||||
WHY NOT CHEAPER?
|
||||
- $0.99 for 50 scans devalues the intelligence. Our free tier already
|
||||
gives 3 scans/day. The paid product must feel like a significant step up.
|
||||
- Crypto security is a serious business. Users spending $500-5K on a rug
|
||||
pull want serious tools, not dollar-store pricing.
|
||||
- The 50% community discount already gives holders $2.50/25 or $7.50/150
|
||||
scans — aggressive discount without cheapening the brand.
|
||||
|
||||
IMPLEMENTATION
|
||||
──────────────
|
||||
|
||||
Scan credit tracking: Redis key x402:scan_credits:{wallet}
|
||||
Community discount: Check wallet balance of CRM/$cryptorugmunch tokens
|
||||
Pack purchase: x402 payment (USDC on Base or SOL)
|
||||
Credit deduction: On first API call per unique address per 24h window
|
||||
Address reuse: Same address within 24h = no additional credit deduction
|
||||
|
||||
x402 Tool Pricing (per-call, no pack):
|
||||
- urlcheck: Free (loss leader)
|
||||
- pulse: Free (loss leader)
|
||||
- risk_scan: $0.05
|
||||
- holder_analysis: $0.08
|
||||
- bubble_map: $0.10
|
||||
- contract_audit: $0.15
|
||||
- funding_trace: $0.08
|
||||
- whale_watch: $0.12
|
||||
- sentiment: $0.05
|
||||
- cross_chain: $0.08
|
||||
- arkham_entity: $0.20
|
||||
- sentinel_deep: $0.25
|
||||
|
||||
Pack scanning: 1 credit = all above tools for 1 address for 24h
|
||||
→ Per-credit value: $1.00+ of individual tool calls
|
||||
→ Effective per-scan price: $0.067-$0.20 depending on pack size
|
||||
"""
|
||||
|
|
@ -1,161 +0,0 @@
|
|||
# RMI RAG Modernization — 2026 Standards
|
||||
# ======================================
|
||||
# Design document for upgrading RMI's RAG system to production-grade
|
||||
# modern standards. Based on audit of all 40+ endpoints, 4 pipelines,
|
||||
# 9 collections, and 3 embedders.
|
||||
|
||||
## Current State Audit
|
||||
|
||||
### Collections (crypto_embeddings.py)
|
||||
wallet_profiles, token_analysis, scam_patterns, forensic_reports,
|
||||
market_intel, contract_audits, known_scams, news_articles,
|
||||
transaction_patterns
|
||||
|
||||
### Embedders (INCONSISTENT — 3 different models)
|
||||
- nomic-embed-text (768d) — rag_engine.py, smart_ai_engine.py
|
||||
- bge-m3 (1024d) — rag_ingestion.py, rag_supreme.py
|
||||
- bge-small-en-v1.5 (384d) — crypto_embeddings.py (primary)
|
||||
|
||||
### Pipelines (4 separate, overlapping)
|
||||
- rag_engine.py — Qdrant REST API, nomic-embed-text, 5 collections
|
||||
- rag_service.py — FAISS ANN, bge-small, 9 collections, 3-pillar search
|
||||
- rag_supreme.py — 15-win pipeline, bge-m3, 5 Qdrant collections
|
||||
- rag_firehose.py — continuous ingestion engine (designed, not fully wired)
|
||||
|
||||
### Gaps Identified
|
||||
1. NO historical scam ingestion (Rekt DB, Chainabuse, DeFi hacks)
|
||||
2. NO structured chunking — raw text embedding, no overlap
|
||||
3. NO evaluation running (RAGAS mentioned, not active)
|
||||
4. Embedding model inconsistency across pipelines
|
||||
5. Firehose sources not wired (cadences defined, fetchers missing)
|
||||
6. NO query transformation in production path
|
||||
7. NO feedback loop active
|
||||
8. Redis SCARD bug (FIXED 2026-06-17)
|
||||
9. FAISS disk indexes exist but Redis backing data evicted for 7/9 collections
|
||||
|
||||
## Modern Standards (2025-2026 Industry Consensus)
|
||||
|
||||
### 1. Chunking Strategy
|
||||
- DEFAULT: Recursive character splitting, 512 tokens, 15% overlap
|
||||
- For code: add class/function boundary separators
|
||||
- For news: sentence-based chunking preserves coherence
|
||||
- For scam reports: semantic chunking on topic boundaries
|
||||
- Overlap: 10-20% (test for your domain — some studies show no benefit)
|
||||
|
||||
### 2. Embedding Models
|
||||
- STANDARDIZE on bge-m3 (1024d) — best open-source, multilingual
|
||||
- Fallback: bge-small-en-v1.5 (384d) for fast/local
|
||||
- Multi-head: different dims for different content types
|
||||
- Contract code: 128d structural features (already in crypto_embeddings.py)
|
||||
- Scam patterns: 384d behavioral embedding
|
||||
- News/articles: 1024d semantic (bge-m3)
|
||||
- Wallet profiles: 64d behavioral fingerprint
|
||||
|
||||
### 3. Retrieval Architecture
|
||||
- HYBRID: Dense (70%) + BM25/Sparse (30%) — 5-15% recall improvement
|
||||
- RRF fusion (Reciprocal Rank Fusion) — proven best for hybrid
|
||||
- Cross-encoder rerank: top-20 → rerank → top-5
|
||||
- MMR dedup: remove near-duplicate results
|
||||
- Query expansion: generate 3 variants, fuse results
|
||||
|
||||
### 4. Ingestion Pipeline (UNIFIED)
|
||||
- SINGLE entry point: POST /api/v1/rag/ingest
|
||||
- Pipeline: Parse → Chunk → Dedup → Classify → Embed → Store → Index
|
||||
- Dedup: content hash in Redis (MD5 of normalized text)
|
||||
- Quality filter: skip docs below quality threshold
|
||||
- Rate limiting: per-collection docs/minute
|
||||
- Batch embedding: groups of 25-50, async
|
||||
|
||||
### 5. Historical Data Sources (NEW)
|
||||
- Rekt DB (de.fi/rekt-database) — 3,000+ DeFi hacks since 2020
|
||||
- Chainabuse — scam reports with addresses
|
||||
- TRM Labs Crypto Crime Report — annual typologies
|
||||
- Elliptic State of Crypto Scams — annual report
|
||||
- Chainalysis Crypto Crime Report — annual trends
|
||||
- SlowMist Hacked Archive — detailed exploit analysis
|
||||
- Immunefi Bug Bounty Reports — vulnerability patterns
|
||||
- CertiK Audit Findings — smart contract vulnerabilities
|
||||
- Solana Compromised Accounts — known drained wallets
|
||||
- Etherscan Labels — 115K+ labeled addresses (already have)
|
||||
|
||||
### 6. Evaluation Framework
|
||||
- RAGAS metrics: faithfulness, answer_relevancy, context_precision, context_recall
|
||||
- Golden test set: 50 known scam queries with expected answers
|
||||
- Run weekly, alert on regression
|
||||
- Track: Hit@5, MRR, NDCG@10
|
||||
|
||||
### 7. Feedback Loop
|
||||
- Scanner hits → boost source weight
|
||||
- False positives → penalize
|
||||
- User corrections → update embeddings
|
||||
- Track helpful docs, boost in future searches
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Standardize & Consolidate (NOW)
|
||||
1. Standardize embedder: bge-m3 (1024d) primary, bge-small (384d) fallback
|
||||
2. Add recursive chunking to ingest pipeline
|
||||
3. Wire firehose sources (Rekt DB, Chainabuse, Etherscan labels)
|
||||
4. Add content hash dedup to all ingestion paths
|
||||
|
||||
### Phase 2: Historical Data Ingestion (THIS WEEK)
|
||||
5. Build Rekt DB scraper → forensic_reports collection
|
||||
6. Build Chainabuse scraper → known_scams collection
|
||||
7. Ingest TRM/Elliptic/Chainalysis annual reports → market_intel
|
||||
8. Ingest SlowMist/Immunefi/CertiK findings → contract_audits
|
||||
|
||||
### Phase 3: Evaluation & Feedback (NEXT WEEK)
|
||||
9. Activate RAGAS evaluation pipeline
|
||||
10. Build golden test set (50 queries)
|
||||
11. Wire feedback loop (scanner hits → boost)
|
||||
12. Add query transformation (HyDE, expansion)
|
||||
|
||||
### Phase 4: Advanced Retrieval (ONGOING)
|
||||
13. Cross-encoder reranking (bge-reranker-v2-m3)
|
||||
14. Parent-child retrieval for long documents
|
||||
15. Multi-modal: code + text + transaction patterns
|
||||
16. Streaming response for agentic investigation
|
||||
|
||||
## New Unified Ingestion Pipeline
|
||||
|
||||
```
|
||||
POST /api/v1/rag/ingest
|
||||
{
|
||||
"documents": [...],
|
||||
"collection": "known_scams",
|
||||
"source": "rekt_db",
|
||||
"chunking": "recursive" // or "semantic", "sentence", "none"
|
||||
}
|
||||
|
||||
Pipeline:
|
||||
1. PARSE — extract text, metadata, entities
|
||||
2. CHUNK — recursive split (512 tokens, 15% overlap)
|
||||
3. DEDUP — MD5 hash check against Redis
|
||||
4. QUALITY — score content, skip if < threshold
|
||||
5. CLASSIFY — route to correct collection
|
||||
6. EMBED — batch embed via bge-m3 (Ollama)
|
||||
7. STORE — Redis (hot) + FAISS (index) + R2 (cold)
|
||||
8. INDEX — update ANN index version
|
||||
```
|
||||
|
||||
## New Collections to Add
|
||||
|
||||
| Collection | Source | Dims | Purpose |
|
||||
|-----------|--------|------|---------|
|
||||
| defi_hacks | Rekt DB, SlowMist | 1024d | Historical DeFi exploits |
|
||||
| rug_timeline | Chainabuse, SENTINEL | 1024d | Rug pull chronology |
|
||||
| vuln_patterns | Immunefi, CertiK | 1024d | Smart contract vulnerabilities |
|
||||
| crime_reports | TRM, Elliptic, Chainalysis | 1024d | Annual crime typologies |
|
||||
| compromised_wallets | Solana, Etherscan | 384d | Known drained addresses |
|
||||
| exploit_techniques | All sources | 1024d | How hacks were executed |
|
||||
|
||||
## Success Metrics
|
||||
|
||||
- RAG total_docs: 2,473 → 50,000+ (20x)
|
||||
- Collections with data: 2/9 → 9/9 + 6 new
|
||||
- Embedding consistency: 3 models → 1 primary + 1 fallback
|
||||
- Ingestion cadence: ad-hoc → continuous (firehose)
|
||||
- Evaluation: none → weekly RAGAS
|
||||
- Chunking: none → recursive 512-token
|
||||
- Dedup: none → content hash
|
||||
- Cold storage: partial → full R2 permanence
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
# RAG R2 Storage — Setup Required
|
||||
|
||||
## One-time Cloudflare setup:
|
||||
|
||||
1. Create R2 bucket "rmi-rag-storage" in Cloudflare dashboard
|
||||
2. Generate R2 API token with Object Read & Write permissions
|
||||
3. Set environment variables:
|
||||
- R2_ACCESS_KEY (the Access Key ID from R2 token)
|
||||
- R2_SECRET_KEY (the Secret Access Key from R2 token)
|
||||
|
||||
## Architecture (already deployed):
|
||||
|
||||
Hot → Redis (in-memory, fast queries, always available)
|
||||
Warm → Local /data/rag-storage (7-day cache, auto-cleaned)
|
||||
Cold → Cloudflare R2 (permanent, 10GB free, zero egress)
|
||||
|
||||
## Endpoints (all working, all bypass write middleware):
|
||||
|
||||
POST /api/v1/rag/permanence/snapshot → Save all collections to R2
|
||||
POST /api/v1/rag/permanence/restore → Pull latest from R2 into Redis
|
||||
POST /api/v1/rag/permanence/nightly → Full cycle: snapshot→R2, clean local, rebuild ANN
|
||||
GET /api/v1/rag/permanence/stats → R2 usage + local cache stats
|
||||
|
||||
## Cron (active):
|
||||
|
||||
cd0f23b963f2 — runs nightly at 3 AM UTC — full RAG persistence cycle
|
||||
|
|
@ -1,308 +0,0 @@
|
|||
# Rug Munch Intelligence — Platform Documentation
|
||||
|
||||
## The Bloomberg Terminal of Shitcoins
|
||||
|
||||
Rug Munch Intelligence (RMI) is a unified crypto intelligence platform providing **270+ tools** for token security, wallet forensics, whale tracking, market data, and blockchain queries. Every data call routes through our **DataBus pipeline** — 38 data chains, 67 providers, automatic failover, multi-layer caching.
|
||||
|
||||
**Access:** `https://mcp.rugmunch.io` | **Docs:** `https://rugmunch.io/docs` | **Operations:** `@rmialerts` (Telegram)
|
||||
|
||||
---
|
||||
|
||||
## v3 Architecture (Jun 21 2026)
|
||||
|
||||
The backend was rebuilt via **strangler-fig pattern** during Jun 21 crisis ops.
|
||||
The legacy 8,475-line `_legacy_main.py` is preserved but **no longer imported**.
|
||||
The new clean `main.py` (243 lines) mounts v1 routers exclusively.
|
||||
|
||||
**Frozen files** (do not edit without explicit un-freeze):
|
||||
- `main.py`, `_legacy_main.py`, `app/core/redis.py`, `app/core/config.py`,
|
||||
`app/databus/core.py`, `app/rag/pipeline.py`, `app/crypto_embeddings.py`,
|
||||
`app/api/v1/__init__.py`, `docker-compose.yml`, `app/core/tracing.py`,
|
||||
`app/agents/loop.py`, `app/mcp/server.py`, `app/core/metrics.py`,
|
||||
`app/middleware/cost_tracking.py`
|
||||
|
||||
**AI-Forward Modules (M1-M8)** — 8 mandated modules shipping through Jun:
|
||||
- M1 Risk Explainer · M2 News Classifier · M3 Sentiment Stream
|
||||
- M4 On-chain Detective · M5 Threat Modeler · M6 Cost Optimizer
|
||||
- M7 SLO + Error Budgets · M8 Self-Healing Loops
|
||||
|
||||
**Telegram channel surface** (rugmunchbot has admin on all):
|
||||
- @cryptorugmuncher (main) · @rmicryptonews (news)
|
||||
- @rmiupdates (changelog) · @rmialerts (operations — wired to AlertManager)
|
||||
- @rmialpha (intel) · @rmiscans (scan feed)
|
||||
|
||||
**Observability stack:**
|
||||
- Prometheus on 172.19.0.8:9090, scrapes rmi-backend at 172.19.0.3:8000
|
||||
- 11 alert rules across `rmi-backend-critical`, `rmi-infra-warning`, `rmi-slo-burn`
|
||||
- AlertManager on host:9095, routes to @rmialerts via rugmunchbot
|
||||
- `/api/v1/admin/alerts/{webhook,critical,recent}` — in-app alert history
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Discover the platform
|
||||
curl https://mcp.rugmunch.io/.well-known/mcp
|
||||
|
||||
# List all tools
|
||||
curl https://mcp.rugmunch.io/mcp/tools
|
||||
|
||||
# Check platform health
|
||||
curl https://mcp.rugmunch.io/mcp/health
|
||||
|
||||
# Get SDK examples
|
||||
curl https://mcp.rugmunch.io/mcp/sdk
|
||||
```
|
||||
|
||||
**MCP Native (Claude Desktop, Cursor, Windsurf):**
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"rug-munch": {
|
||||
"url": "https://mcp.rugmunch.io/mcp",
|
||||
"transport": "streamable-http"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Platform Capabilities
|
||||
|
||||
### Tool Categories (234+ total, growing — check `/mcp/status` for live count)
|
||||
|
||||
| Category | Count | Description |
|
||||
|----------|-------|-------------|
|
||||
| Security Scanning | 45 | Rug pulls, honeypots, audits, clone detection, MEV protection |
|
||||
| Wallet Intelligence | 38 | PnL, clustering, insider networks, whale tracking, forensics |
|
||||
| Market Data | 32 | Prices, liquidity, volume, arbitrage, trends, OHLCV |
|
||||
| Token Analytics | 28 | Holder distribution, sniper detection, deployer history |
|
||||
| DeFi Analytics | 24 | TVL, yields, protocol risk, liquidity flow, bridges |
|
||||
| Social Signals | 18 | Sentiment, KOL tracking, profile flips, meme scoring |
|
||||
| Caching Shield | 13 | Internal multi-provider data access layer |
|
||||
| Local MCP | 85 | Self-hosted Solana RPC (60) + EVM (25 tools, 86 networks) |
|
||||
| Free Public MCP | 50 | Boar blockchain (ETH, ENS, contracts, keyless) |
|
||||
|
||||
### Supported Chains (96+ via DataBus, 13 native + 86 EVM via local MCP)
|
||||
|
||||
DataBus is the single source of truth: 96 chains, 119 providers, automatic failover.
|
||||
See `app/databus/core.py` and `app/api/v1/public/databus/` for the live surface.
|
||||
|
||||
### Payment Facilitators (8 — plus x402 MCP marketplace gateway)
|
||||
|
||||
Coinbase CDP, EIP-7702 Universal EVM, Cloudflare x402, PayAI, Asterpay (SEPA),
|
||||
TRON Self-Verify, Bitcoin Self-Verify, PrimeV. The x402 MCP marketplace gateway
|
||||
(`x402-mcp-gateway` skill) handles tool-level micropayments in USDC on Base.
|
||||
|
||||
---
|
||||
|
||||
## Pricing & Access
|
||||
|
||||
### Free Trials
|
||||
Every paid tool includes 1-5 free trial calls. No payment required until trials exhausted. Fingerprint-gated anti-abuse. Monthly reset.
|
||||
|
||||
### Individual Tools
|
||||
$0.01 - $0.40 per call. Pay only for what you use. Full automatic refund within 48 hours if tool returns no data.
|
||||
|
||||
### Scan Packs (50-53% off)
|
||||
| Pack | Tools | Price |
|
||||
|------|-------|-------|
|
||||
| Token Hunter Pack | Fresh pairs, snipers, security, deployer, clone detection | $0.09 |
|
||||
| Whale Watcher Suite | Whale scan, accumulation, smart money, syndicates, PnL | $0.14 |
|
||||
| Wallet Forensics | Funding trace, insider network, wash trading, wallet graph | $0.17 |
|
||||
| Market Pulse | Prices, liquidity, arbitrage, sentiment, listings | $0.12 |
|
||||
|
||||
### Membership Tiers (60-90% discount)
|
||||
| Tier | Price/mo | Daily Calls | Best For |
|
||||
|------|----------|-------------|----------|
|
||||
| Scout | $4.99 | 50 | Casual traders, hobby agents |
|
||||
| Hunter | $14.99 | 200 | Active traders, alpha groups |
|
||||
| Whale | $49.99 | 1,000 | Professional funds, market makers |
|
||||
| Institution | $199.99 | 5,000 | Enterprises, high-frequency agents |
|
||||
|
||||
### Streaming Feeds
|
||||
Real-time data via WebSocket + webhook delivery:
|
||||
- New Token Firehose ($0.50/hr) — Every new token across all chains
|
||||
- Whale Alert Stream ($0.75/hr) — Large transfers, positions, accumulation
|
||||
- Multi-Chain Price Feed ($0.30/hr) — OHLCV, volume, arbitrage
|
||||
- Security Alert Feed ($0.60/hr) — Rug pulls, exploits, suspicious activity
|
||||
|
||||
### Deep Research Reports
|
||||
- Token Deep Dive ($0.75) — Full contract audit + deployer + holders + sentiment
|
||||
- Wallet Intelligence Profile ($0.50) — PnL, style, associations, risk
|
||||
- Chain Health Report ($0.25) — Gas, congestion, MEV, TVL, governance
|
||||
- Cross-Chain Fund Trace ($1.50) — Follow money through bridges and mixers
|
||||
|
||||
### Batch Processing (75-90% off)
|
||||
- Batch Token Scanner — 100 tokens, $0.05 per 10
|
||||
- Batch Wallet Analysis — 50 wallets, $0.03 per 10
|
||||
- Batch Pre-Buy Screen — 200 tokens, $0.02 per 50
|
||||
|
||||
### AI Data Feeds
|
||||
- Market Context Feed ($9.99/mo) — LLM-optimized market summaries
|
||||
- Alpha Signal Feed ($19.99/mo) — Scored trading signals
|
||||
- Entity Relationship Graph ($14.99/mo) — Pre-computed wallet clusters
|
||||
|
||||
---
|
||||
|
||||
## Agent Skills (18 Workflows)
|
||||
|
||||
Every agent gets 18 guided workflows teaching best practices:
|
||||
|
||||
**Security & Vetting:** Pre-Buy Token Vetting, Scam Investigation, Post-Rug Forensics, Compliance Screening
|
||||
|
||||
**Trading & Execution:** Launch Day Playbook, MEV/Sandwich Avoidance, Market Making Intelligence, Portfolio Defense
|
||||
|
||||
**Alpha Discovery:** Alpha Discovery Pipeline, Whale Movement Tracking, CEX Listing Prediction, Insider Trading Detection
|
||||
|
||||
**DeFi & Yield:** Yield Farming Optimizer, Cross-Chain Bridge Monitor, DAO Governance Intelligence
|
||||
|
||||
**NFTs & Influencers:** NFT Mint Sniper, KOL Performance Tracker, Airdrop Hunting
|
||||
|
||||
Access at `GET /mcp/skills` — includes anti-abuse rules and 4 ready-to-use agent prompts.
|
||||
|
||||
---
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
### Caching Shield
|
||||
Every data call passes through three layers:
|
||||
1. **L1 Memory Cache** — Sub-millisecond TTL lookup (8s-1hr depending on data type)
|
||||
2. **Rate Limiter** — Token bucket per provider (prevents burning free tier quotas)
|
||||
3. **Provider Chain** — Ordered fallback (3-4 providers per data type)
|
||||
|
||||
### Provider Fallback Chains
|
||||
| Data Type | Primary | Fallback 1 | Fallback 2 | Fallback 3 |
|
||||
|-----------|---------|------------|------------|------------|
|
||||
| Token Price | Jupiter | Solana Tracker | DexScreener | Binance |
|
||||
| Token Metadata | Helius DAS | Solana Tracker | Jupiter | DexScreener |
|
||||
| Wallet Balance | Helius | QuickNode | Alchemy | PublicNode |
|
||||
| Risk Scan | GoPlus | RugCheck | Honeypot | Local Labels |
|
||||
| EVM Funding | Blockscout | Etherscan | Public RPC | Boar MCP |
|
||||
|
||||
### Infrastructure
|
||||
- **Server:** Bare metal VPS, Docker Compose (30 containers)
|
||||
- **Secrets:** GPG-encrypted vault (76 secrets), age-encrypted runtime injection
|
||||
- **CI/CD:** GitHub Actions auto-deploy on push to main
|
||||
- **Edge:** Cloudflare Workers for x402 payment gateway
|
||||
- **Observability:** Langfuse cloud with smart sampling (20% normal, 100% errors)
|
||||
|
||||
### Local MCP Servers
|
||||
Two self-hosted MCP servers run on our infrastructure:
|
||||
- **Solana SVM MCP** (Rust, 32MB binary) — 60 RPC tools, WebSocket subscriptions, built-in x402
|
||||
- **EVM MCP** (TypeScript, bun runtime) — 25 tools across 86 networks, ENS, contracts, gas
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
|
||||
### MCP Protocol (Streamable HTTP)
|
||||
```
|
||||
POST /mcp JSON-RPC endpoint
|
||||
GET /mcp/tools Tool catalog with input schemas
|
||||
GET /mcp/call/{tool_id} Direct tool execution
|
||||
GET /.well-known/mcp Server discovery
|
||||
GET /.well-known/x402 Payment protocol discovery
|
||||
```
|
||||
|
||||
### Platform Endpoints
|
||||
```
|
||||
GET /mcp/health Uptime + response time
|
||||
GET /mcp/status Cache stats, provider health
|
||||
GET /mcp/manifest Auto-updating platform manifest
|
||||
GET /mcp/skills 18 agent workflow guides
|
||||
GET /mcp/membership Plans, pricing, scan packs
|
||||
GET /mcp/sdk Python/TS/curl quick-start
|
||||
GET /mcp/changelog Version history
|
||||
GET /mcp/trials Free trial status
|
||||
GET /mcp/earnings Revenue dashboard
|
||||
```
|
||||
|
||||
### REST API
|
||||
```
|
||||
POST /api/v1/investigate/trace Wallet funding source tracing
|
||||
POST /api/v1/investigate/scan Full investigation
|
||||
GET /api/v1/investigate/chains Supported chains
|
||||
GET /api/v1/cache/health Caching shield status
|
||||
```
|
||||
|
||||
### Dashboard Pages
|
||||
```
|
||||
/earnings Revenue dashboard (auto-refreshing)
|
||||
/investigate Wallet investigation tool
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SDKs & Integration
|
||||
|
||||
**Python:** `pip install rmi-agent-sdk`
|
||||
```python
|
||||
from rmi_agent import RMIAgent
|
||||
agent = RMIAgent() # auto-discovers via /.well-known/mcp
|
||||
result = agent.call("rug_pull_predictor", {"token": "So111..."})
|
||||
```
|
||||
|
||||
**TypeScript:** `npm install @rugmunch/agent-sdk`
|
||||
```typescript
|
||||
import { RMIAgent } from "@rugmunch/agent-sdk";
|
||||
const agent = new RMIAgent();
|
||||
const result = await agent.call("rug_pull_predictor", { token: "So111..." });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Directory Listings
|
||||
|
||||
- **Smithery:** `https://smithery.ai/server/@cryptorugmuncher/rug-munch-intelligence`
|
||||
- **Glama:** `https://glama.ai/mcp/servers/@cryptorugmuncher/rug-munch-intelligence`
|
||||
- **mcp.so:** `https://mcp.so/server/rug-munch-intelligence`
|
||||
- **GitHub:** `https://github.com/Rug-Munch-Media-LLC/rug-munch-intelligence-mcp`
|
||||
- **HuggingFace:** `https://huggingface.co/cryptorugmunch/rug-munch-intelligence`
|
||||
|
||||
---
|
||||
|
||||
## Version History
|
||||
|
||||
### v3.4.0 (2026-06-21) — Crisis Ops Rebuild
|
||||
- Backend rewritten via strangler-fig: clean main.py (243 lines) replaces 8,475-line legacy
|
||||
- DataBus promoted to single source of truth (96 chains, 119 providers)
|
||||
- 14 frozen files — explicit un-freeze required before edit
|
||||
- AI-Forward Modules M1-M8 mandated across the system
|
||||
- Prometheus + AlertManager wired to @rmialerts Telegram channel
|
||||
- x402 MCP marketplace gateway live for tool-level micropayments
|
||||
- 5 new v1 routers: admin/alerts_webhook, databus, x402/payments, rag/search, scanner
|
||||
|
||||
### v3.3.0 (2026-06-01)
|
||||
- 18 agent skills with workflow guides and anti-abuse rules
|
||||
- 4 membership tiers with daily call limits (60-90% discount)
|
||||
- 4 scan packs at 50-53% off individual tools
|
||||
- 4 real-time streaming feeds (WebSocket + webhook)
|
||||
- 4 deep research report products
|
||||
- 3 batch scanning products (75-90% off)
|
||||
- 3 AI-optimized data feeds for LLM consumption
|
||||
- 85 local MCP tools (Solana RPC + EVM 86 networks)
|
||||
- 50 free Boar blockchain tools (ETH, ENS, contracts)
|
||||
- Multi-provider caching shield on every data call
|
||||
- Platform manifest — single source of truth, auto-syncing
|
||||
- Earnings dashboard with wallet tracking
|
||||
- Quality endpoints: health, status, SDK, changelog, trials
|
||||
|
||||
### v3.2.0 (2026-05-15)
|
||||
- POST /mcp JSON-RPC handler
|
||||
- inputSchema on every tool
|
||||
- Dynamic facilitator count
|
||||
- CORS headers for browser clients
|
||||
|
||||
### v3.1.0 (2026-04-01)
|
||||
- /.well-known/mcp discovery
|
||||
- llms.txt for AI agent discovery
|
||||
- x402 payment protocol support
|
||||
- 8 payment facilitators across 13 chains
|
||||
|
||||
---
|
||||
|
||||
**Contact:** mcp@rugmunch.io | **GitHub:** github.com/Rug-Munch-Media-LLC
|
||||
**Operations:** @rmialerts (Telegram, Prometheus-wired)
|
||||
|
|
@ -1,281 +0,0 @@
|
|||
# Rug Munch Intelligence (RMI) — System Map & Build Status
|
||||
|
||||
## LIVE SYSTEM OVERVIEW
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ FRONTEND (React) │
|
||||
│ /root/frontend/ — 20 pages, dist/index.html │
|
||||
│ RugMaps, RugCharts, Alerts, Markets, News, │
|
||||
│ Intelligence, Investigation, ScamSchool, MCP Docs │
|
||||
└────────────────────┬────────────────────────────────────┘
|
||||
│ Supabase + REST API
|
||||
┌────────────────────▼────────────────────────────────────┐
|
||||
│ BACKEND (FastAPI) │
|
||||
│ /root/backend/ — 379 endpoints, 6 routers │
|
||||
│ Docker: rmi_backend (volume-mounted /app/app) │
|
||||
│ 66 backend modules, 8 data connectors │
|
||||
│ │
|
||||
│ WALLET-CLUSTERING ROUTER (14 endpoints) │
|
||||
│ POST /contract-scan → holders → clusters → bundles │
|
||||
│ POST /cluster/detect → 7-method detection │
|
||||
│ POST /cluster/analyze → behavioral fingerprinting │
|
||||
│ GET /health → cache + GNN + spam stats │
|
||||
│ │
|
||||
│ FORENSICS ROUTER (12 endpoints) │
|
||||
│ POST /threat-check → CryptoScamDB + GoPlus + Januus │
|
||||
│ POST /deep-scan → full wallet forensics │
|
||||
│ POST /cross-chain → multi-chain correlation │
|
||||
│ │
|
||||
│ RUGMAPS ROUTER (8 endpoints) │
|
||||
│ GET /analyze/{address} → bubble map generation │
|
||||
│ GET /health │
|
||||
│ │
|
||||
│ CROSS-TOKEN ROUTER (8 endpoints) │
|
||||
│ GET /connections/{wallet} → cross-project links │
|
||||
│ │
|
||||
│ DISCOVERY ROUTER (8 endpoints) │
|
||||
│ GET /tokens → new token discovery │
|
||||
│ │
|
||||
│ X402 TOOLS ROUTER (142 endpoints) │
|
||||
└────────────────────┬────────────────────────────────────┘
|
||||
│
|
||||
┌──────────────┼──────────────┐
|
||||
▼ ▼ ▼
|
||||
┌──────────┐ ┌──────────┐ ┌──────────────┐
|
||||
│ Helius x3 │ │QuickNode │ │ DexScreener │
|
||||
│ (primary) │ │(fallback)│ │ (free tier) │
|
||||
└──────────┘ └──────────┘ └──────────────┘
|
||||
▼ ▼ ▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ UNIFIED PROVIDER (7-source cascade) │
|
||||
│ Helius → Birdeye → Solscan → GMGN → DexScreener → │
|
||||
│ QuickNode → Blockchair Rate-limited 5 req/sec │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## DATA SOURCES (18 API Keys)
|
||||
|
||||
| Source | Key File | Purpose | Status |
|
||||
|---|---|---|---|
|
||||
| Helius x3 | helius_api_key, _2, _3 | Solana RPC primary | LIVE |
|
||||
| QuickNode | quicknode_api_key | Solana RPC fallback | LIVE |
|
||||
| Birdeye | birdeye_api_key | Token data, whale tracking | LIVE |
|
||||
| GMGN | gmgn_api_key | Token discovery | LIVE |
|
||||
| Moralis | moralis_api_key | Multi-chain EVM data | CONFIGURED |
|
||||
| Arkham | arkham_api_key | Entity labeling | CONFIGURED |
|
||||
| CoinGecko | coingecko_api_key | Price data | CONFIGURED |
|
||||
| Dune | dune_api_key | SQL queries on-chain | CONFIGURED |
|
||||
| Nansen | nansen_api_key | Smart money tracking | CONFIGURED |
|
||||
| Solscan | solscan_api_key | Solana transaction data | CONFIGURED |
|
||||
| NVIDIA | nvidia_api_key, dev_api_key | AI inference | CONFIGURED |
|
||||
| OpenRouter | openrouter_api_key | LLM routing | CONFIGURED |
|
||||
| Groq | groq_api_key | Fast LLM inference | CONFIGURED |
|
||||
| SiliconFlow | siliconflow_api_key, _2 | LLM inference | CONFIGURED |
|
||||
| Kimi | kimi_api_key | LLM (Moonshot) | CONFIGURED |
|
||||
| Mistral | mistral_api_key | LLM inference | CONFIGURED |
|
||||
| Gemini | gemini_api_key | Google AI | CONFIGURED |
|
||||
| HuggingFace | huggingface_token | Model downloads | CONFIGURED |
|
||||
| Cloudflare | cloudflare_api_token | Workers, DNS | LIVE |
|
||||
| Telegram | telegram_bot_token | Bot integration | LIVE |
|
||||
|
||||
## DETECTION PIPELINE
|
||||
|
||||
```
|
||||
Token/Address Input
|
||||
│
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ ENTITY REGISTRY │ ← 50+ CEX/DeFi/Mixer addresses
|
||||
│ filter_infra() │ ← 100K+ Solana labels from CSV
|
||||
└────────┬─────────┘
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ SPAM REGISTRY │ ← 2,530 Scam Sniffer addresses
|
||||
│ check_token() │ ← GoldRush 8M spam tokens (6 chains)
|
||||
└────────┬─────────┘ ← OpenSanctions OFAC, Guardian phishing
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ HOLDER ANALYSIS │ ← Helius getProgramAccounts (228K JTO)
|
||||
│ (unified_provider)│ ← Multi-source fallback cascade
|
||||
└────────┬─────────┘
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ BUNDLE DETECTION │ 5 signals:
|
||||
│ (bundle_detector)│ ← atomic_block, common_funder, temporal,
|
||||
└────────┬─────────┘ ← distribution_anomaly, concentration
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ CLUSTER DETECTION│ 7 methods:
|
||||
│ (wallet_clustering)│ ← temporal, counterparty, behavioral,
|
||||
└────────┬─────────┘ ← funding, pattern, ML similarity, sleeper
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ GNN FRAUD SCORE │ ← Random Forest fallback (CPU-only)
|
||||
│ (fraud_gnn) │ ← HuggingFace sklearn (gated, not loaded)
|
||||
└────────┬─────────┘
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ THREAT INTEL │ ← CryptoScamDB (MIT, free)
|
||||
│ (threat_feeds) │ ← GoPlus Security (free tier)
|
||||
└────────┬─────────┘ ← Januus risk scores (open-source)
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ CROSS-CHAIN │ ← Behavioral fingerprinting
|
||||
│ (correlator) │ ← CEX deposit pattern matching
|
||||
└────────┬─────────┘ ← Union-find entity grouping
|
||||
▼
|
||||
RISK SCORE OUTPUT
|
||||
```
|
||||
|
||||
## LOCAL DATA FILES
|
||||
|
||||
| File | Lines | Purpose |
|
||||
|---|---|---|
|
||||
| wallet-labels/solana_cex_labels.csv | 100,001 | All known CEX hot wallets on Solana |
|
||||
| wallet-labels/solana_defi_labels.csv | 1,481 | DeFi protocol addresses (Jupiter, Raydium, etc.) |
|
||||
| wallet-labels/solana_dapp_labels.csv | 1,791 | Dapp addresses |
|
||||
| wallet-labels/etherscan_malicious_labels.csv | 7,781 | Etherscan-flagged malicious contracts |
|
||||
| wallet-labels/malicious_smart_contracts.csv | 754 | Additional malicious contracts |
|
||||
| wallet-labels/ofac_sanctions.json | 0 | (Empty - needs seeding) |
|
||||
| spam/scamsniffer_blacklist.json | 2,531 | Scam Sniffer address blacklist |
|
||||
| SOSANA-CRM-2024.json | 101,916 | Full CRM data dump |
|
||||
| wallet_database.json | 364 | Wallet profiles DB |
|
||||
| rmi.db | 9 | SQLite state |
|
||||
|
||||
## SUPABASE TABLES (8 tables)
|
||||
|
||||
- profiles — user profiles
|
||||
- wallet_labels — labeled wallet data
|
||||
- token_analysis — token analysis results
|
||||
- scam_reports — scam report submissions
|
||||
- alerts — alert configurations
|
||||
- market_intel — market intelligence cache
|
||||
- news — news article cache
|
||||
- forensic_reports — forensic analysis results
|
||||
|
||||
## INFRASTRUCTURE
|
||||
|
||||
| Service | Container | Status | Purpose |
|
||||
|---|---|---|---|
|
||||
| Backend API | rmi_backend | UP (healthy) | FastAPI, 379 endpoints |
|
||||
| n8n Automation | rmi_n8n | UP | Workflow automation |
|
||||
| Worker | rmi_worker | UP (healthy) | Background job processing |
|
||||
| Telegram Bot | telegram-mcp | UP | Telegram bot integration |
|
||||
| Listmonk | rmi-listmonk | UP | Email newsletters |
|
||||
| Dragonfly (Redis) | rmi_dragonfly | UP (healthy) | Caching |
|
||||
| Cloudflare Worker | rmi_cloudflare | UP | CF tunnel/edge |
|
||||
| Ghost CMS | rmi-ghost | UP | Blog/content |
|
||||
| MySQL | rmi-mysql | UP | Database |
|
||||
| Langfuse | langfuse stack | UP | LLM observability |
|
||||
| CF Edge Worker | rag.rugmunch.io | LIVE | RAG caching |
|
||||
|
||||
## WHAT'S WORKING (VERIFIED)
|
||||
|
||||
- [x] Helius RPC — 228K JTO holders detected via getProgramAccounts
|
||||
- [x] Multi-source cascade — Helius → QuickNode → DexScreener fallback
|
||||
- [x] Entity Registry — Binance/Uniswap/Tornado correctly identified
|
||||
- [x] Bundle Detection — JTO = 0.09 confidence (correctly low)
|
||||
- [x] Spam Registry — 2,530 Scam Sniffer addresses loaded
|
||||
- [x] GNN Scoring — Random Forest fallback active (HuggingFace model gated)
|
||||
- [x] Threat Feeds — GoPlus + CryptoScamDB + Januus integrated
|
||||
- [x] All 5 health endpoints returning "ok"
|
||||
- [x] All 10 core modules importing clean
|
||||
- [x] RAG Edge Worker at rag.rugmunch.io returning health ok
|
||||
- [x] n8n running with database
|
||||
- [x] Telegram bot connected to Telegram servers
|
||||
|
||||
## WHAT'S BROKEN / NEEDS WORK
|
||||
|
||||
### Critical
|
||||
- [ ] **RAG collections empty** — wallet_profiles, scam_patterns, forensic_reports all 0 docs. n8n needs to feed these. Only news_articles has 4 docs.
|
||||
- [ ] **OFAC sanctions empty** — wallet-labels/ofac_sanctions.json is 0 lines. Needs seeding from opensanctions.org
|
||||
- [ ] **563 uncommitted files** — backend has substantial uncommitted changes (Dockerfile, x402, entity_labeler, portfolio_tracker, etc.)
|
||||
- [ ] **Frontend only has index.html** — 20 page source files exist but dist/ only has index.html. Other pages (docs, pricing, tools, x402) were deleted.
|
||||
|
||||
### High Priority
|
||||
- [ ] **HuggingFace model gated** — fraud_gnn.py falls back to heuristic Random Forest because the sklearn model requires auth. Need to either get access or train a proper model.
|
||||
- [ ] **n8n workflows not queryable** — 6 workflows claimed but API returned empty. Need to verify they're running.
|
||||
- [ ] **CryptoGuard/GoPlus integration testing** — threat_feeds.py has the code but needs live testing with known scam addresses.
|
||||
- [ ] **Entity labeler refactoring** — entity_labeler.py has 1084 lines of changes uncommitted, needs cleanup.
|
||||
- [ ] **Exchange flow analyzer** — exchange_flow_analyzer.py reworked but uncommitted.
|
||||
|
||||
### Medium Priority
|
||||
- [ ] **Frontend build pipeline** — Need to build and deploy the React frontend properly with all 20 pages.
|
||||
- [ ] **Telegram bot features** — Bot is connected but needs command handlers for RMI features (scan, alert, etc.)
|
||||
- [ ] **Email alerts** — Listmonk is running but not wired to RMI alert system.
|
||||
- [ ] **CF Worker source** — rag.rugmunch.io is live but worker source code not in repo.
|
||||
- [ ] **DexScreener connector** — Listed in unified_provider but not tested in cascade.
|
||||
- [ ] **Blockchair connector** — Exists but not wired into unified_provider cascade.
|
||||
- [ ] **EVM connector** — File exists but not tested against real EVM chains.
|
||||
|
||||
### Low Priority / Nice-to-Have
|
||||
- [ ] **x402 payment system** — 142+ endpoints in x402_tools but uncommitted changes.
|
||||
- [ ] **GNN model training** — Train a local sklearn model on known fraud data instead of HF gated model.
|
||||
- [ ] **Cross-chain EVM testing** — cross_chain_correlator has Ethereum CEX addresses but Solana-only testing.
|
||||
- [ ] **Mempool sentinel** — mempool_sentinel.py exists but unclear if active.
|
||||
- [ ] **Wallet monitor** — wallet_monitor.py exists but not connected to alerts.
|
||||
|
||||
## BUILD PLAN — NEXT STEPS
|
||||
|
||||
### Phase 1: Stabilize & Commit (Day 1)
|
||||
1. Commit all 563 uncommitted backend files
|
||||
2. Seed RAG collections (wallet profiles from labels, known scam patterns)
|
||||
3. Seed OFAC sanctions data from OpenSanctions
|
||||
4. Test all threat feeds end-to-end with known scam addresses
|
||||
|
||||
### Phase 2: Frontend & Bot (Day 2-3)
|
||||
5. Build frontend properly — `npm run build` in /root/frontend
|
||||
6. Wire Telegram bot commands: /scan, /alert, /watch, /status
|
||||
7. Deploy frontend to CF Pages or VPS
|
||||
|
||||
### Phase 3: Data Pipeline (Day 3-4)
|
||||
8. Wire n8n workflows to feed RAG collections continuously
|
||||
9. Set up scheduled GoldRush spam token sync (6 chains)
|
||||
10. Set up OpenSanctions daily sync
|
||||
11. Set up Scam Sniffer blacklist auto-update
|
||||
|
||||
### Phase 4: Testing & Hardening (Day 4-5)
|
||||
12. End-to-end test with known scam tokens (not just JTO)
|
||||
13. EVM chain testing with Ethereum addresses
|
||||
14. Load testing on /contract-scan endpoint
|
||||
15. Documentation: API docs, setup guide, architecture diagram
|
||||
|
||||
### Phase 5: Production (Day 5+)
|
||||
16. Set up GitHub Actions CI/CD
|
||||
17. Auto-deploy on merge to main
|
||||
18. Monitoring (Langfuse, uptime checks)
|
||||
19. Rate limiting on public endpoints
|
||||
20. Authentication on sensitive endpoints
|
||||
|
||||
## KEY FILES QUICK REFERENCE
|
||||
|
||||
```
|
||||
/root/backend/app/
|
||||
├── chain_client.py # Rate-limited Solana RPC (Helius + QuickNode)
|
||||
├── chain_cache.py # LRU cache with TTL (500 entries)
|
||||
├── chain_feeder.py # Wallet TX feeding into clustering engine
|
||||
├── unified_provider.py # 7-source data cascade
|
||||
├── bundle_detector.py # 5-signal bundle detection
|
||||
├── entity_registry.py # 50+ CEX/DeFi/Mixer address exclusion
|
||||
├── threat_feeds.py # CryptoScamDB + GoPlus + Januus
|
||||
├── fraud_gnn.py # Random Forest fraud scoring (CPU-only)
|
||||
├── spam_registry.py # 2,530 scam addresses + GoldRush integration
|
||||
├── cross_chain_correlator.py # Multi-chain entity resolution
|
||||
├── wallet_clustering.py # 7-method clustering engine
|
||||
├── cluster_detection.py # Cluster detection orchestrator
|
||||
├── bubble_maps.py # RugMaps visualization engine
|
||||
├── token_discovery.py # New token scanning
|
||||
├── rag_service.py # RAG query service
|
||||
├── routers/
|
||||
│ ├── wallet_clustering_router.py # 14 endpoints
|
||||
│ ├── forensics_router.py # 12 endpoints
|
||||
│ ├── bubble_maps_router.py # 8 endpoints
|
||||
│ ├── cross_token_router.py # 8 endpoints
|
||||
│ ├── discovery_router.py # 8 endpoints
|
||||
│ └── ... (admin, chat, x402, etc.)
|
||||
├── data/
|
||||
│ ├── spam/scamsniffer_blacklist.json # 2,531 lines
|
||||
│ ├── wallet-labels/ # 100K+ Solana labels
|
||||
│ └── SOSANA-CRM-2024.json # Full CRM dump
|
||||
```
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 2.x | ✅ Active support |
|
||||
| 1.x | ❌ End of life |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
**DO NOT OPEN A PUBLIC ISSUE.** This is a commercial security product. Vulnerabilities in our code directly affect our customers' safety.
|
||||
|
||||
**Email:** security@rugmunch.io
|
||||
**PGP Key:** [Available on request]
|
||||
**Response time:** Within 24 hours
|
||||
**Disclosure:** Coordinated disclosure after fix deployment (max 90 days)
|
||||
|
||||
### What to include:
|
||||
- Type of vulnerability (RCE, auth bypass, data exposure, etc.)
|
||||
- Affected endpoint/component
|
||||
- Steps to reproduce
|
||||
- Proof of concept (if available)
|
||||
- Impact assessment
|
||||
|
||||
### What you'll receive:
|
||||
- Confirmation within 24 hours
|
||||
- Regular status updates
|
||||
- Credit in release notes (unless you request anonymity)
|
||||
- Bug bounty at our discretion (contact us for current program details)
|
||||
|
||||
## Security Best Practices for Contributors
|
||||
|
||||
1. **Never commit secrets** — API keys, tokens, passwords, private keys go in environment variables only
|
||||
2. **Use `.env` (gitignored)** for local development credentials
|
||||
3. **Sign your commits** with GPG (`git config commit.gpgsign true`)
|
||||
4. **Review your own diffs** before pushing — check for accidental credential exposure
|
||||
5. **Use branch protection** — all changes to main must go through PR review
|
||||
6. **Run `git-sync.py --dry-run`** before pushing to verify no secrets are staged
|
||||
|
||||
## Our Security Stack
|
||||
|
||||
- Pre-commit hooks scan every staged file for secrets
|
||||
- Pre-push hooks block force pushes and re-scan for secrets
|
||||
- GitHub Actions CI runs secret scanning on every PR
|
||||
- Dependabot monitors dependencies for known CVEs
|
||||
- Production secrets stored in GitHub Secrets vault + environment variables
|
||||
- Backend .env never committed (in .gitignore)
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
# RugMunch Intelligence — Security Stack Summary
|
||||
# Generated: 2026-05-08
|
||||
# WARNING: This file describes the security tooling deployed on this host.
|
||||
# Do NOT share externally — contains system architecture details.
|
||||
# Access: chmod 600, root-only.
|
||||
|
||||
══════════════════════════════════════════════════════════════════
|
||||
INSTALLED OPEN-SOURCE SECURITY TOOLS
|
||||
══════════════════════════════════════════════════════════════════
|
||||
|
||||
SAST (Static Analysis):
|
||||
bandit 1.9.4 Python security linter → pipx install bandit
|
||||
semgrep 1.162.0 Cross-language static analysis → pipx install semgrep
|
||||
|
||||
Secret Detection:
|
||||
gitleaks 8.25.1 Git + filesystem secret scanning → binary download
|
||||
|
||||
Dependency Scan:
|
||||
pip-audit 2.10.0 PyPI vulnerability audit → pipx install pip-audit
|
||||
|
||||
Container Scanning:
|
||||
trivy 0.70.0 Container + filesystem + secrets → binary download
|
||||
|
||||
IPS / WAF:
|
||||
crowdsec 1.7.7 Collaborative intrusion detection → apt install
|
||||
fail2ban active IP-based brute-force blocker → apt install
|
||||
|
||||
Pre-commit:
|
||||
pre-commit 4.6.0 Git hook automation → pipx install pre-commit
|
||||
|
||||
══════════════════════════════════════════════════════════════════
|
||||
NEW FILES CREATED
|
||||
══════════════════════════════════════════════════════════════════
|
||||
|
||||
/srv/rmi/backend/.bandit.yaml Bandit config (excludes B105/B311 false-posit, dirs)
|
||||
/srv/rmi/backend/.gitleaks.toml Gitleaks allowlist (public SOL addresses + static/)
|
||||
/srv/rmi/backend/.trivyignore Trivy ignore (investigation evidence files)
|
||||
/srv/rmi/backend/.pre-commit-config.yaml Pre-commit: bandit + gitleaks + isort + black + pip-audit
|
||||
/srv/rmi/backend/run-security.sh Full security suite runner
|
||||
/srv/rmi/backend/tmp/ fail2ban templates + GitHub Actions template
|
||||
|
||||
══════════════════════════════════════════════════════════════════
|
||||
CODE FIXES APPLIED
|
||||
══════════════════════════════════════════════════════════════════
|
||||
|
||||
DOCKERFILE:
|
||||
- FROM python:3.12-slim (was 3.11)
|
||||
- Added non-root `rmi` user + USER rmi
|
||||
- Upgraded known-vulnerable packages: jaraco.context + wheel
|
||||
|
||||
CODE (md5 → sha256 - CWE-327):
|
||||
app/fallback_engine.py:83 Cache key hash
|
||||
app/routers/news_feed.py:237 Article deduplication hash
|
||||
app/routers/rugmaps.py:462 Token cluster seed
|
||||
app/routers/social.py:435 Like hash
|
||||
app/rugmaps_analyzer.py:99 Analyzer seed
|
||||
|
||||
BUG FIXES:
|
||||
app/routers/daily_briefing.py:280 Fixed unterminated string literal syntax error
|
||||
|
||||
══════════════════════════════════════════════════════════════════
|
||||
SCAN RESULTS (latest run)
|
||||
══════════════════════════════════════════════════════════════════
|
||||
|
||||
Bandit: 0 HIGH, 42 MEDIUM (excludes B105 false-positives)
|
||||
Semgrep: 4 findings (2 INFO + 2 WARNING — all in x402-gateway/*.ts, not backend)
|
||||
pip-audit: 0 known dependency vulnerabilities
|
||||
Gitleaks: 0 leaks (after allowlist for public SOL addresses + dist/)
|
||||
Trivy fs: 0 HIGH/CRITICAL (after .trivyignore for investigation evidence)
|
||||
|
||||
══════════════════════════════════════════════════════════════════
|
||||
MANUAL DEPLOY STEPS
|
||||
══════════════════════════════════════════════════════════════════
|
||||
|
||||
Deploy fail2ban API abuse protection:
|
||||
sudo cp /srv/rmi/backend/tmp/rmi-api.conf /etc/fail2ban/filter.d/rmi-api.conf
|
||||
sudo cp /srv/rmi/backend/tmp/rmi-api-jail.conf /etc/fail2ban/jail.d/rmi-api.conf
|
||||
sudo systemctl restart fail2ban
|
||||
|
||||
Deploy GitHub Actions when repo hooks up:
|
||||
mkdir -p .github/workflows
|
||||
cp /srv/rmi/backend/tmp/github-workflow.yml .github/workflows/security.yml
|
||||
|
||||
══════════════════════════════════════════════════════════════════
|
||||
COMMAND REFERENCE
|
||||
══════════════════════════════════════════════════════════════════
|
||||
|
||||
Quick scan: cd /srv/rmi/backend && ./run-security.sh
|
||||
Full scan: cd /srv/rmi/backend && ./run-security.sh --full
|
||||
Bandit only: bandit -r app/ -c .bandit.yaml
|
||||
Semgrep only: semgrep --config=auto
|
||||
pip-audit: pip-audit -r requirements.txt
|
||||
Gitleaks: gitleaks detect --source . --no-git --config .gitleaks.toml
|
||||
Trivy fs: trivy fs --scanners vuln,secret,misconfig .
|
||||
Trivy image: trivy image --severity HIGH,CRITICAL rmi-backend:latest
|
||||
Pre-commit: pre-commit run --all-files
|
||||
CrowdSec stats: cscli metrics + cscli decisions list
|
||||
Fail2ban ban: sudo fail2ban-client status rmi-api
|
||||
|
|
@ -1,196 +0,0 @@
|
|||
# RMI Development Standards — AI-Forward + Web3 Best Practices
|
||||
|
||||
> **v3 (Jun 21 2026) — Crisis Ops Rebuild**: These standards reflect the
|
||||
> rebuilt architecture. The legacy 8,475-line `_legacy_main.py` is frozen;
|
||||
> do not edit. New code goes into `app/api/v1/` via the aggregator in
|
||||
> `app/api/v1/__init__.py`. See `docs/adr/0001-why-fastapi.md` and
|
||||
> `docs/adr/0003-strangler-fig-not-rewrite.md` for the architectural decisions.
|
||||
|
||||
## v3 Hard Rules
|
||||
|
||||
1. **No mass regex.** Each transformation explicit, one-line-per-replace.
|
||||
2. **New files only** — never modify the 14 frozen files without explicit un-freeze.
|
||||
3. **Strangler-fig migration**: legacy routes stay mounted in `main.py` until
|
||||
explicitly removed. New v1 routes mount at the same path — first match wins.
|
||||
4. **One-line main.py edits only.** Even when adding routes, prefer the
|
||||
`app/api/v1/__init__.py` aggregator + `v1_modules` list pattern.
|
||||
5. **No `_legacy_main` import.** The new main.py does not import legacy.
|
||||
6. **Real data flows only.** A v1 route that returns 404 / placeholder is not
|
||||
done. Acceptance = a real route serves real data end-to-end.
|
||||
7. **Prometheus metrics required.** Every new router emits `rmi_requests_total`
|
||||
counter (auto via CostTrackingMiddleware).
|
||||
8. **Telegram alerts via @rmialerts only.** No personal chat routing.
|
||||
See `app/api/v1/admin/alerts_webhook.py`.
|
||||
|
||||
## v3 Modern Builder Bar (14 points)
|
||||
|
||||
See `~/.hermes/skills/rmi/modern-builder-compliance/SKILL.md` for the full bar.
|
||||
Highlights:
|
||||
- Async/await for all I/O · Pydantic v2 everywhere · Structured logging
|
||||
- Type hints on all public functions · No bare except: · Frozen file respect
|
||||
- Real data acceptance test · Documented in DESIGN.md or relevant ADR
|
||||
- One concern per file · Tests before merge · No silent failures
|
||||
- Idempotent endpoints · Pagination on list endpoints · Rate-limited public routes
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ FIRST: Read /root/DEVELOPERS.md for canonical paths.
|
||||
|
||||
---
|
||||
|
||||
## BACKEND DEVELOPMENT
|
||||
|
||||
### Pre-commit checklist (run before every commit):
|
||||
```bash
|
||||
bash /root/backend/scripts/pre-commit.sh
|
||||
```
|
||||
Checks: Python syntax, hardcoded secrets, env var consistency, stale path references.
|
||||
|
||||
### Environment variables:
|
||||
```bash
|
||||
# Auto-generate from Hermes config:
|
||||
python3 /root/backend/generate_env.py --force
|
||||
# Then fill in missing values:
|
||||
nano /root/backend/.env
|
||||
```
|
||||
|
||||
### Live development (no rebuild needed):
|
||||
```bash
|
||||
# Volume mount means code changes are instant:
|
||||
docker restart rmi-backend
|
||||
# Verify:
|
||||
curl http://localhost:8000/health
|
||||
```
|
||||
|
||||
### Adding new env vars:
|
||||
1. Add to code: `os.getenv("MY_VAR")`
|
||||
2. Add to `/root/backend/.env.example` with comment
|
||||
3. Run `python3 /root/backend/generate_env.py --force`
|
||||
4. Add to `/srv/rugmuncher-backend/docker-compose.yml` if container needs it
|
||||
|
||||
---
|
||||
|
||||
## AI AGENT DEVELOPMENT WORKFLOW
|
||||
|
||||
This system is designed for AI-assisted development. Here's the stack:
|
||||
|
||||
```
|
||||
hermes-agent (CLI)
|
||||
│
|
||||
├── Terminal tool → docker exec, git, curl, python
|
||||
├── Web tool → API testing, research
|
||||
├── File tool → Edit /root/backend/ directly
|
||||
├── Delegate → Spawn sub-agents for parallel work
|
||||
└── Cron jobs → Automated tasks
|
||||
```
|
||||
|
||||
### How Hermes develops the backend:
|
||||
1. **Discover**: Reads AGENTS.md in /root/backend/
|
||||
2. **Edit**: Patches files directly (volume mount = live)
|
||||
3. **Test**: `curl localhost:8000/health` after changes
|
||||
4. **Rebuild**: `docker compose build && docker compose up -d`
|
||||
5. **Verify**: Checks logs, API responses
|
||||
|
||||
### n8n workflow development:
|
||||
- UI: http://localhost:5678 (admin / RugMuncher2024)
|
||||
- Direct DB: `sqlite3 /root/n8n-data/database.sqlite`
|
||||
- Import: Copy workflow JSONs into `/root/n8n-workflows/`
|
||||
- Test: Check execution history in UI
|
||||
|
||||
### Orchestrator swarm:
|
||||
- API: http://localhost:8081
|
||||
- Health: `curl http://localhost:8081/health`
|
||||
- Bots: `curl http://localhost:8081/orchestrator/bots`
|
||||
- Create task: `POST /orchestrator/task`
|
||||
|
||||
---
|
||||
|
||||
## WEB3 SECURITY BEST PRACTICES
|
||||
|
||||
### Secrets management:
|
||||
- **NO hardcoded secrets** in any `.py` file
|
||||
- All secrets in `/root/.secrets/` or `/root/.hermes/.env`
|
||||
- App passwords preferred over account passwords
|
||||
- Rotate API keys quarterly
|
||||
|
||||
### Key scanning:
|
||||
```bash
|
||||
# Run before any commit:
|
||||
grep -rn '0x[0-9a-fA-F]\{64\}\|sk-[a-zA-Z0-9]\{20,\}' /root/backend/app/ --include='*.py'
|
||||
```
|
||||
|
||||
### RPC security:
|
||||
- Use dedicated RPC URLs, never public endpoints in production
|
||||
- Rate limit all on-chain queries
|
||||
- Cache blockchain data aggressively (Redis)
|
||||
|
||||
---
|
||||
|
||||
## CODE QUALITY
|
||||
|
||||
### Python:
|
||||
- Type hints on all public functions
|
||||
- Docstrings for modules and classes
|
||||
- Async/await for all I/O operations
|
||||
- Use Pydantic for data models
|
||||
|
||||
### TypeScript (Frontend):
|
||||
- Components in `/srv/rugmuncher-backend/rmi-frontend/src/components/`
|
||||
- Services in `/srv/rugmuncher-backend/rmi-frontend/src/services/`
|
||||
- Types shared via `/srv/rugmuncher-backend/rmi-frontend/src/types.ts`
|
||||
|
||||
---
|
||||
|
||||
## MONITORING
|
||||
|
||||
### Health checks:
|
||||
```bash
|
||||
# All services:
|
||||
curl http://localhost:8000/health # Backend
|
||||
curl http://localhost:8081/health # Orchestrator
|
||||
curl http://localhost:5678/healthz # n8n
|
||||
curl http://localhost:9001/api/health # Listmonk
|
||||
```
|
||||
|
||||
### Logs:
|
||||
```bash
|
||||
docker logs rmi-backend --tail 50
|
||||
docker logs rmi-n8n --tail 50
|
||||
journalctl -u hermes -n 50
|
||||
```
|
||||
|
||||
### Cron jobs:
|
||||
```bash
|
||||
# List all:
|
||||
cronjob action='list'
|
||||
# Check status of specific job:
|
||||
cronjob action='list' # look for last_status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DEPLOYMENT
|
||||
|
||||
### Full stack restart:
|
||||
```bash
|
||||
cd /srv/rugmuncher-backend
|
||||
docker compose down
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Rebuild with cache clear:
|
||||
```bash
|
||||
docker compose build --no-cache backend worker orchestrator
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Rollback (if something breaks):
|
||||
```bash
|
||||
# Restore backup:
|
||||
cp /root/backups/n8n/$(date +%Y-%m)/database.sqlite /root/n8n-data/
|
||||
docker restart rmi-n8n
|
||||
|
||||
# Rebuild from known-good commit:
|
||||
cd /root/backend && git checkout <commit-hash>
|
||||
docker restart rmi-backend
|
||||
```
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
# RMI Supabase Integration — AI-First, Web3-Forward
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ RMI Backend (FastAPI) │
|
||||
│ │
|
||||
│ supabase_router.py supabase_oauth_router.py │
|
||||
│ supabase_auth_router.py supabase_service.py │
|
||||
│ supabase_rag.py db_client.py │
|
||||
└──────────────┬──────────────────────────────────────────┘
|
||||
│ httpx + service_role key
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Supabase │
|
||||
│ │
|
||||
│ ┌───────────┐ ┌───────────┐ ┌───────────────────┐ │
|
||||
│ │ Auth │ │ Postgres │ │ Row Level │ │
|
||||
│ │ (JWT+OAuth)│ │ (Database)│ │ Security (RLS) │ │
|
||||
│ └───────────┘ └───────────┘ └───────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────┐ ┌───────────┐ ┌───────────────────┐ │
|
||||
│ │ Storage │ │ Edge │ │ Real-time │ │
|
||||
│ │ (Files) │ │ Functions │ │ Subscriptions │ │
|
||||
│ └───────────┘ └───────────┘ └───────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Key Integration Points
|
||||
|
||||
### 1. Authentication (Web3 + Traditional)
|
||||
- **JWT auth** via `supabase-auth` skill + `auth.py`
|
||||
- **OAuth providers**: GitHub, Google (configured in `supabase_oauth_router.py`)
|
||||
- **Wallet auth**: EVM + Solana wallet connection (non-custodial)
|
||||
- **x402 trial tracking**: Device fingerprint + wallet-based quotas
|
||||
|
||||
### 2. Database (Postgres via Supabase)
|
||||
- **User profiles**: `users` table with premium tiers, notification prefs
|
||||
- **Intelligence data**: whale_movements, market_trending_tokens, scam_alerts
|
||||
- **Content**: content posts, comments, upvotes, gamification events
|
||||
- **x402 payments**: transaction logs, tool usage, trial tracking
|
||||
- **Retention**: 90-day auto-cleanup for non-security data
|
||||
|
||||
### 3. RAG / Vector Store
|
||||
- Redis-based vector store for crypto intelligence (`rag_service.py`)
|
||||
- Lightweight SQLite+TF-IDF fallback (`rag_lightweight.py`)
|
||||
- Collections: wallet_profiles, token_analysis, scam_patterns, forensic_reports, market_intel
|
||||
- n8n workflow ingests news articles into RAG
|
||||
|
||||
### 4. Env Vars Required
|
||||
```
|
||||
SUPABASE_URL=https://<project>.supabase.co
|
||||
SUPABASE_ANON_KEY=eyJh...
|
||||
SUPABASE_SERVICE_KEY=eyJh...
|
||||
SUPABASE_JWT_SECRET=...
|
||||
```
|
||||
|
||||
## MCP (Model Context Protocol) Integration
|
||||
|
||||
- **`/api/v1/x402/tools-catalog`** — Full MCP catalog of 51 (44 MCP + 7 bundles) tools
|
||||
- **`app/mcp/x402_mcp_server.py`** — MCP server implementation
|
||||
- **`app/mcp_router.py`** — Routes MCP tool calls to backend functions
|
||||
- **GitHub repo**: `Rug-Munch-Media-LLC/rug-munch-intelligence-mcp` (public)
|
||||
|
||||
## x402 Payment Protocol
|
||||
|
||||
- **`/.well-known/x402`** — Protocol discovery document
|
||||
- **7 chains**: Solana (Facilitator), Base (Facilitator), ETH/BSC/ARB/OPT/POL (Self-verify)
|
||||
- **Trial**: 1 free call (no wallet), 3 free calls (with wallet)
|
||||
- **Payment**: USDC micropayments via HTTP 402
|
||||
- **Repos**: `x402-gateway-solana`, `x402-gateway-base`, `x402-twitter-view`
|
||||
|
||||
## AI-Forward Architecture
|
||||
|
||||
```
|
||||
User Request → Backend API → Orchestrator (9 agents)
|
||||
│ │
|
||||
├── Supabase ├── Wallet clustering
|
||||
├── Redis RAG ├── Scam detection
|
||||
├── News Agg ├── Threat intel
|
||||
└── x402 Gate └── Cross-chain analysis
|
||||
```
|
||||
|
||||
## Web3 Best Practices Applied
|
||||
- **Non-custodial**: No private keys stored server-side
|
||||
- **RLS**: Row Level Security on all Supabase tables
|
||||
- **Device fingerprinting**: Anti-abuse for trial system
|
||||
- **On-chain verification**: Self-verify mode for 5 chains
|
||||
- **Facilitator mode**: Cloudflare Workers for Base/Solana
|
||||
|
|
@ -1,233 +0,0 @@
|
|||
# x402 Protocol — Complete System Architecture
|
||||
## MUST READ for all future RMI developers
|
||||
### Auto-audited: May 23, 2026 — 59 tools, 7 chains, all endpoints verified
|
||||
|
||||
---
|
||||
|
||||
## SYSTEM OVERVIEW
|
||||
|
||||
```
|
||||
INTERNET
|
||||
│
|
||||
▼
|
||||
Cloudflare Tunnel (rmi-cloudflare)
|
||||
┌─────────────────────────────┐
|
||||
│ rugmunch.io │
|
||||
│ mcp.rugmunch.io │
|
||||
│ n8n.rugmunch.io │
|
||||
└─────────────┬───────────────┘
|
||||
│
|
||||
┌─────────────▼───────────────┐
|
||||
│ nginx (:80, :443) │
|
||||
│ Routes: │
|
||||
│ /api/* → :8000 │
|
||||
│ /.well-known/* → :8000 │
|
||||
│ /mcp/* → :8000 │
|
||||
│ /health → :8000 │
|
||||
│ / → static │
|
||||
└─────────────┬───────────────┘
|
||||
│
|
||||
┌───────────────────┼───────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────┐ ┌──────────┐ ┌──────────┐
|
||||
│ Backend │ │ Orchestrator│ │ n8n │
|
||||
│ :8000 │ │ :8081 │ │ :5678 │
|
||||
│ 59 tools│ │ 9 agents │ │ 2 flows │
|
||||
└────┬────┘ └──────────┘ └──────────┘
|
||||
│
|
||||
┌────┼────────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌──────┐ ┌──────┐ ┌──────────┐
|
||||
│Redis │ │Supabase│ │ Langfuse │
|
||||
│:6379 │ │ (API) │ │ :3100 │
|
||||
└──────┘ └──────┘ └──────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## FILE MAP — Every x402 file and what it does
|
||||
|
||||
### Core Backend (Python/FastAPI)
|
||||
|
||||
| File | Lines | Purpose |
|
||||
|------|-------|---------|
|
||||
| `app/routers/x402_enforcement.py` | 1290 | **Payment gatekeeper** — intercepts all `/api/v1/x402-tools/*`, verifies x402 payment headers, enforces trials, builds 402 Payment Required responses. 7-chain support. |
|
||||
| `app/routers/x402_tools.py` | 3581 | **Tool handlers** — 48 route implementations. Each `@router.post("/audit")` is a tool. Also serves AI framework adapters (OpenAI, Anthropic, Gemini, LangChain formats). |
|
||||
| `app/routers/x402_catalog.py` | 255 | **Auto-discovery** — parses gateway index.ts files + scans route decorators. Builds unified catalog. No hardcoded tool lists. |
|
||||
| `app/routers/x402_forensic_tools.py` | 237 | **Forensic bundles** — 3 premium tools: forensic_valuation, osint_identity_hunt, investigation_report |
|
||||
| `app/routers/x402_dashboard.py` | 493 | **Analytics** — usage tracking, revenue per tool, top users, trial exhaustion stats |
|
||||
| `app/routers/x402_middleware.py` | 685 | **Anti-abuse** — device fingerprinting, trial tracking per device/wallet, rate limiting |
|
||||
| `app/mcp/x402_mcp_server.py` | 682 | **MCP protocol server** — translates x402 tools into MCP format for Claude/Cursor/Windsurf |
|
||||
|
||||
### Cloudflare Workers (TypeScript)
|
||||
|
||||
| File | Lines | Purpose |
|
||||
|------|-------|---------|
|
||||
| `x402-gateway/base/index.ts` | 2650 | **Base + EVM gateway** — Payment verification via PayAI facilitator. 44 tool definitions. Routes to backend. |
|
||||
| `x402-gateway/solana/index.ts` | 2650 | **Solana gateway** — Payment verification via PayAI facilitator. 35 tool definitions. Routes to backend. |
|
||||
| `x402-twitter-view/src/index.ts` | ~200 | **Twitter data worker** — profiles, timelines, search. Self-healing with failover. |
|
||||
|
||||
### GitHub Repos (public)
|
||||
|
||||
| Repo | Purpose |
|
||||
|------|---------|
|
||||
| `rug-munch-intelligence-mcp` | Public pip package. Thin MCP wrapper around x402 API. |
|
||||
| `x402-gateway-solana` | Solana gateway source — deploys to Cloudflare Workers |
|
||||
| `x402-gateway-base` | Base + EVM gateway source — deploys to Cloudflare Workers |
|
||||
| `x402-twitter-view` | Twitter data worker source |
|
||||
|
||||
---
|
||||
|
||||
## PAYMENT FLOW — Step by step
|
||||
|
||||
```
|
||||
1. User/bot calls POST /api/v1/x402-tools/{tool}
|
||||
│
|
||||
2. x402_enforcement middleware intercepts
|
||||
├── Check: Has user paid? (x-pay header with tx hash)
|
||||
├── Check: Is trial available? (device fingerprint + wallet)
|
||||
├── If unpaid AND no trials → build 402 Payment Required
|
||||
│ └── Returns: payment addresses per chain, amounts, timeout
|
||||
│
|
||||
3. If paid or trial available → forward to tool handler
|
||||
│
|
||||
4. Tool handler (x402_tools.py) executes
|
||||
├── Call backend connectors (Helius, Etherscan, DeFiLlama, etc.)
|
||||
├── Aggregate multi-source data
|
||||
└── Return JSON response
|
||||
│
|
||||
5. Payment verification (if paid):
|
||||
├── Base/Solana → PayAI facilitator verifies USDC transfer
|
||||
└── ETH/BSC/ARB/OPT/POL → Self-verify via Etherscan on-chain check
|
||||
```
|
||||
|
||||
### Payment Addresses
|
||||
- **All EVM chains**: `0x1E3AC01d0fdb976179790BDD02823196A92705C9`
|
||||
- **Solana**: `Gix4P9AmwcZRGzr2hCEME5m2QAvY86dBfm8c7e7MpFzv`
|
||||
- **Token**: USDC on all chains
|
||||
- **Amounts**: $0.01 - $0.50 per tool (defined in gateway index.ts)
|
||||
|
||||
---
|
||||
|
||||
## TOOL DISCOVERY — How the catalog works
|
||||
|
||||
```
|
||||
MCP Catalog (/api/v1/x402/tools-catalog)
|
||||
│
|
||||
├── Step 1: parse_gateway_tools()
|
||||
│ └── Reads x402-gateway/{base,solana}/index.ts
|
||||
│ └── Regex extracts each tool from RMI_TOOLS object
|
||||
│ └── Finds: name, description, price, category, trialFree, method
|
||||
│ └── Result: 44 tools (22 unique to base, 0 unique to solana)
|
||||
│
|
||||
├── Step 2: discover_route_tools()
|
||||
│ └── Scans x402_tools.py + x402_forensic_tools.py
|
||||
│ └── Finds @router.get/post decorators
|
||||
│ └── Extracts docstrings as descriptions
|
||||
│ └── Result: 5 unique tools not in gateways
|
||||
│
|
||||
└── Step 3: Merge + Deduplicate
|
||||
└── Same tool ID = merge chains
|
||||
└── Result: 59 total tools
|
||||
```
|
||||
|
||||
### Output formats
|
||||
| Format | Endpoint | For |
|
||||
|--------|----------|-----|
|
||||
| Full catalog | `/api/v1/x402/tools-catalog` | Humans, dashboards |
|
||||
| x402 protocol | `/.well-known/x402` | AI agents, protocol discovery |
|
||||
| OpenAI | `/api/v1/x402-tools/openai-tools` | ChatGPT, OpenAI-compatible |
|
||||
| Anthropic | `/api/v1/x402-tools/anthropic-tools` | Claude, Cursor |
|
||||
| Gemini | `/api/v1/x402-tools/gemini-tools` | Google Gemini |
|
||||
| LangChain | `/api/v1/x402-tools/langchain-tools` | LangChain agents |
|
||||
|
||||
---
|
||||
|
||||
## PRICING & TRIALS
|
||||
|
||||
| Tier | Calls | Requirement |
|
||||
|------|-------|-------------|
|
||||
| Anonymous | 1 free per tool | Device fingerprint |
|
||||
| Wallet connected | 3 free per tool | MetaMask/Phantom |
|
||||
| Paid | Unlimited | USDC payment per call |
|
||||
|
||||
**Refund**: Full refund if tool returns no data. POST `/api/v1/x402/refund` with tx hash within 48h.
|
||||
|
||||
**Anti-abuse**: Device fingerprinting survives VPN/incognito. Identity hierarchy: wallet > device_id > turnstile > fingerprint.
|
||||
|
||||
---
|
||||
|
||||
## CHAIN SUPPORT MATRIX
|
||||
|
||||
| Chain | Network ID | USDC Address | Verification |
|
||||
|-------|-----------|-------------|--------------|
|
||||
| Base | eip155:8453 | 0x833589...a02913 | PayAI facilitator |
|
||||
| Solana | solana:5eykt4... | EPjFWdd5...TDt1v | PayAI facilitator |
|
||||
| Ethereum | eip155:1 | 0xA0b869...eB48 | Self-verify |
|
||||
| BSC | eip155:56 | 0x8AC76a...d580d | Self-verify |
|
||||
| Arbitrum | eip155:42161 | 0xaf88d0...5831 | Self-verify |
|
||||
| Optimism | eip155:10 | 0x0b2C63...Ff85 | Self-verify |
|
||||
| Polygon | eip155:137 | 0x3c499c...3359 | Self-verify |
|
||||
|
||||
---
|
||||
|
||||
## CONNECTOR APIS — What data we have
|
||||
|
||||
| Connector | API Key | Status | Used By |
|
||||
|-----------|---------|--------|---------|
|
||||
| Helius | ✅ Working | Solana RPC, webhooks, transactions | wallet, cluster, whale, forensics |
|
||||
| Etherscan | ✅ Working | Contract source, ABI, TX history | contract_inspect, tx_decoder |
|
||||
| DeFiLlama | 🆓 Free | TVL, protocols, yields | protocol_research, yield_scanner |
|
||||
| Birdeye | ✅ Working | Trending, token data | trending_tokens |
|
||||
| CoinGecko | ✅ Working | Prices, categories, trending | market_price, market_sectors |
|
||||
| DexScreener | 🆓 Free | Pairs, liquidity, volume | dex_activity, market_price |
|
||||
| Moralis | ❌ Key invalid | Multi-chain wallet/token data | NOT USED |
|
||||
| GMGN | ❌ Access denied | KOL tracking, trending | NOT USED |
|
||||
| Arkham | ⚠️ Untested | Entity labeling | NOT USED |
|
||||
| Nansen | ⚠️ Untested | Smart money, token god mode | NOT USED |
|
||||
| Dune | ⚠️ Untested | Custom queries | NOT USED |
|
||||
|
||||
---
|
||||
|
||||
## TESTING
|
||||
|
||||
```bash
|
||||
# Test all 59 tools (expect 403 = x402 enforcement working):
|
||||
bash /root/backend/scripts/test_all_tools.sh
|
||||
|
||||
# Status dashboard:
|
||||
python3 /root/scripts/rmi-status
|
||||
|
||||
# Pre-commit check:
|
||||
bash /root/backend/scripts/pre-commit.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## COMMON ISSUES & FIXES
|
||||
|
||||
| Issue | Symptom | Fix |
|
||||
|-------|---------|-----|
|
||||
| Gateway files missing | Catalog shows 0 tools | Clone gateways to `/root/backend/x402-gateway/` |
|
||||
| Tool returns 403 | x402 enforcement active | Expected — tools require payment or trial |
|
||||
| Catalog stale after adding tools | Old count | Restart backend: `docker restart rmi-backend` |
|
||||
| Payment verification fails | 402 responses | Check USDC addresses, network config |
|
||||
| Self-signed cert on external | curl fails without -k | Cloudflare provides edge cert — use -k or browser |
|
||||
|
||||
---
|
||||
|
||||
## WHEN ADDING NEW TOOLS
|
||||
|
||||
1. Add definition to `x402-gateway/base/index.ts` (and solana if applicable)
|
||||
2. Add route handler to `x402_tools.py`:
|
||||
```python
|
||||
@router.post("/my_new_tool")
|
||||
async def my_new_tool(req: SomeRequest):
|
||||
"""Description of what this tool does."""
|
||||
# Implementation using existing connectors
|
||||
```
|
||||
3. Restart backend: `docker restart rmi-backend`
|
||||
4. Verify: `curl http://localhost:8000/api/v1/x402-tools/my_new_tool`
|
||||
5. Check catalog auto-updated: `curl http://localhost:8000/api/v1/x402/tools-catalog`
|
||||
|
|
@ -1,219 +0,0 @@
|
|||
# CryptoRugMunch X (Twitter) Complete Audit & Strategy
|
||||
## Generated: June 2, 2026
|
||||
|
||||
---
|
||||
|
||||
## ACCOUNT SNAPSHOT
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Handle | @CryptoRugMunch |
|
||||
| Display Name | Crypto Rug Muncher ✓ (verified) |
|
||||
| Joined | March 23, 2024 |
|
||||
| Followers | 66,699 |
|
||||
| Following | 505 |
|
||||
| Total Posts | ~14,395 |
|
||||
| Bio | "Rug Munch Intelligence: Terminal for dev tracking, KOL rep cards, & deep token analysis" |
|
||||
| Website | t.me/cryptorugmuncher |
|
||||
|
||||
---
|
||||
|
||||
## COMPLETE TWEKE INVENTORY (Discovered)
|
||||
|
||||
### TIER 1: High-Performing Investigative Threads (50-158 likes)
|
||||
|
||||
| Date | ID | Topic | Est. Likes |
|
||||
|------|----|-------|-----------|
|
||||
| 2026-01-13 | 2011121865268273169 | 🚨 RUG PULL WARNING: $USOR bundled scam | 158 |
|
||||
| 2026-03-13 | 2032249064440431012 | DavinciJeremie expose — "turned followers into exit liquidity" | 50 |
|
||||
| 2026-02-23 | 2025778728123666684 | The Paid Shill Pipeline: How KOLs Get Rich (Pump.fun lawsuit) | 41 |
|
||||
| 2026-01-22 | 2014168260812685576 | More bundled garbage: $USR playing off $USOR success | ~30 |
|
||||
| 2025-03-15 | 1900961816672694749 | WallStreetBets + Hayden Davis scam connection | 74 |
|
||||
| 2025-02-14 | 1890538027115503700 | $LIBRA deployer multiple rug pulls (GMGN data) | ~60 |
|
||||
| 2025-02-04 | 1886805232878858528 | Finixio/Clickout Media presale scams list Feb 2025 | ~35 |
|
||||
| 2024-12-24 | 1871615662814056757 | Results of typical Finixio/Clickout Media presale scam | 35 |
|
||||
| 2024-12-22 | 1870863989946609894 | Removed $ALICE post after feedback, community accountability | ~20 |
|
||||
|
||||
### TIER 2: Product/Brand Announcements (10-31 likes)
|
||||
|
||||
| Date | ID | Topic |
|
||||
|------|----|-------|
|
||||
| 2026-02-15 | 2023164661852418141 | Chrome/Firefox web extensions launching this week |
|
||||
| 2026-02-18 | 2024167740219462025 | Coinbase AgentKit plugin for Rug Intel risk checks (x402) |
|
||||
| 2025-09-26 | 1971604084756218356 | $CRM token supply, distribution, and controls for CoinGecko |
|
||||
| 2026-01-29 | 2016675199387914592 | Bringing on additional developer, reworking website |
|
||||
| 2026-03-01 | 2038909240178536655 | V2 $CRM token relaunch plan (3-step: forensics, product, then token) |
|
||||
| 2026-02-08 | 2020370336940806508 | $BEAM shilled by serial scammer warning |
|
||||
| 2025-04-05 | 1908461438894821690 | Wallet warning — remove immediately |
|
||||
| 2025-02-14 | 1890529174802096440 | $LIBRA / JMilei — 3 wallets control 80% of supply |
|
||||
|
||||
### TIER 3: Scam Warnings & Call-Outs (19-35 likes)
|
||||
|
||||
| Date | ID | Topic |
|
||||
|------|----|-------|
|
||||
| 2024-06-12 | 1800953406137209006 | $DOGEVERSE scam — reports of staking theft |
|
||||
| 2024-06-16 | 1802326083338945012 | Presale scam analysis: countdown clock, promotional tactics |
|
||||
| 2024-06-17 | 1802697920606552549 | YouTube shill promotion, undoxxed team |
|
||||
| 2024-06-18 | 1803060093740617960 | Paid advertorials in major crypto news outlets |
|
||||
| 2024-06-19 | 1803439240203710756 | Same team behind $DOGEVERSE, $SMOG, $SLOTH, $SEALANA |
|
||||
| 2024-06-25 | 1805662250101076378 | $SEAL team = $SLOTH + $DOGEVERSE + $DOGE20 + $SMOG |
|
||||
| 2024-06-28 | 1806730519129805187 | $TIME presale scam — founder Erdem Nazli promoting to 167k |
|
||||
| 2024-07-18 | 1813973110216925618 | Another presale scam format |
|
||||
| 2024-09-03 | 1830974957700153375 | Cabal's 2024 End-of-Year Party, $NEIRO |
|
||||
| 2024-11-08 | 1854947038968045621 | BlockDAG — inflated numbers, likely to become biggest presale scam |
|
||||
| 2024-12-12 | 1867248464414867620 | Pepe Unchained $PEPU at $500M mcap — skeptics proven right? |
|
||||
| 2024-12-18 | 1869175591804846368 | Clickout Media / Finixio — can't stop scamming |
|
||||
| 2024-12-18 | 1869391339923570852 | Related Clickout expose |
|
||||
| 2025-01-16 | 1811452834162110886 | Scam analysis continuation |
|
||||
| 2025-11-01 | 1984657092507005033 | D Poppin collaboration/profile |
|
||||
|
||||
### TIER 4: Community / Personal
|
||||
|
||||
| Date | ID | Topic |
|
||||
|------|----|-------|
|
||||
| 2024-06-19 | 1803520713695105516 | OnlyFans behind-the-scenes access announcement |
|
||||
| 2024-06-18 | 1803119961226756139 | "Who has been here?" — engagement post (2337 views) |
|
||||
| 2026-03 | 2042987696889696307 | "I hear all of you... the silence has been frustrating... I had to learn to code and build" |
|
||||
| 2025-02-04 | 1886835371331174834 | Top "traders" of bundled $ALPHA |
|
||||
| 2026-01-16 | 2011984602559365328 | "Verify everything before you buy" |
|
||||
|
||||
### PRODUCT & TECH MILESTONES
|
||||
|
||||
| Date | ID | Topic |
|
||||
|------|----|-------|
|
||||
| 2026-02-15 | 2023164661852418141 | Web extensions (Chrome + Firefox) launching |
|
||||
| 2026-02-18 | 2024167740219462025 | Coinbase AgentKit plugin with x402 risk checks |
|
||||
| 2025-09-26 | 1971604084756218356 | $CRM token supply/distribution documentation |
|
||||
| 2026-03-01 | 2038909240178536655 | V2 $CRM relaunch (3-step: forensics public → product live → token relaunch) |
|
||||
| ~2025 | GitHub | Rug Munch MCP server (19 tools for crypto risk intelligence) |
|
||||
| ~2025 | HuggingFace | x402-gateway-solana (Solana payment gateway) |
|
||||
| ~2025 | Phantom | Listed on Phantom App Store |
|
||||
| ~2025 | RNWY / Smithery | MCP Server directory listing |
|
||||
| ~2025 | DexScreener | KOL scanner integration |
|
||||
|
||||
---
|
||||
|
||||
## CONTENT ANALYSIS BY CATEGORY
|
||||
|
||||
### Category Breakdown (% of discovered tweets)
|
||||
|
||||
| Category | % | Avg Likes | Quality |
|
||||
|----------|---|-----------|---------|
|
||||
| 🔍 Scam/Investigation Expose | 40% | 50-158 | HIGH — core value prop |
|
||||
| 🚨 Rug Pull Warnings | 25% | 20-40 | MEDIUM — high volume, lower per-tweet impact |
|
||||
| 🛠️ Product Announcements | 10% | 10-31 | LOW — poor product-to-engagement conversion |
|
||||
| 🗣️ Community/Personal | 10% | 5-15 | LOW — but necessary for trust |
|
||||
| 🔁 Thread Continuations | 15% | 5-20 | MEDIUM — follow-through is good |
|
||||
|
||||
### STRENGTHS
|
||||
|
||||
1. **Deep investigative work** — the KOL expose thread (41 likes), $LIBRA research, Finixio/Clickout series are genuinely valuable
|
||||
2. **Consistent anti-scam voice** — never wavering from the core mission
|
||||
3. **Real on-chain evidence** — citing GMGN, wallet data, transaction analysis
|
||||
4. **Thread discipline** — most investigations are properly threaded with evidence
|
||||
5. **Brand recognition** — 66K followers in crypto security niche is solid
|
||||
|
||||
### WEAKNESSES (Critical)
|
||||
|
||||
1. **POSTING FREQUENCY IS ERRATIC** — massive gaps (weeks/months of silence), then bursts. The "I hear you all" tweet from March 2026 acknowledges this directly.
|
||||
|
||||
2. **PRODUCT ANNOUNCEMENTS HAVE ZERO HYPE STRATEGY** — Chrome/Firefox extension launch got 31 likes. AgentKit got buried. These should be 500+ likes announcements. The gap between product capability and audience awareness is enormous.
|
||||
|
||||
3. **NO VISUAL BRANDING** — no consistent color scheme, no branded graphics, no template for warnings vs. investigations vs. announcements. Every top crypto security account uses branded templates.
|
||||
|
||||
4. **NO ENGAGEMENT FUNNEL** — 66K followers but average engagement is 20-80 likes. That's a 0.03-0.12% engagement rate. Crypto Twitter avg for this size is 0.5-2%. Something is deeply wrong.
|
||||
|
||||
5. **INCONSISTENT THREAD LENGTH** — some bangers are 1-tweet wonders, others are 15-part threads. No standard format.
|
||||
|
||||
6. **NO RECURRING CONTENT SERIES** — no "Scam of the Week", no daily digest, no regular format that builds habit.
|
||||
|
||||
7. **ONLYFANS STUNT** — the June 2024 OnlyFans post was engagement bait that confused the serious security brand. Never again.
|
||||
|
||||
8. **$CRM TOKEN MISHANDLING** — the token launch, then silence, then "V2 relaunch" 6 months later creates massive trust erosion. The 3-step plan is good but should have been communicated DURING the gap, not after.
|
||||
|
||||
9. **NO COLLABORATION STRATEGY** — zero threads tagging or quoting other security researchers (ZachXBT, Coffeezilla, etc.). Self-contained bubble.
|
||||
|
||||
10. **THREADBOLDS/FORMAT INCONSISTENCY** — mix of 🚨 emojis and plain text, no visual hierarchy standard.
|
||||
|
||||
---
|
||||
|
||||
## COMPETITIVE ANALYSIS vs TOP CRYPTO SECURITY ACCOUNTS
|
||||
|
||||
| Account | Followers | Avg Likes | Engagement Rate | Content Type |
|
||||
|---------|-----------|-----------|----------------|-------------|
|
||||
| @zabxXBT (ZachXBT) | 650K | 2,000-10,000 | 1.5-3% | Investigative threads |
|
||||
| @Coffeezilla | 1.2M | 5,000-50,000 | 0.8-4% | Video + thread exposes |
|
||||
| @lookonchain | 450K | 500-5,000 | 0.5-1.5% | On-chain data threads |
|
||||
| @CryptoRugMunch | 66.7K | 20-158 | 0.03-0.24% | Scam warnings + investigations |
|
||||
| @ape_scanner | 15K | 50-200 | 0.5-1.3% | Token security alerts |
|
||||
|
||||
**KEY INSIGHT**: RMI's engagement rate is 5-10x BELOW comparable accounts. The content quality is there but the distribution and format strategy is fundamentally broken.
|
||||
|
||||
---
|
||||
|
||||
## ACTIONABLE IMPROVEMENTS
|
||||
|
||||
### 1. POSTING CADENCE (Critical)
|
||||
- **Minimum 2 posts/day**: 1 morning alert, 1 evening analysis
|
||||
- **1 major thread/week**: Deep investigation (Tuesday 2pm ET)
|
||||
- **Daily scam digest**: Top 3-5 scams to avoid that day (morning, 8am ET)
|
||||
- **Fill the silence gaps**: If building, post "building in public" updates weekly
|
||||
|
||||
### 2. VISUAL BRANDING STACK
|
||||
- Create 3 branded templates:
|
||||
- 🚨 RUG ALERT (red/black, high urgency)
|
||||
- 🔍 INVESTIGATION (blue/white, analytical)
|
||||
- 🛡️ PRODUCT NEWS (green/dark, positive)
|
||||
- Use consistent header bars with RMI logo
|
||||
- All threads start with a branded image/graphic
|
||||
|
||||
### 3. ENGAGEMENT FUNNEL
|
||||
- End every thread with a CTA: "Scan any token free at cryptorugmunch.com"
|
||||
- Quote-tweet other researchers (ZachXBT, Lookonchain) with added context
|
||||
- Reply to every major scam news within 60 minutes
|
||||
- Use polls 1x/week for engagement bait ("How many of you lost money to [scam type]?")
|
||||
|
||||
### 4. RECURRING SERIES (Builds Habit)
|
||||
- **"Scam School" weekly thread**: Educational deep-dive into one scam technique
|
||||
- **"Monday Munchies"**: Top 5 projects to avoid this week
|
||||
- **"Whale Watch Wednesday"**: Following smart money / whale wallet movements
|
||||
- **"Verification Friday"**: Legit projects that passed RMI's full scan
|
||||
- **Monthly State of Scams**: Comprehensive monthly report (great for bookmarks)
|
||||
|
||||
### 5. PRODUCT LAUNCH PLAYBOOK
|
||||
- **7-day teaser campaign** before any launch
|
||||
- **Launch day**: Thread storm (5+ tweets), video walkthrough, CTA
|
||||
- **48-hour follow-up**: Share user results/stats
|
||||
- **Week after**: "What we built vs. what you asked for" thread
|
||||
|
||||
### 6. CROSS-PLATFORM AMPLIFICATION
|
||||
- Mirror every thread to Telegram channel (existing)
|
||||
- Create YouTube Shorts from top threads (60-sec summaries)
|
||||
- Reddit posts in r/CryptoCurrency for major investigations
|
||||
- Cross-post to Mirror/Medium for long-form
|
||||
|
||||
### 7. HASHTAG STRATEGY
|
||||
- Primary: #RugMunch #RugAlert #CryptoSecurity
|
||||
- Secondary: #ScamAlert #DeFiSafety #OnChain
|
||||
- Campaign: #ScanFirst (our equivalent of #DYOR but specific to RMI)
|
||||
|
||||
### 8. COMMUNITY BUILDING
|
||||
- Weekly AMAs on X Spaces
|
||||
- Create a "RMI Verified" badge for projects that pass full scan
|
||||
- Community reports: let users submit scams, credit them in posts
|
||||
- Reward top community members with premium access
|
||||
|
||||
---
|
||||
|
||||
## ENGAGEMENT TARGETS (30/60/90 Day)
|
||||
|
||||
| Metric | Current | 30 Day | 60 Day | 90 Day |
|
||||
|--------|---------|--------|--------|--------|
|
||||
| Posts/week | ~2-3 | 14 | 14 | 14 |
|
||||
| Avg likes/tweet | 30 | 80 | 150 | 250 |
|
||||
| Engagement rate | 0.08% | 0.5% | 1.0% | 1.5% |
|
||||
| Major threads/month | 1-2 | 4 | 6 | 8 |
|
||||
| Thread avg likes | 50 | 200 | 400 | 600 |
|
||||
| Followers | 66.7K | 70K | 78K | 90K |
|
||||
|
||||
This requires: consistent posting, visual branding, engagement funnel, and collaboration strategy as outlined above.
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
from app.api.v1.mcp import router
|
||||
print("MCP router loaded:", len(router.routes), "routes")
|
||||
for r in router.routes:
|
||||
print(" ", r.methods, r.path)
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,155 +0,0 @@
|
|||
"""fact_store — Agent loop long-term memory (M1 companion).
|
||||
|
||||
Frozen on creation. Modified only via bounded-task delegation with Task ID.
|
||||
|
||||
Redis-backed KV of verified system facts. The agent loop reads at loop start
|
||||
and writes at loop end, preventing re-discovery of known facts on every
|
||||
iteration.
|
||||
|
||||
Schema:
|
||||
Key: fact:{namespace}:{key}
|
||||
Value: JSON-encoded arbitrary data
|
||||
TTL: 24h default (override per call)
|
||||
|
||||
Examples:
|
||||
await write_fact("agents", "postgres.host", "rmi-postgres", ttl=86400)
|
||||
await write_fact("agents", "embedder.current", "bge-m3", ttl=86400)
|
||||
facts = await load_facts("agents")
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
# Lazy import — get_redis() may not be importable in unit tests.
|
||||
try:
|
||||
from app.core.redis import get_redis
|
||||
except ImportError:
|
||||
get_redis = None # type: ignore[assignment]
|
||||
|
||||
|
||||
KEY_PREFIX = "fact:"
|
||||
DEFAULT_TTL_SECONDS = 86400
|
||||
|
||||
|
||||
async def load_facts(namespace: str) -> dict[str, Any]:
|
||||
"""Load all facts in a namespace.
|
||||
|
||||
Returns an empty dict if Redis is unavailable (e.g. unit tests).
|
||||
"""
|
||||
if get_redis is None:
|
||||
return {}
|
||||
try:
|
||||
r = await get_redis()
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
keys = await r.keys(f"{KEY_PREFIX}{namespace}:*")
|
||||
if not keys:
|
||||
return {}
|
||||
|
||||
vals = await r.mget(*keys)
|
||||
out: dict[str, Any] = {}
|
||||
for k, v in zip(keys, vals, strict=True):
|
||||
if v is None:
|
||||
continue
|
||||
try:
|
||||
# Strip the prefix to get just the key portion.
|
||||
short_key = k.decode() if isinstance(k, bytes) else k
|
||||
short_key = short_key.removeprefix(f"{KEY_PREFIX}{namespace}:")
|
||||
out[short_key] = json.loads(v)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
async def write_fact(
|
||||
namespace: str,
|
||||
key: str,
|
||||
value: Any,
|
||||
ttl: int = DEFAULT_TTL_SECONDS,
|
||||
) -> bool:
|
||||
"""Write one fact to the store.
|
||||
|
||||
Returns False if Redis is unavailable (graceful degradation).
|
||||
"""
|
||||
if get_redis is None:
|
||||
return False
|
||||
try:
|
||||
r = await get_redis()
|
||||
await r.setex(
|
||||
f"{KEY_PREFIX}{namespace}:{key}",
|
||||
ttl,
|
||||
json.dumps(value, default=str),
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def delete_fact(namespace: str, key: str) -> bool:
|
||||
"""Delete one fact. Returns False if not found or Redis unavailable."""
|
||||
if get_redis is None:
|
||||
return False
|
||||
try:
|
||||
r = await get_redis()
|
||||
deleted = await r.delete(f"{KEY_PREFIX}{namespace}:{key}")
|
||||
return bool(deleted)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def list_namespaces() -> list[str]:
|
||||
"""Return all distinct namespaces currently in the store."""
|
||||
if get_redis is None:
|
||||
return []
|
||||
try:
|
||||
r = await get_redis()
|
||||
keys = await r.keys(f"{KEY_PREFIX}*")
|
||||
namespaces: set[str] = set()
|
||||
for k in keys:
|
||||
s = k.decode() if isinstance(k, bytes) else k
|
||||
parts = s.removeprefix(KEY_PREFIX).split(":", 1)
|
||||
if parts:
|
||||
namespaces.add(parts[0])
|
||||
return sorted(namespaces)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
# ── Seed function for first-time setup ─────────────────────────────────
|
||||
SEED_FACTS: list[tuple[str, str, Any, int]] = [
|
||||
# (namespace, key, value, ttl)
|
||||
("agents", "postgres.host", "rmi-postgres", 86400),
|
||||
("agents", "postgres.port", 5432, 86400),
|
||||
("agents", "redis.host", "rmi-redis", 86400),
|
||||
("agents", "redis.port", 6379, 86400),
|
||||
("agents", "clickhouse.host", "rmi-clickhouse", 86400),
|
||||
("agents", "neo4j.host", "rmi-neo4j", 86400),
|
||||
("agents", "neo4j.port", 7474, 86400),
|
||||
("agents", "qdrant.host", "rmi-qdrant", 86400),
|
||||
("agents", "ollama.url", "http://ollama:11434", 86400),
|
||||
("agents", "embedder.current", "bge-m3", 86400),
|
||||
("agents", "embedder.target", "qwen3-embedding:4b", 86400),
|
||||
("agents", "embedder.current_dims", 1024, 86400),
|
||||
("agents", "embedder.target_dims", 2048, 86400),
|
||||
("agents", "main.path", "/root/backend/main.py", 86400),
|
||||
("agents", "main.lines_target", 94, 86400),
|
||||
("agents", "legacy.lines", 8475, 86400),
|
||||
("agents", "frozen_manifest.count", 14, 86400),
|
||||
("agents", "mustdo.total", 30, 86400),
|
||||
("agents", "phase.current", "0", 86400),
|
||||
("agents", "deploy.method", "blue-green", 86400),
|
||||
]
|
||||
|
||||
|
||||
async def seed_facts() -> int:
|
||||
"""Seed the store with the top 20 verified system facts.
|
||||
|
||||
Returns the number of facts written. Idempotent — overwrites existing.
|
||||
"""
|
||||
written = 0
|
||||
for namespace, key, value, ttl in SEED_FACTS:
|
||||
if await write_fact(namespace, key, value, ttl):
|
||||
written += 1
|
||||
return written
|
||||
|
|
@ -1,260 +0,0 @@
|
|||
"""Agent Loop Specification (M1 — P2 #30 in v3 unfuck plan).
|
||||
|
||||
Frozen on creation. Modified only via bounded-task delegation with Task ID
|
||||
in /home/z/my-project/worklog.md. See DESIGN.md §M1.
|
||||
|
||||
Defines the formal contract for any AI agent loop that runs on RMI infrastructure:
|
||||
Hermes, claude-code, aider, GLM-5.2. Without this spec, loops are unbounded —
|
||||
they iterate forever, burn tokens, produce no verifiable artifacts.
|
||||
|
||||
Usage from a Hermes cron task:
|
||||
from app.agents.loop import BoundedAgentLoop, TaskInput, LoopBudget
|
||||
loop = BoundedAgentLoop(
|
||||
task=TaskInput(
|
||||
task_id="30-a",
|
||||
description="Add the new typed error class",
|
||||
success_criteria="error class exists and passes mypy",
|
||||
verify_command="python -c 'from app.core.errors import NewError'",
|
||||
),
|
||||
budget=LoopBudget(max_iterations=5, max_tokens=10_000),
|
||||
)
|
||||
result = await loop.run()
|
||||
if result.verify_passed:
|
||||
...
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
# ── Input Contract ──────────────────────────────────────────────────────
|
||||
class TaskInput(BaseModel):
|
||||
"""What every agent loop receives."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
task_id: str = Field(
|
||||
...,
|
||||
pattern=r"^\d+-[a-z0-9-]+$",
|
||||
description="Globally-unique ID matching ^\\d+-[a-z0-9-]+$. Logged before delegation.",
|
||||
)
|
||||
description: str = Field(
|
||||
...,
|
||||
max_length=500,
|
||||
description="One-paragraph task description. No multi-page briefs — split the task.",
|
||||
)
|
||||
context_budget_tokens: int = Field(
|
||||
default=8000,
|
||||
le=32000,
|
||||
description="How much context the agent loads. Larger = more expensive.",
|
||||
)
|
||||
allowed_tools: list[str] = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="Whitelist of MCP tools the agent may call. Anything else trips a kill switch.",
|
||||
)
|
||||
success_criteria: str = Field(
|
||||
...,
|
||||
max_length=300,
|
||||
description="One-sentence definition of 'done.' Verifiable by verify_command.",
|
||||
)
|
||||
verify_command: str = Field(
|
||||
...,
|
||||
max_length=200,
|
||||
description="Shell command that returns 0 if success_criteria is met.",
|
||||
)
|
||||
|
||||
|
||||
# ── Budget / Kill Switches ─────────────────────────────────────────────
|
||||
class LoopBudget(BaseModel):
|
||||
"""The four kill switches. Checked every iteration."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
max_iterations: int = Field(default=20, le=100)
|
||||
max_tokens: int = Field(default=50_000, le=500_000)
|
||||
max_wallclock_seconds: int = Field(default=900, le=3600)
|
||||
max_spend_usd: float = Field(default=5.0, le=50.0)
|
||||
|
||||
|
||||
# ── Output Contract ─────────────────────────────────────────────────────
|
||||
class TaskOutput(BaseModel):
|
||||
"""What every agent loop returns."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
task_id: str
|
||||
files_touched: list[str] = Field(default_factory=list)
|
||||
lines_added: int = 0
|
||||
lines_removed: int = 0
|
||||
verify_passed: bool = False
|
||||
worklog_entry: str = ""
|
||||
spend_usd: float = 0.0
|
||||
iterations_used: int = 0
|
||||
aborted: bool = False
|
||||
abort_reason: str | None = None
|
||||
|
||||
|
||||
# ── Kill switch reasons ─────────────────────────────────────────────────
|
||||
class BudgetExceededError(RuntimeError):
|
||||
"""Raised when any kill switch trips."""
|
||||
|
||||
|
||||
# ── Bounded Agent Loop ──────────────────────────────────────────────────
|
||||
class BoundedAgentLoop:
|
||||
"""Wraps any agent loop with kill switches and a verifiable output contract.
|
||||
|
||||
This is the production runtime. For the actual loop body, subclass and
|
||||
override _step(). The base class enforces the budget and produces the
|
||||
output contract.
|
||||
"""
|
||||
|
||||
def __init__(self, task: TaskInput, budget: LoopBudget | None = None) -> None:
|
||||
self.task = task
|
||||
self.budget = budget or LoopBudget()
|
||||
self._iterations_used = 0
|
||||
self._tokens_used = 0
|
||||
self._spend_usd = 0.0
|
||||
self._files_touched: list[str] = []
|
||||
self._lines_added = 0
|
||||
self._lines_removed = 0
|
||||
self._start_time = 0.0
|
||||
self._aborted = False
|
||||
self._abort_reason: str | None = None
|
||||
|
||||
async def run(self) -> TaskOutput:
|
||||
"""Execute the loop until done, budget exceeded, or verify passes."""
|
||||
self._start_time = time.monotonic()
|
||||
|
||||
# Load long-term memory from fact_store at loop start.
|
||||
facts = await self._load_facts()
|
||||
context = self._build_initial_context(facts)
|
||||
|
||||
while not self._aborted:
|
||||
self._check_budget()
|
||||
self._iterations_used += 1
|
||||
|
||||
try:
|
||||
step_result = await self._step(context)
|
||||
except BudgetExceededError as exc:
|
||||
self._aborted = True
|
||||
self._abort_reason = str(exc)
|
||||
break
|
||||
|
||||
self._record_step(step_result)
|
||||
context = self._update_context(context, step_result)
|
||||
|
||||
# Check verify_command after each step (cheap path).
|
||||
if await self._verify():
|
||||
break
|
||||
|
||||
# Final verification.
|
||||
verify_passed = await self._verify()
|
||||
|
||||
return TaskOutput(
|
||||
task_id=self.task.task_id,
|
||||
files_touched=self._files_touched,
|
||||
lines_added=self._lines_added,
|
||||
lines_removed=self._lines_removed,
|
||||
verify_passed=verify_passed,
|
||||
worklog_entry=self._build_worklog_entry(),
|
||||
spend_usd=self._spend_usd,
|
||||
iterations_used=self._iterations_used,
|
||||
aborted=self._aborted,
|
||||
abort_reason=self._abort_reason,
|
||||
)
|
||||
|
||||
# ── To be overridden by subclasses ──────────────────────────────────
|
||||
async def _step(self, context: Any) -> dict[str, Any]:
|
||||
"""One iteration of the agent loop. Subclass and implement."""
|
||||
raise NotImplementedError
|
||||
|
||||
# ── Built-in budget enforcement ─────────────────────────────────────
|
||||
def _check_budget(self) -> None:
|
||||
"""Throws BudgetExceededError if any kill switch is tripped."""
|
||||
if self._iterations_used + 1 > self.budget.max_iterations:
|
||||
raise BudgetExceededError(
|
||||
f"max_iterations={self.budget.max_iterations} exceeded"
|
||||
)
|
||||
|
||||
elapsed = time.monotonic() - self._start_time
|
||||
if elapsed > self.budget.max_wallclock_seconds:
|
||||
raise BudgetExceededError(
|
||||
f"max_wallclock_seconds={self.budget.max_wallclock_seconds} exceeded"
|
||||
)
|
||||
|
||||
if self._tokens_used > self.budget.max_tokens:
|
||||
raise BudgetExceededError(
|
||||
f"max_tokens={self.budget.max_tokens} exceeded"
|
||||
)
|
||||
|
||||
if self._spend_usd > self.budget.max_spend_usd:
|
||||
raise BudgetExceededError(
|
||||
f"max_spend_usd={self.budget.max_spend_usd} exceeded"
|
||||
)
|
||||
|
||||
# ── Helpers (override-friendly) ─────────────────────────────────────
|
||||
async def _load_facts(self) -> dict[str, Any]:
|
||||
"""Load facts from fact_store at loop start."""
|
||||
from app.agents.fact_store import load_facts
|
||||
|
||||
return await load_facts(namespace="agents")
|
||||
|
||||
async def _verify(self) -> bool:
|
||||
"""Run verify_command and return True if exit code is 0."""
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
self.task.verify_command,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
proc.communicate(), timeout=120
|
||||
)
|
||||
return proc.returncode == 0
|
||||
except (asyncio.TimeoutError, OSError):
|
||||
return False
|
||||
|
||||
def _build_initial_context(self, facts: dict[str, Any]) -> Any:
|
||||
"""Build the initial context for step 0. Override for custom merging."""
|
||||
return {
|
||||
"task": self.task.model_dump(),
|
||||
"facts": facts,
|
||||
"budget": self.budget.model_dump(),
|
||||
"iteration": 0,
|
||||
}
|
||||
|
||||
def _update_context(self, context: Any, step_result: dict[str, Any]) -> Any:
|
||||
"""Update context for the next iteration."""
|
||||
context = dict(context)
|
||||
context["iteration"] = context.get("iteration", 0) + 1
|
||||
context["last_step"] = step_result
|
||||
return context
|
||||
|
||||
def _record_step(self, step_result: dict[str, Any]) -> None:
|
||||
"""Update internal counters from step result."""
|
||||
self._tokens_used += int(step_result.get("tokens_used", 0))
|
||||
self._spend_usd += float(step_result.get("spend_usd", 0))
|
||||
self._files_touched.extend(step_result.get("files_touched", []))
|
||||
self._lines_added += int(step_result.get("lines_added", 0))
|
||||
self._lines_removed += int(step_result.get("lines_removed", 0))
|
||||
|
||||
def _build_worklog_entry(self) -> str:
|
||||
"""Build the worklog entry for this task."""
|
||||
return (
|
||||
f"task_id: {self.task.task_id}\n"
|
||||
f"description: {self.task.description}\n"
|
||||
f"iterations_used: {self._iterations_used}\n"
|
||||
f"tokens_used: {self._tokens_used}\n"
|
||||
f"spend_usd: ${self._spend_usd:.3f}\n"
|
||||
f"files_touched: {len(self._files_touched)}\n"
|
||||
f"lines_added: {self._lines_added}\n"
|
||||
f"lines_removed: {self._lines_removed}\n"
|
||||
f"aborted: {self._aborted}"
|
||||
+ (f" (reason: {self._abort_reason})" if self._abort_reason else "")
|
||||
)
|
||||
|
|
@ -1 +0,0 @@
|
|||
"""HTTP transport layer. Routes are thin: parse → call domain service → return."""
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
"""Shared FastAPI dependencies.
|
||||
|
||||
Use these in route signatures to inject cross-cutting concerns:
|
||||
from app.api.deps import get_redis, get_current_user, get_settings
|
||||
|
||||
Actual implementations live in `app/core/`. This module is a re-export
|
||||
facade so route authors don't need to know which core module owns what.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
# Re-exports — actual implementations come from app/core/.
|
||||
# Core modules are populated by the parallel DeepSeek tasks (DS-1..DS-10).
|
||||
# Until then, these imports will fail; routes should not depend on them yet.
|
||||
try:
|
||||
from app.core.redis import get_redis # noqa: F401
|
||||
except ImportError:
|
||||
get_redis = None # type: ignore[assignment]
|
||||
|
||||
try:
|
||||
from app.core.db import get_db # noqa: F401
|
||||
except ImportError:
|
||||
get_db = None # type: ignore[assignment]
|
||||
|
||||
try:
|
||||
from app.core.auth import get_current_user, get_optional_user # noqa: F401
|
||||
except ImportError:
|
||||
get_current_user = None # type: ignore[assignment]
|
||||
get_optional_user = None # type: ignore[assignment]
|
||||
|
||||
try:
|
||||
from app.core.config import settings # noqa: F401
|
||||
except ImportError:
|
||||
from app.config import settings # fallback until core.config lands
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
"""V1 API router aggregator.
|
||||
|
||||
The strangle: new v1 routes are added here as domains migrate. The legacy
|
||||
main.py still mounts all old routes; we ADD new v1 routes on top so they
|
||||
co-exist until cutover.
|
||||
|
||||
To add a new domain:
|
||||
1. Create app/api/v1/<group>/<domain>.py with APIRouter
|
||||
2. Import and append it to `api_v1_router` below
|
||||
3. Mount the route prefix in the domain's __init__.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
# Aggregator list — populated as domains migrate.
|
||||
# Each entry is an APIRouter from app/api/v1/<group>/<domain>.py.
|
||||
api_v1_router: list[APIRouter] = []
|
||||
|
||||
# Aggregator router — single mount point for v1.
|
||||
# When domains migrate, replace this with a real aggregator:
|
||||
# from app.api.v1.public import router as public_router
|
||||
# api_v1_router.append(public_router)
|
||||
router = APIRouter(prefix="/api/v1", tags=["v1"])
|
||||
|
||||
|
||||
# ── Migrated domains ───────────────────────────────────────────────────
|
||||
# Each migrated domain is imported here. The router exposes endpoints
|
||||
# at /api/v1/<domain>/* (path defined per-router).
|
||||
#
|
||||
# During strangelfig, the LEGACY /api/v1/<domain>/* endpoints remain
|
||||
# mounted in main.py. The new v1 router is mounted at the same path
|
||||
# (FastAPI handles prefix-based routing) — first match wins, so the
|
||||
# legacy stays until we explicitly remove it.
|
||||
|
||||
from app.api.v1.auth.alerts import router as alerts_router # noqa: E402
|
||||
|
||||
api_v1_router.append(alerts_router)
|
||||
|
||||
from app.api.v1.public.wallet import router as wallet_router # noqa: E402
|
||||
|
||||
api_v1_router.append(wallet_router)
|
||||
|
||||
from app.api.v1.public.token import router as token_router # noqa: E402
|
||||
|
||||
api_v1_router.append(token_router)
|
||||
|
||||
from app.api.v1.public.scanner import router as scanner_router # noqa: E402
|
||||
|
||||
api_v1_router.append(scanner_router)
|
||||
|
||||
# x402 moved to app.domain.x402 (T34 v2)
|
||||
# Old app/api/v1/x402/payments.py removed to avoid model conflicts
|
||||
|
||||
from app.api.v1.rag.search import router as rag_v2_router # noqa: E402
|
||||
|
||||
api_v1_router.append(rag_v2_router)
|
||||
|
||||
from app.api.v1.admin.alerts_webhook import router as admin_alerts_webhook_router # noqa: E402
|
||||
|
||||
api_v1_router.append(admin_alerts_webhook_router)
|
||||
|
||||
from app.api.v1.catalog import router as catalog_router # noqa: E402
|
||||
|
||||
api_v1_router.append(catalog_router)
|
||||
|
||||
|
||||
def build_v1_router() -> APIRouter:
|
||||
"""Construct the v1 aggregator with all migrated routes mounted."""
|
||||
aggregated = APIRouter(prefix="/api/v1")
|
||||
for r in api_v1_router:
|
||||
aggregated.include_router(r)
|
||||
return aggregated
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
"""Admin routes — admin role required.
|
||||
|
||||
Target: user management, system config, ops, bulletin moderation.
|
||||
"""
|
||||
|
|
@ -1,215 +0,0 @@
|
|||
"""V1 Admin alerts webhook — receives AlertManager notifications.
|
||||
|
||||
This is the HTTP bridge between Prometheus AlertManager and the RMI backend.
|
||||
AlertManager POSTs JSON payloads here for two receivers:
|
||||
- /api/v1/admin/alerts/webhook — default (all severities, from rmi-alerts receiver)
|
||||
- /api/v1/admin/alerts/critical — critical only (from rmi-critical receiver)
|
||||
|
||||
Each handler:
|
||||
1. Parses the AlertManager v2 webhook payload
|
||||
2. Persists to Redis (sorted set, capped) for in-app display
|
||||
3. Logs structured record (JSON, single line)
|
||||
4. Returns 200 OK immediately so AlertManager doesn't retry
|
||||
|
||||
AlertManager webhook payload shape (Prometheus):
|
||||
{
|
||||
"version": "4",
|
||||
"groupKey": "<strings>",
|
||||
"status": "firing|resolved",
|
||||
"receiver": "rmi-alerts",
|
||||
"groupLabels": {"alertname": "...", ...},
|
||||
"commonLabels": {...},
|
||||
"commonAnnotations": {...},
|
||||
"externalURL": "...",
|
||||
"alerts": [
|
||||
{
|
||||
"status": "firing|resolved",
|
||||
"labels": {...},
|
||||
"annotations": {...},
|
||||
"startsAt": "RFC3339",
|
||||
"endsAt": "RFC3339",
|
||||
"generatorURL": "..."
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
logger = logging.getLogger("rmi.admin.alerts_webhook")
|
||||
|
||||
# Cap the in-Redis alert history so it doesn't grow unbounded.
|
||||
_REDIS_CAP = int(os.getenv("RMI_ALERTS_HISTORY_CAP", "500"))
|
||||
|
||||
router = APIRouter(prefix="/api/v1/admin/alerts", tags=["admin-alerts"])
|
||||
|
||||
|
||||
class AlertmanagerAlert(BaseModel):
|
||||
status: str
|
||||
labels: dict[str, str] = Field(default_factory=dict)
|
||||
annotations: dict[str, str] = Field(default_factory=dict)
|
||||
startsAt: str | None = None
|
||||
endsAt: str | None = None
|
||||
generatorURL: str | None = None
|
||||
|
||||
|
||||
class AlertmanagerPayload(BaseModel):
|
||||
version: str | None = None
|
||||
groupKey: str | None = None
|
||||
status: str
|
||||
receiver: str | None = None
|
||||
groupLabels: dict[str, str] = Field(default_factory=dict)
|
||||
commonLabels: dict[str, str] = Field(default_factory=dict)
|
||||
commonAnnotations: dict[str, str] = Field(default_factory=dict)
|
||||
externalURL: str | None = None
|
||||
alerts: list[AlertmanagerAlert] = Field(default_factory=list)
|
||||
|
||||
|
||||
def _redis_url_from_env() -> str:
|
||||
"""Build a Redis URL from discrete REDIS_HOST/PORT/DB/PASSWORD env vars.
|
||||
|
||||
Falls back to REDIS_URL if set, otherwise localhost. Returns a URL with
|
||||
auth credentials embedded if REDIS_PASSWORD is set.
|
||||
"""
|
||||
explicit = os.getenv("REDIS_URL")
|
||||
if explicit:
|
||||
return explicit
|
||||
host = os.getenv("REDIS_HOST", "localhost")
|
||||
port = os.getenv("REDIS_PORT", "6379")
|
||||
db = os.getenv("REDIS_DB", "0")
|
||||
password = os.getenv("REDIS_PASSWORD", "")
|
||||
if password:
|
||||
return f"redis://:{password}@{host}:{port}/{db}"
|
||||
return f"redis://{host}:{port}/{db}"
|
||||
|
||||
|
||||
def _redis_client():
|
||||
"""Lazy Redis import — avoids forcing a Redis dep at module load."""
|
||||
try:
|
||||
import redis.asyncio as redis_async # type: ignore
|
||||
|
||||
url = _redis_url_from_env()
|
||||
return redis_async.from_url(url, decode_responses=True)
|
||||
except Exception as exc: # pragma: no cover - degraded mode
|
||||
logger.warning("redis_unavailable", extra={"err": str(exc)})
|
||||
return None
|
||||
|
||||
|
||||
async def _persist_to_redis(payload: AlertmanagerPayload, severity: str) -> int:
|
||||
"""Push payload to Redis sorted set capped at _REDIS_CAP entries.
|
||||
|
||||
Returns number of alerts persisted (0 if Redis is down).
|
||||
"""
|
||||
client = _redis_client()
|
||||
if client is None:
|
||||
return 0
|
||||
try:
|
||||
score = time.time()
|
||||
record = json.dumps(
|
||||
{
|
||||
"received_at": score,
|
||||
"severity_bucket": severity,
|
||||
"receiver": payload.receiver,
|
||||
"status": payload.status,
|
||||
"group_labels": payload.groupLabels,
|
||||
"common_labels": payload.commonLabels,
|
||||
"common_annotations": payload.commonAnnotations,
|
||||
"alerts": [a.model_dump() for a in payload.alerts],
|
||||
},
|
||||
default=str,
|
||||
)
|
||||
key = "rmi:alerts:webhook"
|
||||
async with client.pipeline(transaction=False) as pipe:
|
||||
pipe.zadd(key, {record: score})
|
||||
pipe.zremrangebyrank(key, 0, -(_REDIS_CAP + 1))
|
||||
pipe.expire(key, 7 * 24 * 3600) # 7 days
|
||||
await pipe.execute()
|
||||
return len(payload.alerts)
|
||||
except Exception as exc:
|
||||
logger.warning("redis_persist_failed", extra={"err": str(exc)})
|
||||
return 0
|
||||
finally:
|
||||
try:
|
||||
await client.aclose()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _log_payload(payload: AlertmanagerPayload, severity: str, persisted: int) -> None:
|
||||
"""Single-line JSON log so log aggregators can index cleanly."""
|
||||
record = {
|
||||
"ts": time.time(),
|
||||
"event": "alertmanager_webhook",
|
||||
"severity_bucket": severity,
|
||||
"receiver": payload.receiver,
|
||||
"status": payload.status,
|
||||
"alert_count": len(payload.alerts),
|
||||
"persisted": persisted,
|
||||
"alertname": payload.groupLabels.get("alertname"),
|
||||
"common_labels": payload.commonLabels,
|
||||
}
|
||||
logger.info(json.dumps(record, default=str))
|
||||
|
||||
|
||||
@router.post("/webhook", status_code=200)
|
||||
async def alerts_webhook(payload: AlertmanagerPayload, request: Request) -> dict[str, Any]:
|
||||
"""Default receiver webhook — all severities."""
|
||||
severity = payload.commonLabels.get("severity", "unknown")
|
||||
persisted = await _persist_to_redis(payload, severity)
|
||||
_log_payload(payload, severity, persisted)
|
||||
return {
|
||||
"ok": True,
|
||||
"received": len(payload.alerts),
|
||||
"persisted": persisted,
|
||||
"severity": severity,
|
||||
"alertname": payload.groupLabels.get("alertname"),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/critical", status_code=200)
|
||||
async def alerts_critical_webhook(
|
||||
payload: AlertmanagerPayload, request: Request
|
||||
) -> dict[str, Any]:
|
||||
"""Critical-only webhook — escalations only (severity=critical)."""
|
||||
# Defensive: if someone misroutes a non-critical here, log and accept
|
||||
# (we don't want to lose data). Severity bucket stays "critical" because
|
||||
# the receiver name implies it.
|
||||
severity = "critical"
|
||||
persisted = await _persist_to_redis(payload, severity)
|
||||
_log_payload(payload, severity, persisted)
|
||||
return {
|
||||
"ok": True,
|
||||
"received": len(payload.alerts),
|
||||
"persisted": persisted,
|
||||
"severity": severity,
|
||||
"alertname": payload.groupLabels.get("alertname"),
|
||||
"bucket": "critical",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/recent", status_code=200)
|
||||
async def alerts_recent(limit: int = 50) -> dict[str, Any]:
|
||||
"""Read recent alerts (debug endpoint — admin only in production)."""
|
||||
client = _redis_client()
|
||||
if client is None:
|
||||
return {"ok": False, "error": "redis_unavailable", "items": []}
|
||||
try:
|
||||
# Newest first
|
||||
raw = await client.zrevrange("rmi:alerts:webhook", 0, max(0, limit - 1))
|
||||
items = [json.loads(r) for r in raw]
|
||||
return {"ok": True, "count": len(items), "items": items}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": str(exc), "items": []}
|
||||
finally:
|
||||
try:
|
||||
await client.aclose()
|
||||
except Exception:
|
||||
pass
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
"""T07 GlitchTip test endpoint.
|
||||
|
||||
POST /api/v1/_test/glitchtip
|
||||
{"type": "error", "message": "test error"}
|
||||
POST /api/v1/_test/exception
|
||||
→ triggers a real exception, captured by GlitchTip
|
||||
POST /api/v1/_test/message
|
||||
→ captures an info-level message
|
||||
|
||||
Used for:
|
||||
- Verifying the GlitchTip pipeline works
|
||||
- Smoke testing after deploys
|
||||
- Demonstrating the secret-scrubbing before_send hook
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/_test", tags=["test"])
|
||||
|
||||
|
||||
class GlitchtipTestRequest(BaseModel):
|
||||
type: str = "error" # error | exception | message
|
||||
message: str = "test event from RMI"
|
||||
secret: str | None = None # should be REDACTED in Sentry
|
||||
|
||||
|
||||
@router.post("/glitchtip")
|
||||
async def test_glitchtip(req: GlitchtipTestRequest) -> dict:
|
||||
"""Trigger a test event. Tests the secret-scrubbing before_send hook."""
|
||||
if req.type == "exception":
|
||||
try:
|
||||
raise ValueError(req.message)
|
||||
except Exception as e:
|
||||
try:
|
||||
from app.core.observability import capture_exception
|
||||
capture_exception(e, secret=req.secret, route="/api/v1/_test/glitchtip")
|
||||
except ImportError:
|
||||
log.exception("test_exception_no_sentry")
|
||||
return {"captured": "exception", "message": req.message}
|
||||
if req.type == "message":
|
||||
try:
|
||||
from app.core.observability import capture_message
|
||||
capture_message(req.message, level="warning", secret=req.secret)
|
||||
except ImportError:
|
||||
log.warning(f"test_message_no_sentry: {req.message}")
|
||||
return {"captured": "message", "message": req.message}
|
||||
# default: error log + capture
|
||||
log.error(f"test_error: {req.message} (secret={req.secret})")
|
||||
try:
|
||||
from app.core.observability import capture_message
|
||||
capture_message(req.message, level="error", secret=req.secret)
|
||||
except ImportError:
|
||||
pass
|
||||
return {"captured": "error", "message": req.message, "secret_redacted_in_sentry": True}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
"""Authenticated routes — JWT required.
|
||||
|
||||
Target: portfolio, alerts, intel feeds, profile, settings.
|
||||
"""
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
"""V1 alerts route — thin HTTP layer over app.domain.alerts.
|
||||
|
||||
Rules (per 2026 standards):
|
||||
- Parse request → call service → return response. No business logic here.
|
||||
- Pydantic models for request/response, defined in app.domain.alerts.models.
|
||||
- No direct Redis or DB access. Goes through AlertService.
|
||||
- All errors raised as AppError subclasses, handled by core.errors handlers.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.core.auth import get_optional_user
|
||||
from app.core.errors import AuthError, NotFoundError
|
||||
from app.domain.alerts import (
|
||||
AlertService,
|
||||
AlertSubscription,
|
||||
CreateAlertRequest,
|
||||
)
|
||||
from app.models import PaginatedResponse
|
||||
|
||||
router = APIRouter(prefix="/api/v1/alerts", tags=["alerts"])
|
||||
|
||||
|
||||
def _service() -> AlertService:
|
||||
"""Dependency: a fresh AlertService per request (stateless)."""
|
||||
return AlertService()
|
||||
|
||||
|
||||
@router.post("/subscribe", response_model=AlertSubscription, status_code=201)
|
||||
async def subscribe(
|
||||
req: CreateAlertRequest,
|
||||
user: Annotated[dict[str, Any] | None, Depends(get_optional_user)],
|
||||
svc: Annotated[AlertService, Depends(_service)],
|
||||
) -> AlertSubscription:
|
||||
"""Create a new alert subscription for a token."""
|
||||
owner_id = (user or {}).get("id")
|
||||
return await svc.create_subscription(req, owner_id=owner_id)
|
||||
|
||||
|
||||
@router.get("", response_model=PaginatedResponse)
|
||||
async def list_alerts(
|
||||
user: Annotated[dict[str, Any] | None, Depends(get_optional_user)],
|
||||
svc: Annotated[AlertService, Depends(_service)],
|
||||
) -> PaginatedResponse:
|
||||
"""List alert subscriptions. Authenticated users see their own; anonymous sees all (legacy behavior)."""
|
||||
owner_id = (user or {}).get("id")
|
||||
items = await svc.list_subscriptions(owner_id=owner_id)
|
||||
return PaginatedResponse(
|
||||
items=[i.model_dump(mode="json") for i in items],
|
||||
total=len(items),
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{alert_id}", status_code=204)
|
||||
async def cancel(
|
||||
alert_id: str,
|
||||
user: Annotated[dict[str, Any] | None, Depends(get_optional_user)],
|
||||
svc: Annotated[AlertService, Depends(_service)],
|
||||
) -> None:
|
||||
"""Cancel (delete) a subscription. Owner only."""
|
||||
owner_id = (user or {}).get("id")
|
||||
if owner_id is None:
|
||||
raise AuthError("authentication required to cancel alerts")
|
||||
ok = await svc.cancel_subscription(alert_id, owner_id=owner_id)
|
||||
if not ok:
|
||||
raise NotFoundError(f"alert {alert_id} not found or not owned by you")
|
||||
return None
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
"""Catalog v1 routes — thin HTTP layer."""
|
||||
from .router import router
|
||||
|
||||
__all__ = ["router"]
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -1,155 +0,0 @@
|
|||
"""T27B HTTP routes — CatalogService endpoints.
|
||||
|
||||
Per v4.0 §T27. The thin HTTP layer over app.catalog.service.CatalogService.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.catalog.models import Chain
|
||||
from app.catalog.service import get_catalog
|
||||
|
||||
router = APIRouter(prefix="/api/v1/catalog", tags=["catalog"])
|
||||
|
||||
|
||||
# ── Request models ───────────────────────────────────────────────
|
||||
class RagIngestRequest(BaseModel):
|
||||
content: str = Field(..., min_length=1)
|
||||
collection: str = "scam_intel"
|
||||
doc_id: str | None = None
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class RagSearchRequest(BaseModel):
|
||||
query: str = Field(..., min_length=1)
|
||||
collection: str = "scam_intel"
|
||||
top_k: int = Field(default=5, ge=1, le=50)
|
||||
|
||||
|
||||
class ResolveEntityRequest(BaseModel):
|
||||
wallet_id: str
|
||||
max_chains: int = Field(default=5, ge=1, le=20)
|
||||
|
||||
|
||||
class FindRiskyTokensRequest(BaseModel):
|
||||
min_rug_count: int = Field(default=1, ge=1)
|
||||
chain: str | None = None
|
||||
limit: int = Field(default=50, ge=1, le=200)
|
||||
|
||||
|
||||
class AttachRagRequest(BaseModel):
|
||||
chain: str
|
||||
address: str
|
||||
qdrant_point_id: str
|
||||
|
||||
|
||||
# ── Health / introspection ───────────────────────────────────────
|
||||
@router.get("/stats")
|
||||
async def stats() -> dict:
|
||||
"""Catalog stats: which stores are reachable + entity counts."""
|
||||
return await get_catalog().stats()
|
||||
|
||||
|
||||
@router.get("/probe")
|
||||
async def probe() -> dict:
|
||||
"""Probe which stores are reachable from this container."""
|
||||
return await get_catalog().probe_stores()
|
||||
|
||||
|
||||
# ── Token endpoints ─────────────────────────────────────────────
|
||||
@router.get("/tokens/{chain}/{address}")
|
||||
async def get_token(chain: str, address: str) -> dict:
|
||||
"""Get a token by chain+address. Returns full Token model + provenance."""
|
||||
try:
|
||||
c = Chain(chain)
|
||||
except ValueError:
|
||||
raise HTTPException(400, f"unknown chain: {chain}")
|
||||
tok = await get_catalog().get_token(c, address)
|
||||
if not tok:
|
||||
raise HTTPException(404, "token not found")
|
||||
return tok.model_dump(mode="json")
|
||||
|
||||
|
||||
@router.get("/tokens/{chain}/{address}/risk")
|
||||
async def get_token_risk(chain: str, address: str) -> dict:
|
||||
"""Recipe 3 — Real-time risk score. Composes Redis + Postgres + Neo4j."""
|
||||
try:
|
||||
c = Chain(chain)
|
||||
except ValueError:
|
||||
raise HTTPException(400, f"unknown chain: {chain}")
|
||||
return await get_catalog().get_token_risk(c, address)
|
||||
|
||||
|
||||
@router.post("/tokens/risky-by-deployer")
|
||||
async def risky_tokens(req: FindRiskyTokensRequest) -> dict:
|
||||
"""Recipe 1 — Find tokens deployed by wallets with rug history."""
|
||||
chain_enum = None
|
||||
if req.chain:
|
||||
try:
|
||||
chain_enum = Chain(req.chain)
|
||||
except ValueError:
|
||||
raise HTTPException(400, f"unknown chain: {req.chain}")
|
||||
tokens = await get_catalog().find_tokens_by_deployer_history(
|
||||
min_rug_count=req.min_rug_count, chain=chain_enum, limit=req.limit
|
||||
)
|
||||
return {
|
||||
"count": len(tokens),
|
||||
"tokens": [t.model_dump(mode="json") for t in tokens],
|
||||
}
|
||||
|
||||
|
||||
# ── Wallet endpoints ─────────────────────────────────────────────
|
||||
@router.get("/wallets/{chain}/{address}")
|
||||
async def get_wallet(chain: str, address: str) -> dict:
|
||||
try:
|
||||
c = Chain(chain)
|
||||
except ValueError:
|
||||
raise HTTPException(400, f"unknown chain: {chain}")
|
||||
w = await get_catalog().get_wallet(c, address)
|
||||
if not w:
|
||||
raise HTTPException(404, "wallet not found")
|
||||
return w.model_dump(mode="json")
|
||||
|
||||
|
||||
# ── Entity resolution (Recipe 5) ────────────────────────────────
|
||||
@router.post("/entities/resolve")
|
||||
async def resolve_entity(req: ResolveEntityRequest) -> dict:
|
||||
"""Cross-chain entity resolution via Neo4j Cypher."""
|
||||
return await get_catalog().resolve_entity(req.wallet_id, req.max_chains)
|
||||
|
||||
|
||||
# ── RAG bridge endpoints ────────────────────────────────────────
|
||||
@router.post("/rag/search")
|
||||
async def rag_search(req: RagSearchRequest) -> dict:
|
||||
"""Search the RAG system. Returns ranked hits with RRF scores."""
|
||||
hits = await get_catalog().rag_search(
|
||||
query=req.query, collection=req.collection, top_k=req.top_k
|
||||
)
|
||||
return {"count": len(hits), "hits": hits}
|
||||
|
||||
|
||||
@router.post("/rag/ingest")
|
||||
async def rag_ingest(req: RagIngestRequest) -> dict:
|
||||
"""Ingest content into RAG. Returns qdrant_point_id for cross-store linking."""
|
||||
return await get_catalog().rag_ingest(
|
||||
content=req.content,
|
||||
collection=req.collection,
|
||||
doc_id=req.doc_id,
|
||||
metadata=req.metadata,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/tokens/{chain}/{address}/attach-rag")
|
||||
async def attach_rag(chain: str, address: str, req: AttachRagRequest) -> dict:
|
||||
"""Link an existing RAG embedding (Qdrant point) to a Token row."""
|
||||
try:
|
||||
c = Chain(chain)
|
||||
except ValueError:
|
||||
raise HTTPException(400, f"unknown chain: {chain}")
|
||||
ok = await get_catalog().attach_rag_to_token(c, address, req.qdrant_point_id)
|
||||
if not ok:
|
||||
raise HTTPException(404, "token not found or update failed")
|
||||
return {"ok": True, "chain": chain, "address": address, "rag_embedding_id": req.qdrant_point_id}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
"""MCP v1 routes."""
|
||||
from .router import router
|
||||
|
||||
__all__ = ["router"]
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -1,120 +0,0 @@
|
|||
"""T33 MCP Server — HTTP wrapper for SSE transport.
|
||||
|
||||
Per v4.0 §T33. Endpoints:
|
||||
POST /mcp JSON-RPC 2.0 endpoint
|
||||
GET /mcp/tools Tool catalog
|
||||
POST /mcp/call/{tool_id} Direct tool execution (no JSON-RPC)
|
||||
|
||||
The server speaks the Model Context Protocol natively. Claude Desktop
|
||||
and Cursor connect via:
|
||||
{"mcpServers": {"rugmunch": {"url": "https://mcp.rugmunch.io/mcp", "transport": "sse"}}}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.mcp.server import TOOL_CATALOG, call_tool
|
||||
|
||||
router = APIRouter(prefix="/mcp", tags=["mcp"])
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class JsonRpcRequest(BaseModel):
|
||||
jsonrpc: str = "2.0"
|
||||
method: str
|
||||
params: dict[str, Any] = {}
|
||||
id: int | str | None = None
|
||||
|
||||
|
||||
class JsonRpcResponse(BaseModel):
|
||||
jsonrpc: str = "2.0"
|
||||
result: Any | None = None
|
||||
error: dict | None = None
|
||||
id: int | str | None = None
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def jsonrpc_handler(req: JsonRpcRequest) -> dict:
|
||||
"""JSON-RPC 2.0 endpoint for MCP clients.
|
||||
|
||||
Methods:
|
||||
- initialize → returns server info
|
||||
- tools/list → returns tool catalog
|
||||
- tools/call → dispatches to backend
|
||||
- resources/list → empty
|
||||
- prompts/list → empty
|
||||
"""
|
||||
if req.jsonrpc != "2.0":
|
||||
return {"jsonrpc": "2.0", "error": {"code": -32600, "message": "invalid jsonrpc version"}, "id": req.id}
|
||||
|
||||
if req.method == "initialize":
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"serverInfo": {
|
||||
"name": "rugmunch-intelligence",
|
||||
"version": "4.0.0",
|
||||
"description": "Crypto intelligence platform — 13+ chains, 8 MCP tools, x402 paid tier",
|
||||
},
|
||||
"capabilities": {"tools": {}, "resources": {}, "prompts": {}},
|
||||
},
|
||||
"id": req.id,
|
||||
}
|
||||
|
||||
if req.method == "tools/list":
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"result": {"tools": TOOL_CATALOG},
|
||||
"id": req.id,
|
||||
}
|
||||
|
||||
if req.method == "tools/call":
|
||||
name = req.params.get("name", "")
|
||||
arguments = req.params.get("arguments", {})
|
||||
if not name:
|
||||
return {"jsonrpc": "2.0", "error": {"code": -32602, "message": "tool name required"}, "id": req.id}
|
||||
result = await call_tool(name, arguments)
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"content": [{"type": "text", "text": json.dumps(result, default=str)[:50000]}],
|
||||
"isError": "error" in result,
|
||||
},
|
||||
"id": req.id,
|
||||
}
|
||||
|
||||
if req.method == "resources/list":
|
||||
return {"jsonrpc": "2.0", "result": {"resources": []}, "id": req.id}
|
||||
|
||||
if req.method == "prompts/list":
|
||||
return {"jsonrpc": "2.0", "result": {"prompts": []}, "id": req.id}
|
||||
|
||||
if req.method == "notifications/initialized":
|
||||
return {"jsonrpc": "2.0", "result": {}, "id": req.id}
|
||||
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"error": {"code": -32601, "message": f"method not found: {req.method}"},
|
||||
"id": req.id,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/tools")
|
||||
async def list_tools() -> dict:
|
||||
"""Plain JSON endpoint (for direct integration, no JSON-RPC)."""
|
||||
return {"server": "rugmunch-intelligence", "version": "4.0.0", "tools": TOOL_CATALOG}
|
||||
|
||||
|
||||
@router.post("/call/{tool_id}")
|
||||
async def direct_call(tool_id: str, request: Request) -> dict:
|
||||
"""Direct tool execution (no JSON-RPC). For curl/scripts."""
|
||||
body = await request.json() if request.headers.get("content-type", "").startswith("application/json") else {}
|
||||
arguments = body.get("arguments", body) if isinstance(body, dict) else {}
|
||||
result = await call_tool(tool_id, arguments)
|
||||
return result
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
"""Public routes — no authentication required.
|
||||
|
||||
Target: scanner, wallet lookup, token info, pricing, health.
|
||||
"""
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
"""V1 scanner route — thin HTTP layer over app.domain.scanner.
|
||||
|
||||
The actual scan logic lives in app.token_scanner (legacy 4,109-line
|
||||
monolith). The new domain/ layer is a Pydantic facade that:
|
||||
- Validates the request
|
||||
- Calls the legacy scan_token
|
||||
- Wraps the result in a Pydantic response
|
||||
|
||||
Per-domain migration: as check_* functions are rewritten, the service
|
||||
stops calling the legacy monolith and uses the new modules instead.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.core.auth import get_optional_user
|
||||
from app.domain.scanner import ScanRequest, ScanResponse, ScannerService
|
||||
|
||||
router = APIRouter(prefix="/api/v1/scanner", tags=["scanner"])
|
||||
|
||||
|
||||
def _service() -> ScannerService:
|
||||
return ScannerService()
|
||||
|
||||
|
||||
@router.post("/scan", response_model=ScanResponse)
|
||||
async def scan(
|
||||
req: ScanRequest,
|
||||
svc: Annotated[ScannerService, Depends(_service)],
|
||||
_user: Annotated[dict[str, Any] | None, Depends(get_optional_user)],
|
||||
) -> ScanResponse:
|
||||
"""Full token scan. Multi-module. Returns Pydantic response.
|
||||
|
||||
The legacy /api/v1/scanner/scan (and /api/v1/token/scan) are still
|
||||
served — they take priority on the same path during strangelfig.
|
||||
"""
|
||||
return await svc.scan(req)
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
"""V1 token route — thin HTTP layer over app.domain.token."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.core.auth import get_optional_user
|
||||
from app.domain.token import (
|
||||
TokenDetail,
|
||||
TokenRisk,
|
||||
TokenScanRequest,
|
||||
TokenScanResult,
|
||||
TokenService,
|
||||
)
|
||||
from app.models import PaginatedResponse
|
||||
|
||||
router = APIRouter(prefix="/api/v1/token", tags=["token"])
|
||||
|
||||
|
||||
def _service() -> TokenService:
|
||||
return TokenService()
|
||||
|
||||
|
||||
@router.get("/{address}", response_model=TokenDetail)
|
||||
async def get_detail(
|
||||
address: str,
|
||||
svc: Annotated[TokenService, Depends(_service)],
|
||||
chain: str = Query(default="solana"),
|
||||
) -> TokenDetail:
|
||||
"""Token metadata + supply + verification."""
|
||||
return await svc.get_detail(address, chain=chain)
|
||||
|
||||
|
||||
@router.get("/{address}/holders", response_model=PaginatedResponse)
|
||||
async def get_holders(
|
||||
address: str,
|
||||
svc: Annotated[TokenService, Depends(_service)],
|
||||
chain: str = Query(default="solana"),
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
) -> PaginatedResponse:
|
||||
"""Top token holders."""
|
||||
holders = await svc.get_holders(address, chain=chain, limit=limit)
|
||||
return PaginatedResponse(
|
||||
items=[h.model_dump(mode="json") for h in holders],
|
||||
total=len(holders),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{address}/liquidity", response_model=dict)
|
||||
async def get_liquidity(
|
||||
address: str,
|
||||
svc: Annotated[TokenService, Depends(_service)],
|
||||
chain: str = Query(default="solana"),
|
||||
) -> dict:
|
||||
"""Token liquidity + pool info."""
|
||||
liq = await svc.get_liquidity(address, chain=chain)
|
||||
return liq.model_dump(mode="json")
|
||||
|
||||
|
||||
@router.get("/{address}/risk", response_model=TokenRisk)
|
||||
async def get_risk(
|
||||
address: str,
|
||||
svc: Annotated[TokenService, Depends(_service)],
|
||||
chain: str = Query(default="solana"),
|
||||
) -> TokenRisk:
|
||||
"""Combined risk: holders + liquidity + heuristics."""
|
||||
return await svc.get_risk(address, chain=chain)
|
||||
|
||||
|
||||
@router.post("/scan", response_model=TokenScanResult)
|
||||
async def scan(
|
||||
req: TokenScanRequest,
|
||||
svc: Annotated[TokenService, Depends(_service)],
|
||||
_user: Annotated[dict[str, Any] | None, Depends(get_optional_user)],
|
||||
) -> TokenScanResult:
|
||||
"""Full token scan. Multi-module. Freemium rate-limit applied in legacy router."""
|
||||
return await svc.scan(req)
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
"""V1 wallet route — thin HTTP layer over app.domain.wallet.
|
||||
|
||||
Rules (per 2026 standards):
|
||||
- Parse request → call service → return response. No business logic.
|
||||
- Pydantic models defined in app.domain.wallet.models.
|
||||
- No direct Redis/DB access. Goes through WalletService.
|
||||
- Errors raised as AppError subclasses, handled by core.errors.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.core.auth import get_optional_user
|
||||
from app.domain.wallet import (
|
||||
Balance,
|
||||
ScanRequest,
|
||||
ScanResult,
|
||||
WalletAnalysis,
|
||||
WalletService,
|
||||
)
|
||||
from app.models import PaginatedResponse
|
||||
|
||||
router = APIRouter(prefix="/api/v1/wallet", tags=["wallet"])
|
||||
|
||||
|
||||
def _service() -> WalletService:
|
||||
return WalletService()
|
||||
|
||||
|
||||
@router.get("/{address}/analysis", response_model=WalletAnalysis)
|
||||
async def get_analysis(
|
||||
address: str,
|
||||
svc: Annotated[WalletService, Depends(_service)],
|
||||
chain: str = Query(default="solana"),
|
||||
) -> WalletAnalysis:
|
||||
"""Full wallet analysis: risk score + tokens + recent transactions."""
|
||||
return await svc.analyze(address, chain=chain)
|
||||
|
||||
|
||||
@router.get("/{address}/balance", response_model=Balance)
|
||||
async def get_balance(
|
||||
address: str,
|
||||
svc: Annotated[WalletService, Depends(_service)],
|
||||
chain: str = Query(default="solana"),
|
||||
) -> Balance:
|
||||
"""Wallet balance + token holdings."""
|
||||
return await svc.get_balance(address, chain=chain)
|
||||
|
||||
|
||||
@router.get("/{address}/transactions", response_model=PaginatedResponse)
|
||||
async def get_transactions(
|
||||
address: str,
|
||||
svc: Annotated[WalletService, Depends(_service)],
|
||||
chain: str = Query(default="solana"),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
) -> PaginatedResponse:
|
||||
"""Recent transactions for a wallet."""
|
||||
txs = await svc.get_transactions(address, chain=chain, limit=limit)
|
||||
return PaginatedResponse(
|
||||
items=[t.model_dump(mode="json") for t in txs],
|
||||
total=len(txs),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/scan", response_model=ScanResult)
|
||||
async def scan(
|
||||
req: ScanRequest,
|
||||
svc: Annotated[WalletService, Depends(_service)],
|
||||
_user: Annotated[dict[str, Any] | None, Depends(get_optional_user)],
|
||||
) -> ScanResult:
|
||||
"""Multi-chain threat scan. Auth optional — freemium rate-limit applied in legacy router."""
|
||||
return await svc.scan(req)
|
||||
Binary file not shown.
|
|
@ -1,81 +0,0 @@
|
|||
"""V1 RAG route — thin HTTP layer over app.rag.
|
||||
|
||||
The RAG system is the most coupled module (14 legacy files). This
|
||||
facade exposes the most-used operations: search, ingest, feedback.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.rag import (
|
||||
FeedbackRecord,
|
||||
IngestRequest,
|
||||
IngestResult,
|
||||
RAGService,
|
||||
SearchRequest,
|
||||
SearchResponse,
|
||||
)
|
||||
from app.rag.engine import bulk_ingest as engine_bulk_ingest
|
||||
from app.rag.engine import get_stats as engine_get_stats
|
||||
|
||||
router = APIRouter(prefix="/api/v1/rag/v2", tags=["rag"])
|
||||
|
||||
|
||||
def _service() -> RAGService:
|
||||
return RAGService()
|
||||
|
||||
|
||||
class BulkIngestRequest(BaseModel):
|
||||
collection: str = "scam_intel"
|
||||
items: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
@router.post("/search", response_model=SearchResponse)
|
||||
async def search(
|
||||
req: SearchRequest,
|
||||
svc: Annotated[RAGService, Depends(_service)],
|
||||
) -> SearchResponse:
|
||||
"""RAG search. Returns Pydantic response with hits + scores."""
|
||||
return await svc.search(req)
|
||||
|
||||
|
||||
@router.post("/ingest", response_model=IngestResult)
|
||||
async def ingest(
|
||||
req: IngestRequest,
|
||||
svc: Annotated[RAGService, Depends(_service)],
|
||||
) -> IngestResult:
|
||||
"""Ingest a document into the RAG system."""
|
||||
return await svc.ingest(req)
|
||||
|
||||
|
||||
@router.post("/feedback", response_model=IngestResult)
|
||||
async def feedback(
|
||||
record: FeedbackRecord,
|
||||
svc: Annotated[RAGService, Depends(_service)],
|
||||
) -> IngestResult:
|
||||
"""Record scanner → RAG feedback. Ingests known scam into known_scams collection."""
|
||||
ok = await svc.record_feedback(record)
|
||||
return IngestResult(
|
||||
doc_id=record.token_address,
|
||||
collection="known_scams",
|
||||
status="ok" if ok else "failed",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def stats() -> dict:
|
||||
"""Per-collection vector counts + active embedder backend."""
|
||||
return engine_get_stats()
|
||||
|
||||
|
||||
@router.post("/bulk-ingest")
|
||||
async def bulk(req: BulkIngestRequest) -> dict:
|
||||
"""Ingest many items into a collection sequentially (max 500 per call)."""
|
||||
if not req.items:
|
||||
raise HTTPException(status_code=400, detail="items must be non-empty")
|
||||
if len(req.items) > 500:
|
||||
raise HTTPException(status_code=400, detail="bulk limit 500 per call")
|
||||
return await engine_bulk_ingest(items=req.items, collection=req.collection)
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
"""Test endpoint for verifying error handlers work end-to-end.
|
||||
|
||||
This is a development-only endpoint that raises domain errors to verify
|
||||
the FastAPI exception handlers in app/core/errors.py work correctly.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.core.errors import (
|
||||
HoneypotDetectedError,
|
||||
InsufficientFundsError,
|
||||
PaymentRequiredError,
|
||||
WalletNotFoundError,
|
||||
)
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter(prefix="/api/v1/_test_errors", tags=["test-errors"])
|
||||
|
||||
|
||||
@router.get("/wallet_not_found/{address}")
|
||||
async def test_wallet_not_found(address: str):
|
||||
"""Raises WalletNotFoundError → 404 with code=wallet_not_found."""
|
||||
raise WalletNotFoundError(address, "ethereum")
|
||||
|
||||
|
||||
@router.get("/insufficient_funds")
|
||||
async def test_insufficient_funds():
|
||||
"""Raises InsufficientFundsError → 402 with code=insufficient_funds."""
|
||||
raise InsufficientFundsError(required=1.5, available=0.3, asset="SOL")
|
||||
|
||||
|
||||
@router.get("/honeypot/{address}")
|
||||
async def test_honeypot(address: str):
|
||||
"""Raises HoneypotDetectedError → 422 with code=honeypot_detected."""
|
||||
raise HoneypotDetectedError(address, "solana")
|
||||
|
||||
|
||||
@router.get("/payment_required")
|
||||
async def test_payment_required():
|
||||
"""Raises PaymentRequiredError → 402 with code=payment_required."""
|
||||
raise PaymentRequiredError("scan_token", 0.05, "solana")
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
"""Test endpoint for verifying the rate limiter works end-to-end.
|
||||
|
||||
This is a dev-only endpoint that uses rate_limit_dep to enforce
|
||||
per-tier rate limits. Returns RateLimitInfo in the response.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Response
|
||||
|
||||
from app.core.errors import RateLimitError
|
||||
from app.core.rate_limit import RateLimitInfo, rate_limit_dep
|
||||
|
||||
router = APIRouter(prefix="/api/v1/_test_ratelimit", tags=["test-ratelimit"])
|
||||
|
||||
|
||||
@router.get("/ping")
|
||||
async def ping(
|
||||
response: Response,
|
||||
rate: Annotated[RateLimitInfo, Depends(rate_limit_dep)],
|
||||
) -> dict:
|
||||
"""Hit this endpoint repeatedly to see rate limit kick in.
|
||||
|
||||
Returns 200 with rate info, or 429 when limit is exceeded.
|
||||
Headers: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset.
|
||||
"""
|
||||
# Surface rate info in response headers
|
||||
response.headers["X-RateLimit-Limit"] = str(rate.limit)
|
||||
response.headers["X-RateLimit-Remaining"] = str(rate.remaining)
|
||||
response.headers["X-RateLimit-Reset"] = rate.reset_at
|
||||
response.headers["X-RateLimit-Tier"] = rate.tier
|
||||
|
||||
if not rate.allowed:
|
||||
raise RateLimitError(
|
||||
f"Rate limit exceeded: {rate.used}/{rate.limit}",
|
||||
details={
|
||||
"tier": rate.tier,
|
||||
"used": rate.used,
|
||||
"limit": rate.limit,
|
||||
"reset_at": rate.reset_at,
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"rate_limit": rate.model_dump(mode="json"),
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
"""x402 paid routes — crypto micropayment gated.
|
||||
|
||||
Target: tools (split from legacy x402_tools.py), tokens, wallets, defi, security.
|
||||
"""
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
"""V1 x402 route — thin HTTP layer over app.domain.x402.
|
||||
|
||||
The actual x402 logic lives in 25+ legacy routers (x402_tools,
|
||||
x402_enforcement, x402_databus_tools, etc.). The new domain layer
|
||||
is a Pydantic facade that:
|
||||
- Validates requests
|
||||
- Calls the legacy routers
|
||||
- Wraps results in Pydantic models
|
||||
|
||||
Per-router cutover: as each x402 router is rewritten, the service
|
||||
stops calling the legacy code.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.domain.x402 import (
|
||||
PaymentFacilitator,
|
||||
ToolCatalog,
|
||||
X402Service,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/x402", tags=["x402"])
|
||||
|
||||
|
||||
def _service() -> X402Service:
|
||||
return X402Service()
|
||||
|
||||
|
||||
@router.get("/catalog", response_model=ToolCatalog)
|
||||
async def get_catalog(
|
||||
svc: Annotated[X402Service, Depends(_service)],
|
||||
) -> ToolCatalog:
|
||||
"""Full x402 tool catalog — list of paid tools + categories + pricing."""
|
||||
return await svc.get_catalog()
|
||||
|
||||
|
||||
@router.get("/facilitators", response_model=list[PaymentFacilitator])
|
||||
async def get_facilitators(
|
||||
svc: Annotated[X402Service, Depends(_service)],
|
||||
) -> list[PaymentFacilitator]:
|
||||
"""List of enabled payment facilitators and their health status."""
|
||||
return await svc.get_facilitators()
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
"""WebSocket endpoints.
|
||||
|
||||
Target: real-time alerts, scanner results, intel feeds.
|
||||
"""
|
||||
|
|
@ -1,600 +0,0 @@
|
|||
"""
|
||||
Arkham Counterparties — Entity Relationship Graph & Money Flow Analysis
|
||||
=====================================================================
|
||||
Maps address relationships and traces fund flows between entities.
|
||||
|
||||
Signals detected:
|
||||
- Direct trading counterparties (who trades with whom)
|
||||
- Fund flow paths (where money comes from and goes)
|
||||
- Entity clustering (wallets controlled by same entity)
|
||||
- Money flow volume analysis (total in/out, net flow)
|
||||
- Risk-weighted counterparties (scam/sanctioned exposure)
|
||||
- Cross-chain relationship mapping
|
||||
- Historical relationship persistence
|
||||
|
||||
Tier : Elite ($0.20)
|
||||
Price : 200000 atoms
|
||||
Endpoint: POST /api/v1/x402-tools/arkham_counterparties
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Constants ──────────────────────────────────────────────────────
|
||||
|
||||
ARKHAM_API_BASE = "https://api.arkhamintelligence.com"
|
||||
CACHE_TTL = 300 # 5 minutes
|
||||
MAX_TRANSACTIONS = 500
|
||||
MAX_COUNTERPARTIES = 50
|
||||
|
||||
# Risk thresholds
|
||||
HIGH_RISK_THRESHOLD = 75
|
||||
MEDIUM_RISK_THRESHOLD = 40
|
||||
LOW_RISK_THRESHOLD = 15
|
||||
|
||||
|
||||
# ── Enums ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class RelationshipType(str, Enum):
|
||||
DIRECT_TRADE = "direct_trade"
|
||||
FUNDING_SOURCE = "funding_source"
|
||||
WITHDRAWAL_DEST = "withdrawal_dest"
|
||||
SAME_ENTITY = "same_entity"
|
||||
CROSS_CHAIN = "cross_chain"
|
||||
SMART_CONTRACT = "smart_contract"
|
||||
|
||||
|
||||
class RiskLevel(str, Enum):
|
||||
CRITICAL = "critical"
|
||||
HIGH = "high"
|
||||
MEDIUM = "medium"
|
||||
LOW = "low"
|
||||
NONE = "none"
|
||||
|
||||
|
||||
# ── Data Models ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class Counterparty:
|
||||
"""A single counterparty entity related to the target address."""
|
||||
|
||||
address: str
|
||||
chain: str
|
||||
entity_name: str = ""
|
||||
entity_category: str = "unknown"
|
||||
relationship_type: RelationshipType = RelationshipType.DIRECT_TRADE
|
||||
relationship_strength: float = 0.0 # 0.0 to 1.0
|
||||
total_volume_in_usd: float = 0.0
|
||||
total_volume_out_usd: float = 0.0
|
||||
net_volume_usd: float = 0.0
|
||||
tx_count: int = 0
|
||||
first_interaction: int = 0 # unix timestamp
|
||||
last_interaction: int = 0
|
||||
risk_score: float = 0.0
|
||||
risk_level: str = "none"
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"address": self.address,
|
||||
"chain": self.chain,
|
||||
"entity_name": self.entity_name,
|
||||
"entity_category": self.entity_category,
|
||||
"relationship_type": (
|
||||
self.relationship_type.value
|
||||
if isinstance(self.relationship_type, RelationshipType)
|
||||
else self.relationship_type
|
||||
),
|
||||
"relationship_strength": round(self.relationship_strength, 3),
|
||||
"total_volume_in_usd": round(self.total_volume_in_usd, 2),
|
||||
"total_volume_out_usd": round(self.total_volume_out_usd, 2),
|
||||
"net_volume_usd": round(self.net_volume_usd, 2),
|
||||
"tx_count": self.tx_count,
|
||||
"first_interaction": self.first_interaction,
|
||||
"last_interaction": self.last_interaction,
|
||||
"risk_score": round(self.risk_score, 1),
|
||||
"risk_level": self.risk_level,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class MoneyFlow:
|
||||
"""Money flow path between addresses."""
|
||||
|
||||
source: str
|
||||
destination: str
|
||||
amount_usd: float
|
||||
token: str = ""
|
||||
chain: str = ""
|
||||
tx_hash: str = ""
|
||||
timestamp: int = 0
|
||||
hop: int = 1 # distance from original source
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"source": self.source,
|
||||
"destination": self.destination,
|
||||
"amount_usd": round(self.amount_usd, 2),
|
||||
"token": self.token,
|
||||
"chain": self.chain,
|
||||
"tx_hash": self.tx_hash[:18] + "..." if len(self.tx_hash) > 18 else self.tx_hash,
|
||||
"timestamp": self.timestamp,
|
||||
"hop": self.hop,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class EntityCluster:
|
||||
"""A cluster of related addresses/wallets."""
|
||||
|
||||
cluster_id: str
|
||||
addresses: list[str]
|
||||
total_volume_usd: float
|
||||
entity_hint: str = "" # Likely entity type or name
|
||||
confidence: float = 0.0 # 0-100
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"cluster_id": self.cluster_id,
|
||||
"address_count": len(self.addresses),
|
||||
"addresses": [a[:12] + "..." for a in self.addresses[:20]],
|
||||
"total_volume_usd": round(self.total_volume_usd, 2),
|
||||
"entity_hint": self.entity_hint,
|
||||
"confidence": round(self.confidence, 1),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class CounterpartyReport:
|
||||
"""Complete entity relationship analysis report."""
|
||||
|
||||
target_address: str
|
||||
chain: str
|
||||
target_entity_name: str = ""
|
||||
target_entity_category: str = "unknown"
|
||||
|
||||
# Summary stats
|
||||
total_interactions: int = 0
|
||||
unique_counterparties: int = 0
|
||||
total_volume_in_usd: float = 0.0
|
||||
total_volume_out_usd: float = 0.0
|
||||
net_volume_usd: float = 0.0
|
||||
|
||||
# Key findings
|
||||
counterparties: list[Counterparty] = field(default_factory=list)
|
||||
top_counterparties_by_volume: list[Counterparty] = field(default_factory=list)
|
||||
risk_exposed_counterparties: list[Counterparty] = field(default_factory=list)
|
||||
|
||||
# Flow analysis
|
||||
money_flows: list[MoneyFlow] = field(default_factory=list)
|
||||
fund_sources: list[str] = field(default_factory=list) # top funding sources
|
||||
|
||||
# Clustering
|
||||
entity_clusters: list[EntityCluster] = field(default_factory=list)
|
||||
|
||||
# Risk assessment
|
||||
max_risk_score: float = 0.0
|
||||
aggregate_risk_level: str = "none"
|
||||
scam_exposure_count: int = 0
|
||||
sanctioned_exposure_count: int = 0
|
||||
|
||||
errors: list[str] = field(default_factory=list)
|
||||
generated_at: str = field(
|
||||
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"target_address": self.target_address,
|
||||
"chain": self.chain,
|
||||
"target_entity_name": self.target_entity_name,
|
||||
"target_entity_category": self.target_entity_category,
|
||||
"summary": {
|
||||
"total_interactions": self.total_interactions,
|
||||
"unique_counterparties": self.unique_counterparties,
|
||||
"total_volume_in_usd": round(self.total_volume_in_usd, 2),
|
||||
"total_volume_out_usd": round(self.total_volume_out_usd, 2),
|
||||
"net_volume_usd": round(self.net_volume_usd, 2),
|
||||
"max_risk_score": round(self.max_risk_score, 1),
|
||||
"aggregate_risk_level": self.aggregate_risk_level,
|
||||
},
|
||||
"counterparties": [c.to_dict() for c in self.counterparties[:MAX_COUNTERPARTIES]],
|
||||
"top_counterparties_by_volume": [
|
||||
c.to_dict() for c in self.top_counterparties_by_volume[:10]
|
||||
],
|
||||
"risk_exposed_counterparties": [
|
||||
c.to_dict() for c in self.risk_exposed_counterparties[:10]
|
||||
],
|
||||
"fund_sources": self.fund_sources[:5],
|
||||
"entity_clusters": [c.to_dict() for c in self.entity_clusters[:10]],
|
||||
"scam_exposure_count": self.scam_exposure_count,
|
||||
"sanctioned_exposure_count": self.sanctioned_exposure_count,
|
||||
"generated_at": self.generated_at,
|
||||
"errors": self.errors,
|
||||
}
|
||||
|
||||
def summary(self) -> str:
|
||||
risk_emoji = {
|
||||
"critical": "🔴 CRITICAL",
|
||||
"high": "🟠 HIGH",
|
||||
"medium": "🟡 MEDIUM",
|
||||
"low": "🔵 LOW",
|
||||
"none": "✅ CLEAN",
|
||||
}.get(self.aggregate_risk_level, "⚪ UNKNOWN")
|
||||
|
||||
return (
|
||||
f"{risk_emoji} Counterparties — {self.target_address[:12]}... | "
|
||||
f"Interactions: {self.total_interactions} | "
|
||||
f"Unique counterparties: {self.unique_counterparties} | "
|
||||
f"Net flow: ${self.net_volume_usd:,.0f} | "
|
||||
f"Scam exposure: {self.scam_exposure_count} | "
|
||||
f"Sanctioned: {self.sanctioned_exposure_count}"
|
||||
)
|
||||
|
||||
|
||||
# ── Core Detector ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ArkhamCounterparties:
|
||||
"""Fetches and analyzes entity relationships and money flows."""
|
||||
|
||||
def __init__(self, api_key: str = "", cache_ttl: int = CACHE_TTL):
|
||||
self._api_key = api_key or os.getenv("ARKHAM_API_KEY", "")
|
||||
self._cache_ttl = cache_ttl
|
||||
self._local_cache: dict[str, CounterpartyReport] = {}
|
||||
|
||||
def _get_cached(self, address: str) -> CounterpartyReport | None:
|
||||
"""Check cache for existing report."""
|
||||
cached = self._local_cache.get(address)
|
||||
if cached:
|
||||
# Check TTL
|
||||
if cached.generated_at:
|
||||
cached_time = datetime.fromisoformat(cached.generated_at).timestamp()
|
||||
if (datetime.now().timestamp() - cached_time) < self._cache_ttl:
|
||||
return cached
|
||||
del self._local_cache[address]
|
||||
return None
|
||||
|
||||
def _set_cache(self, address: str, report: CounterpartyReport):
|
||||
"""Store report in cache."""
|
||||
self._local_cache[address] = report
|
||||
|
||||
async def analyze(
|
||||
self,
|
||||
address: str,
|
||||
chain: str = "ethereum",
|
||||
depth: int = 2,
|
||||
max_transactions: int = MAX_TRANSACTIONS,
|
||||
) -> CounterpartyReport:
|
||||
"""
|
||||
Analyze entity relationships and money flows for an address.
|
||||
|
||||
Args:
|
||||
address: Target wallet address to analyze
|
||||
chain: Blockchain name (ethereum, solana, bsc, etc.)
|
||||
depth: How many hops to trace fund flows (1-3)
|
||||
max_transactions: Max transactions to analyze
|
||||
|
||||
Returns:
|
||||
CounterpartyReport with full relationship analysis
|
||||
"""
|
||||
# Check cache first
|
||||
cached = self._get_cached(address)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
report = CounterpartyReport(
|
||||
target_address=address,
|
||||
chain=chain,
|
||||
)
|
||||
|
||||
try:
|
||||
# Fetch transaction history
|
||||
txs = await self._fetch_transactions(address, chain, max_transactions)
|
||||
report.total_interactions = len(txs)
|
||||
|
||||
if not txs:
|
||||
report.errors.append("No transactions found for address")
|
||||
return report
|
||||
|
||||
# Extract counterparties
|
||||
counterparties = self._extract_counterparties(txs, address, chain)
|
||||
|
||||
# Remove self from counterparties
|
||||
counterparties = [c for c in counterparties if c.address.lower() != address.lower()]
|
||||
|
||||
report.counterparties = counterparties
|
||||
report.unique_counterparties = len(counterparties)
|
||||
|
||||
# Calculate summary stats
|
||||
report.total_volume_in_usd = sum(c.total_volume_in_usd for c in counterparties)
|
||||
report.total_volume_out_usd = sum(c.total_volume_out_usd for c in counterparties)
|
||||
report.net_volume_usd = (
|
||||
report.total_volume_in_usd - report.total_volume_out_usd
|
||||
)
|
||||
|
||||
# Sort by volume
|
||||
report.top_counterparties_by_volume = sorted(
|
||||
counterparties, key=lambda x: x.total_volume_in_usd + x.total_volume_out_usd, reverse=True
|
||||
)[:10]
|
||||
|
||||
# Identify risk-exposed counterparties
|
||||
report.risk_exposed_counterparties = [
|
||||
c for c in counterparties if c.risk_score >= MEDIUM_RISK_THRESHOLD
|
||||
]
|
||||
|
||||
# Track fund sources
|
||||
report.fund_sources = self._identify_fund_sources(txs, address)[:5]
|
||||
|
||||
# Cluster related addresses
|
||||
report.entity_clusters = self._cluster_addresses(counterparties)[:10]
|
||||
|
||||
# Calculate aggregate risk
|
||||
report.max_risk_score = max((c.risk_score for c in counterparties), default=0.0)
|
||||
report.scam_exposure_count = sum(
|
||||
1 for c in counterparties if c.entity_category in ("scam", "sanctioned")
|
||||
)
|
||||
report.sanctioned_exposure_count = sum(
|
||||
1 for c in counterparties if c.entity_category == "sanctioned"
|
||||
)
|
||||
|
||||
if report.max_risk_score >= HIGH_RISK_THRESHOLD:
|
||||
report.aggregate_risk_level = "critical"
|
||||
elif report.max_risk_score >= MEDIUM_RISK_THRESHOLD:
|
||||
report.aggregate_risk_level = "high"
|
||||
elif report.max_risk_score >= LOW_RISK_THRESHOLD:
|
||||
report.aggregate_risk_level = "medium"
|
||||
elif report.risk_exposed_counterparties:
|
||||
report.aggregate_risk_level = "low"
|
||||
else:
|
||||
report.aggregate_risk_level = "none"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Counterparty analysis failed for {address}: {e}")
|
||||
report.errors.append(str(e))
|
||||
report.aggregate_risk_level = "error"
|
||||
|
||||
self._set_cache(address, report)
|
||||
return report
|
||||
|
||||
async def _fetch_transactions(
|
||||
self, address: str, chain: str, limit: int
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Fetch transaction history from Arkham API or fallback source."""
|
||||
txs = []
|
||||
|
||||
if self._api_key and httpx:
|
||||
try:
|
||||
txs = await self._fetch_from_arkham(address, chain, limit)
|
||||
except Exception as e:
|
||||
logger.warning(f"Arkham API fetch failed: {e}")
|
||||
|
||||
# Fallback: return empty if no API key (would normally use other sources)
|
||||
return txs
|
||||
|
||||
async def _fetch_from_arkham(
|
||||
self, address: str, chain: str, limit: int
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Fetch transactions from Arkham Intelligence API."""
|
||||
headers = {
|
||||
"API-Key": self._api_key,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
params = {
|
||||
"address": address,
|
||||
"chain": chain,
|
||||
"limit": min(limit, 500),
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp = await client.get(
|
||||
f"{ARKHAM_API_BASE}/v0/transactions",
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
return data.get("transactions", [])
|
||||
resp.raise_for_status()
|
||||
|
||||
return []
|
||||
|
||||
def _extract_counterparties(
|
||||
self, txs: list[dict[str, Any]], target: str, chain: str
|
||||
) -> list[Counterparty]:
|
||||
"""Extract and aggregate counterparty data from transactions."""
|
||||
counterparties: dict[str, Counterparty] = {}
|
||||
|
||||
for tx in txs:
|
||||
# Determine counterparty (the other side of the transaction)
|
||||
counterparty_addr = self._get_counterparty_address(tx, target)
|
||||
if not counterparty_addr:
|
||||
continue
|
||||
|
||||
if counterparty_addr not in counterparties:
|
||||
counterparties[counterparty_addr] = Counterparty(
|
||||
address=counterparty_addr,
|
||||
chain=tx.get("chain", chain),
|
||||
entity_name=tx.get("counterparty", {}).get("name", ""),
|
||||
entity_category=tx.get("counterparty", {}).get("type", "unknown"),
|
||||
)
|
||||
|
||||
cp = counterparties[counterparty_addr]
|
||||
|
||||
# Determine relationship type
|
||||
cp.relationship_type = self._determine_relationship_type(tx)
|
||||
|
||||
# Aggregate volumes
|
||||
amount = float(tx.get("amount", 0))
|
||||
usd_value = float(tx.get("usd_price", 0)) * amount
|
||||
|
||||
if self._is_incoming(tx, target):
|
||||
cp.total_volume_in_usd += usd_value
|
||||
else:
|
||||
cp.total_volume_out_usd += usd_value
|
||||
|
||||
cp.tx_count += 1
|
||||
cp.net_volume_usd = cp.total_volume_in_usd - cp.total_volume_out_usd
|
||||
|
||||
# Update timestamps
|
||||
ts = int(tx.get("timestamp", 0))
|
||||
if ts:
|
||||
if cp.first_interaction == 0 or ts < cp.first_interaction:
|
||||
cp.first_interaction = ts
|
||||
if ts > cp.last_interaction:
|
||||
cp.last_interaction = ts
|
||||
|
||||
# Calculate risk
|
||||
cp.risk_score, cp.risk_level = self._calculate_risk(cp.entity_category)
|
||||
cp.relationship_strength = min(1.0, cp.tx_count / 10.0)
|
||||
|
||||
return list(counterparties.values())
|
||||
|
||||
def _get_counterparty_address(
|
||||
self, tx: dict[str, Any], target: str
|
||||
) -> str:
|
||||
"""Get the counterparty address from a transaction."""
|
||||
target_lower = target.lower()
|
||||
|
||||
# Check from/to fields
|
||||
tx_from = (tx.get("from", "") or "").lower()
|
||||
tx_to = (tx.get("to", "") or "").lower()
|
||||
|
||||
if tx_from and tx_from != target_lower:
|
||||
return tx_from
|
||||
if tx_to and tx_to != target_lower:
|
||||
return tx_to
|
||||
|
||||
# Check for counterparty in nested structure
|
||||
counterparty = tx.get("counterparty", {}).get("address", "")
|
||||
if counterparty:
|
||||
return counterparty
|
||||
|
||||
return ""
|
||||
|
||||
def _is_incoming(self, tx: dict[str, Any], target: str) -> bool:
|
||||
"""Determine if transaction is incoming to target address."""
|
||||
target_lower = target.lower()
|
||||
tx_to = (tx.get("to", "") or "").lower()
|
||||
return tx_to == target_lower
|
||||
|
||||
def _determine_relationship_type(self, tx: dict[str, Any]) -> RelationshipType:
|
||||
"""Determine the type of relationship from transaction data."""
|
||||
tx_type = (tx.get("type", "") or "").lower()
|
||||
category = (tx.get("counterparty", {}).get("type", "") or "").lower()
|
||||
|
||||
if "swap" in tx_type or "trade" in tx_type:
|
||||
return RelationshipType.DIRECT_TRADE
|
||||
if "fund" in tx_type or "transfer" in tx_type:
|
||||
return RelationshipType.FUNDING_SOURCE
|
||||
if category in ("exchange", "cex"):
|
||||
return RelationshipType.FUNDING_SOURCE
|
||||
if category in ("contract", "smart_contract"):
|
||||
return RelationshipType.SMART_CONTRACT
|
||||
|
||||
return RelationshipType.DIRECT_TRADE
|
||||
|
||||
def _calculate_risk(
|
||||
self, category: str
|
||||
) -> tuple[float, str]:
|
||||
"""Calculate risk score based on entity category."""
|
||||
cat_low = category.lower()
|
||||
|
||||
if cat_low in ("scam", "sanctioned"):
|
||||
return 90.0, "critical"
|
||||
if cat_low in ("malicious", "phishing"):
|
||||
return 75.0, "high"
|
||||
if cat_low in ("suspicious", "high_risk"):
|
||||
return 50.0, "medium"
|
||||
if cat_low in ("rug", "hacker"):
|
||||
return 80.0, "high"
|
||||
|
||||
return 0.0, "none"
|
||||
|
||||
def _identify_fund_sources(
|
||||
self, txs: list[dict[str, Any]], target: str
|
||||
) -> list[str]:
|
||||
"""Identify primary sources of funds (both incoming and outgoing)."""
|
||||
sources = []
|
||||
|
||||
for tx in txs[:100]: # Limit analysis
|
||||
if self._is_incoming(tx, target):
|
||||
# Incoming: who sent funds
|
||||
source = self._get_counterparty_address(tx, target)
|
||||
if source and source not in sources:
|
||||
sources.append(source)
|
||||
else:
|
||||
# Outgoing: where funds went
|
||||
dest = self._get_counterparty_address(tx, target)
|
||||
if dest and dest not in sources:
|
||||
sources.append(dest)
|
||||
|
||||
return sources
|
||||
|
||||
def _cluster_addresses(
|
||||
self, counterparties: list[Counterparty]
|
||||
) -> list[EntityCluster]:
|
||||
"""Cluster addresses that may be controlled by the same entity."""
|
||||
clusters: list[EntityCluster] = []
|
||||
|
||||
# Group by entity name/hint
|
||||
by_entity: dict[str, list[Counterparty]] = {}
|
||||
for cp in counterparties:
|
||||
key = cp.entity_name or cp.entity_category or "unknown"
|
||||
if key not in by_entity:
|
||||
by_entity[key] = []
|
||||
by_entity[key].append(cp)
|
||||
|
||||
for entity_name, addrs in by_entity.items():
|
||||
if len(addrs) < 2:
|
||||
continue # Need at least 2 for a cluster
|
||||
|
||||
total_vol = sum(a.total_volume_in_usd + a.total_volume_out_usd for a in addrs)
|
||||
confidence = min(100.0, len(addrs) * 15.0) # More addresses = higher confidence
|
||||
|
||||
clusters.append(
|
||||
EntityCluster(
|
||||
cluster_id=entity_name[:20].replace(" ", "_").lower(),
|
||||
addresses=[a.address for a in addrs],
|
||||
total_volume_usd=total_vol,
|
||||
entity_hint=entity_name,
|
||||
confidence=confidence,
|
||||
)
|
||||
)
|
||||
|
||||
return sorted(clusters, key=lambda x: x.total_volume_usd, reverse=True)
|
||||
|
||||
|
||||
# ── CLI Entry Point ──────────────────────────────────────────────
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python -m app.arkham_counterparties <address> [chain]")
|
||||
sys.exit(1)
|
||||
|
||||
addr = sys.argv[1]
|
||||
chain = sys.argv[2] if len(sys.argv) > 2 else "ethereum"
|
||||
|
||||
async def main():
|
||||
analyzer = ArkhamCounterparties()
|
||||
report = await analyzer.analyze(addr, chain)
|
||||
print(report.summary())
|
||||
print(f"\nTop counterparties by volume:")
|
||||
for cp in report.top_counterparties_by_volume[:5]:
|
||||
print(f" - {cp.entity_name or cp.address[:12]}... | ${cp.net_volume_usd:,.0f} net | {cp.tx_count} tx")
|
||||
|
||||
asyncio.run(main())
|
||||
|
|
@ -1,728 +0,0 @@
|
|||
"""
|
||||
Arkham Entity Resolver
|
||||
======================
|
||||
Map any blockchain address to its real-world owner with multi-source confidence scoring.
|
||||
|
||||
Entity types detected:
|
||||
1. CEX/DEX Wallets — Binance, Coinbase, Kraken, OKX, Uniswap, SushiSwap, Curve
|
||||
2. DeFi Protocols — Aave, Compound, MakerDAO, Lido, EigenLayer, Ethena
|
||||
3. Institutional Funds — Grayscale, Pantera, a16z, Multicoin, Paradigm
|
||||
4. Smart Money / Whales — High-value wallets with repeat profitable trades
|
||||
5. Bridge / Cross-chain — LayerZero, Wormhole, Stargate, Across
|
||||
6. Scam / Sanctioned — Lazarus, Ronin exploiter, known bad actors
|
||||
7. NFT / Gaming — BAYC, Opensea, Blur, Magic Eden wallets
|
||||
8. Unknown / Uncategorized — Addresses with no known entity label
|
||||
|
||||
Competitive advantage:
|
||||
- Arkham Intelligence paid ($299/mo+) vs our bundled x402 access ($0.10/tool)
|
||||
- Chainalysis/TRM Labs require enterprise contracts (50K+/yr)
|
||||
- Our multi-source hybrid approach (Arkham API + local entity DB + heuristic scoring)
|
||||
means we still return useful results when the API is down
|
||||
- Confidence scoring (0-100) lets users calibrate trust per use case
|
||||
- Integrates with existing RMI tooling (wallet_graph, entity_clustering, reputation_score)
|
||||
|
||||
Usage:
|
||||
from app.arkham_entity import ArkhamEntityResolver
|
||||
|
||||
resolver = ArkhamEntityResolver()
|
||||
result = await resolver.resolve("0xBE0eB53FC46b790099138e3d32C721856d41e865")
|
||||
print(f"Entity: {result.entity_name}")
|
||||
print(f"Confidence: {result.confidence}/100")
|
||||
print(f"Category: {result.category}")
|
||||
for label in result.labels:
|
||||
print(f" [{label.source}] {label.label}")
|
||||
|
||||
CLI:
|
||||
python3 -m app.arkham_entity 0xBE0eB53FC46b790099138e3d32C721856d41e865
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from app.arkham_connector import ArkhamClient, ARKHAM_API_KEY
|
||||
except ImportError:
|
||||
ArkhamClient = None # type: ignore
|
||||
ARKHAM_API_KEY = ""
|
||||
|
||||
try:
|
||||
from app.entity_registry import KNOWN_CEX_WALLETS
|
||||
except ImportError:
|
||||
KNOWN_CEX_WALLETS = {}
|
||||
|
||||
try:
|
||||
from app.entity_labeler import EXCHANGES, PROTOCOLS
|
||||
except ImportError:
|
||||
EXCHANGES = {}
|
||||
PROTOCOLS = {}
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
ETH_ADDRESS_RE = re.compile(r"^0x[a-fA-F0-9]{40}$")
|
||||
SOL_ADDRESS_RE = re.compile(r"^[1-9A-HJ-NP-Za-km-z]{32,44}$")
|
||||
|
||||
# Known entity database — manual curation supplementing Arkham data
|
||||
KNOWN_ENTITIES: dict[str, dict[str, Any]] = {
|
||||
# Exchanges
|
||||
"0xBE0eB53FC46b790099138e3d32C721856d41e865": {
|
||||
"name": "Binance", "category": "exchange", "label": "Binance Hot Wallet 7",
|
||||
"tags": "cex,hot_wallet,high_volume",
|
||||
},
|
||||
"0xF977814e90dA44bFA03b6295A0616a897441aceC": {
|
||||
"name": "Binance", "category": "exchange", "label": "Binance Hot Wallet 8",
|
||||
"tags": "cex,hot_wallet,high_volume",
|
||||
},
|
||||
"0x28C6c06298d514Db089934071355E5743bf21d60": {
|
||||
"name": "Binance", "category": "exchange", "label": "Binance Hot Wallet 14",
|
||||
"tags": "cex,hot_wallet,high_volume",
|
||||
},
|
||||
"0x503828976D22510aad0201ac7EC88293211D23Da": {
|
||||
"name": "Coinbase", "category": "exchange", "label": "Coinbase Hot Wallet 1",
|
||||
"tags": "cex,hot_wallet",
|
||||
},
|
||||
"0xddfAbCdc4D8fFC17086Ea2cBcebe71504184443C": {
|
||||
"name": "Coinbase", "category": "exchange", "label": "Coinbase Hot Wallet 2",
|
||||
"tags": "cex,hot_wallet",
|
||||
},
|
||||
"0x267be1C1D684F78cb4F6a176C4911b741E4Ffdc0": {
|
||||
"name": "Kraken", "category": "exchange", "label": "Kraken Hot Wallet",
|
||||
"tags": "cex,hot_wallet",
|
||||
},
|
||||
"0x6cC5F688a315f3dC28A7781717a9A798a59fDA7b": {
|
||||
"name": "OKX", "category": "exchange", "label": "OKX Hot Wallet",
|
||||
"tags": "cex,hot_wallet",
|
||||
},
|
||||
# DeFi Protocols
|
||||
"0x7a250d5630b4cf139281983dce37532e7d5c9196": {
|
||||
"name": "Uniswap V2 Router", "category": "defi", "label": "Uniswap V2 Router",
|
||||
"tags": "dex,router,swap",
|
||||
},
|
||||
"0x7d2768de32b0b8013e039f31fbacf67128c9c3d8": {
|
||||
"name": "Aave V2 Lending Pool", "category": "defi", "label": "Aave V2 Lending Pool",
|
||||
"tags": "lending,liquidity,pools",
|
||||
},
|
||||
"0xdAC17F958D2ee523a2206206994597C13D831ec7": {
|
||||
"name": "Tether (USDT)", "category": "token", "label": "Tether USDT Contract",
|
||||
"tags": "stablecoin,erc20,high_volume",
|
||||
},
|
||||
"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48": {
|
||||
"name": "Circle (USDC)", "category": "token", "label": "USD Coin Contract",
|
||||
"tags": "stablecoin,erc20,high_volume",
|
||||
},
|
||||
# Known bad actors / scams
|
||||
"0x1CBd3b2770909D4e10f157cABC84C7264073C9Ec": {
|
||||
"name": "Lazarus Group", "category": "scam", "label": "Lazarus Group (Sanctioned)",
|
||||
"tags": "sanctioned,north_korea,exploiter",
|
||||
},
|
||||
"0x098B716B8Aaf21512996dC57EB0615e2383E2f96": {
|
||||
"name": "Ronin Bridge Exploiter", "category": "scam", "label": "Ronin Bridge Exploiter",
|
||||
"tags": "exploiter,hack,6.2m_eth",
|
||||
},
|
||||
}
|
||||
|
||||
# Normalize known entity addresses to lowercase
|
||||
KNOWN_ENTITIES = {k.lower(): v for k, v in KNOWN_ENTITIES.items()}
|
||||
|
||||
# Entity category weights for confidence scoring
|
||||
CATEGORY_WEIGHTS: dict[str, float] = {
|
||||
"exchange": 0.95,
|
||||
"defi": 0.90,
|
||||
"token": 0.90,
|
||||
"scam": 0.85,
|
||||
"bridge": 0.85,
|
||||
"fund": 0.80,
|
||||
"nft": 0.70,
|
||||
"whale": 0.60,
|
||||
"unknown": 0.20,
|
||||
}
|
||||
|
||||
|
||||
# ── Enums ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class EntityCategory(str, Enum):
|
||||
"""Top-level entity classification."""
|
||||
EXCHANGE = "exchange"
|
||||
DEFI = "defi"
|
||||
TOKEN = "token"
|
||||
BRIDGE = "bridge"
|
||||
FUND = "fund"
|
||||
WHALE = "whale"
|
||||
NFT = "nft"
|
||||
GAMING = "gaming"
|
||||
SCAM = "scam"
|
||||
SANCTIONED = "sanctioned"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
class ResolverSource(str, Enum):
|
||||
"""Source that provided the entity resolution."""
|
||||
ARKHAM_API = "arkham_api"
|
||||
LOCAL_DB = "local_db"
|
||||
ENTITY_REGISTRY = "entity_registry"
|
||||
ENTITY_LABELER = "entity_labeler"
|
||||
HEURISTIC = "heuristic"
|
||||
|
||||
|
||||
# ── Dataclasses ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class EntityLabel:
|
||||
"""A single label for an address from a specific source."""
|
||||
label: str
|
||||
source: ResolverSource
|
||||
confidence: float # 0.0 to 1.0
|
||||
category: str = "unknown"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResolvedEntity:
|
||||
"""Complete entity resolution result for a single address."""
|
||||
address: str
|
||||
chain: str
|
||||
entity_name: str
|
||||
category: EntityCategory
|
||||
confidence: float # 0-100
|
||||
labels: list[EntityLabel] = field(default_factory=list)
|
||||
tags: list[str] = field(default_factory=list)
|
||||
related_addresses: list[str] = field(default_factory=list)
|
||||
source: ResolverSource = ResolverSource.HEURISTIC
|
||||
arkham_data: dict[str, Any] | None = None
|
||||
resolved_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
||||
raw_signals: list[dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"address": self.address,
|
||||
"chain": self.chain,
|
||||
"entity_name": self.entity_name,
|
||||
"category": self.category.value if isinstance(self.category, EntityCategory) else self.category,
|
||||
"confidence": self.confidence,
|
||||
"labels": [asdict(l) for l in self.labels],
|
||||
"tags": self.tags,
|
||||
"source": self.source.value if isinstance(self.source, ResolverSource) else self.source,
|
||||
"resolved_at": self.resolved_at,
|
||||
"related_count": len(self.related_addresses),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class EntityReport:
|
||||
"""Full entity resolution report for one or more addresses."""
|
||||
query_addresses: list[str]
|
||||
chain: str
|
||||
entities: list[ResolvedEntity]
|
||||
summary: dict[str, Any]
|
||||
generated_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
||||
error: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"query_addresses": self.query_addresses,
|
||||
"chain": self.chain,
|
||||
"entities": [e.to_dict() for e in self.entities],
|
||||
"summary": self.summary,
|
||||
"generated_at": self.generated_at,
|
||||
"error": self.error,
|
||||
}
|
||||
|
||||
|
||||
# ── Entity Resolver ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ArkhamEntityResolver:
|
||||
"""
|
||||
Multi-source entity resolution engine.
|
||||
|
||||
Pipeline:
|
||||
1. Local DB lookup (fastest, always available)
|
||||
2. Entity Registry lookup (CEX wallets, known protocols)
|
||||
3. Entity Labeler lookup (advanced protocol detection)
|
||||
4. Arkham API lookup (requires API key, richest data)
|
||||
5. Heuristic inference (pattern matching, fallback)
|
||||
|
||||
Each source contributes confidence-weighted labels. Final confidence
|
||||
is aggregated from all sources.
|
||||
"""
|
||||
|
||||
def __init__(self, cache_ttl: int = 300):
|
||||
self._arkham: ArkhamClient | None = None
|
||||
self._cache_ttl = cache_ttl
|
||||
|
||||
async def _get_arkham(self) -> ArkhamClient | None:
|
||||
"""Lazy-init Arkham client if API key is available."""
|
||||
if self._arkham is None and ArkhamClient is not None and bool(ARKHAM_API_KEY):
|
||||
try:
|
||||
self._arkham = ArkhamClient(cache_ttl=self._cache_ttl)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to init Arkham client: {e}")
|
||||
return self._arkham
|
||||
|
||||
async def close(self):
|
||||
"""Close the underlying Arkham HTTP client."""
|
||||
if self._arkham is not None:
|
||||
await self._arkham.close()
|
||||
self._arkham = None
|
||||
|
||||
def _detect_chain(self, address: str) -> str:
|
||||
"""Heuristically detect chain from address format."""
|
||||
if ETH_ADDRESS_RE.match(address):
|
||||
return "ethereum"
|
||||
if SOL_ADDRESS_RE.match(address):
|
||||
return "solana"
|
||||
# Could expand with more chain patterns
|
||||
return "unknown"
|
||||
|
||||
def _lookup_local_db(self, address: str) -> ResolvedEntity | None:
|
||||
"""Step 1: Check hardcoded known entity database."""
|
||||
addr = address.lower()
|
||||
entry = KNOWN_ENTITIES.get(addr)
|
||||
if not entry:
|
||||
return None
|
||||
|
||||
labels = [
|
||||
EntityLabel(
|
||||
label=str(entry.get("label", entry["name"])),
|
||||
source=ResolverSource.LOCAL_DB,
|
||||
confidence=0.95,
|
||||
category=str(entry.get("category", "unknown")),
|
||||
)
|
||||
]
|
||||
|
||||
tags_raw = entry.get("tags", "")
|
||||
tags = [t.strip() for t in tags_raw.split(",") if t.strip()]
|
||||
|
||||
category = EntityCategory(entry["category"]) if entry["category"] in EntityCategory._value2member_map_ else EntityCategory.UNKNOWN # type: ignore
|
||||
|
||||
return ResolvedEntity(
|
||||
address=address,
|
||||
chain=self._detect_chain(address),
|
||||
entity_name=str(entry["name"]),
|
||||
category=category,
|
||||
confidence=95.0,
|
||||
labels=labels,
|
||||
tags=tags,
|
||||
source=ResolverSource.LOCAL_DB,
|
||||
)
|
||||
|
||||
def _lookup_entity_registry(self, address: str) -> ResolvedEntity | None:
|
||||
"""Step 2: Check KNOWN_CEX_WALLETS from entity_registry."""
|
||||
addr = address.lower()
|
||||
labels: list[EntityLabel] = []
|
||||
|
||||
for exchange_name, wallets in KNOWN_CEX_WALLETS.items():
|
||||
for w in wallets:
|
||||
if w.lower() == addr:
|
||||
labels.append(EntityLabel(
|
||||
label=f"{exchange_name.capitalize()} Wallet",
|
||||
source=ResolverSource.ENTITY_REGISTRY,
|
||||
confidence=0.90,
|
||||
category="exchange",
|
||||
))
|
||||
|
||||
if not labels:
|
||||
return None
|
||||
|
||||
return ResolvedEntity(
|
||||
address=address,
|
||||
chain=self._detect_chain(address),
|
||||
entity_name=labels[0].label.split(" Wallet")[0],
|
||||
category=EntityCategory.EXCHANGE,
|
||||
confidence=90.0,
|
||||
labels=labels,
|
||||
tags=["cex", "exchange"],
|
||||
source=ResolverSource.ENTITY_REGISTRY,
|
||||
)
|
||||
|
||||
def _lookup_entity_labeler(self, address: str) -> ResolvedEntity | None:
|
||||
"""Step 3: Check EXCHANGES and PROTOCOLS from entity_labeler."""
|
||||
addr = address.lower()
|
||||
labels: list[EntityLabel] = []
|
||||
|
||||
# Check EXCHANGES dict
|
||||
for name, addresses in EXCHANGES.items():
|
||||
for a in addresses:
|
||||
if isinstance(a, str) and a.lower() == addr:
|
||||
labels.append(EntityLabel(
|
||||
label=f"{name.capitalize()} (Labeler)",
|
||||
source=ResolverSource.ENTITY_LABELER,
|
||||
confidence=0.85,
|
||||
category="exchange",
|
||||
))
|
||||
|
||||
# Check PROTOCOLS dict
|
||||
for name, info in PROTOCOLS.items():
|
||||
if isinstance(info, dict):
|
||||
proto_addr = info.get("address", "")
|
||||
if isinstance(proto_addr, str) and proto_addr.lower() == addr:
|
||||
labels.append(EntityLabel(
|
||||
label=f"{name.capitalize()} (Labeler)",
|
||||
source=ResolverSource.ENTITY_LABELER,
|
||||
confidence=0.90,
|
||||
category="defi",
|
||||
))
|
||||
|
||||
if not labels:
|
||||
return None
|
||||
|
||||
top = labels[0]
|
||||
cat = EntityCategory.EXCHANGE if top.category == "exchange" else EntityCategory.DEFI
|
||||
return ResolvedEntity(
|
||||
address=address,
|
||||
chain=self._detect_chain(address),
|
||||
entity_name=top.label.split(" (Labeler")[0],
|
||||
category=cat,
|
||||
confidence=85.0,
|
||||
labels=labels,
|
||||
tags=[top.category],
|
||||
source=ResolverSource.ENTITY_LABELER,
|
||||
)
|
||||
|
||||
async def _lookup_arkham_api(self, address: str) -> ResolvedEntity | None:
|
||||
"""Step 4: Query Arkham Intelligence API for entity data."""
|
||||
arkham = await self._get_arkham()
|
||||
if not arkham:
|
||||
return None
|
||||
|
||||
try:
|
||||
data = await arkham.get_entity(address)
|
||||
if not data or "error" in data:
|
||||
return None
|
||||
|
||||
entity_name = data.get("name", data.get("entity", ""))
|
||||
if not entity_name:
|
||||
return None
|
||||
|
||||
category_raw = data.get("category", "unknown")
|
||||
category = EntityCategory.UNKNOWN
|
||||
if category_raw in EntityCategory._value2member_map_:
|
||||
category = EntityCategory(category_raw)
|
||||
|
||||
labels = [
|
||||
EntityLabel(
|
||||
label=data.get("label", entity_name),
|
||||
source=ResolverSource.ARKHAM_API,
|
||||
confidence=min(1.0, float(data.get("confidence", 0.85))),
|
||||
category=category_raw,
|
||||
)
|
||||
]
|
||||
|
||||
return ResolvedEntity(
|
||||
address=address,
|
||||
chain=self._detect_chain(address),
|
||||
entity_name=entity_name,
|
||||
category=category,
|
||||
confidence=min(100.0, float(data.get("confidence", 85)) * 100),
|
||||
labels=labels,
|
||||
tags=data.get("tags", []),
|
||||
source=ResolverSource.ARKHAM_API,
|
||||
arkham_data=data,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Arkham API lookup failed for {address}: {e}")
|
||||
return None
|
||||
|
||||
def _heuristic_inference(self, address: str) -> ResolvedEntity | None:
|
||||
"""Step 5: Fallback heuristic — pattern-based entity inference."""
|
||||
addr = address.lower()
|
||||
signals: list[dict[str, Any]] = []
|
||||
labels: list[EntityLabel] = []
|
||||
tags: list[str] = []
|
||||
|
||||
# Check address patterns
|
||||
if addr.endswith("dead") or addr.endswith("0000"):
|
||||
signals.append({"type": "burn_pattern", "detail": "Address ends with dead/0000", "weight": 0.3})
|
||||
labels.append(EntityLabel(
|
||||
label="Likely Burn Address",
|
||||
source=ResolverSource.HEURISTIC,
|
||||
confidence=0.6,
|
||||
category="burn",
|
||||
))
|
||||
tags.append("burn")
|
||||
|
||||
if addr[:6] == "0x0000" and len(addr) == 42:
|
||||
signals.append({"type": "zero_prefix", "detail": "Heavy zero-prefixed address", "weight": 0.2})
|
||||
|
||||
# Check if it looks like a contract (common patterns)
|
||||
# Most contracts start with non-random patterns
|
||||
if not labels:
|
||||
return None
|
||||
|
||||
confidence = max(0.0, min(100.0, 20.0 + sum(s.get("weight", 0) for s in signals) * 100))
|
||||
return ResolvedEntity(
|
||||
address=address,
|
||||
chain=self._detect_chain(address),
|
||||
entity_name="Unknown (Heuristic Match)",
|
||||
category=EntityCategory.UNKNOWN,
|
||||
confidence=round(confidence, 1),
|
||||
labels=labels,
|
||||
tags=tags,
|
||||
source=ResolverSource.HEURISTIC,
|
||||
raw_signals=signals,
|
||||
)
|
||||
|
||||
async def resolve(
|
||||
self,
|
||||
address: str,
|
||||
chain: str | None = None,
|
||||
use_arkham: bool = True,
|
||||
) -> ResolvedEntity:
|
||||
"""
|
||||
Resolve a single blockchain address to an entity.
|
||||
|
||||
Pipeline: Local DB → Entity Registry → Entity Labeler → Arkham API → Heuristic
|
||||
|
||||
Args:
|
||||
address: Blockchain address to resolve.
|
||||
chain: Chain override (auto-detected if None).
|
||||
use_arkham: Whether to attempt Arkham API lookup.
|
||||
|
||||
Returns:
|
||||
ResolvedEntity with best available data.
|
||||
"""
|
||||
if not chain:
|
||||
chain = self._detect_chain(address)
|
||||
|
||||
# Validate address format
|
||||
input_entity = address.strip()
|
||||
if not (ETH_ADDRESS_RE.match(input_entity) or SOL_ADDRESS_RE.match(input_entity)):
|
||||
return ResolvedEntity(
|
||||
address=address,
|
||||
chain=chain,
|
||||
entity_name="Invalid Address",
|
||||
category=EntityCategory.UNKNOWN,
|
||||
confidence=0.0,
|
||||
labels=[],
|
||||
tags=["invalid"],
|
||||
source=ResolverSource.HEURISTIC,
|
||||
)
|
||||
|
||||
# Pipeline — stop at first positive match
|
||||
result = self._lookup_local_db(input_entity)
|
||||
if result:
|
||||
result.chain = chain or result.chain
|
||||
return result
|
||||
|
||||
result = self._lookup_entity_registry(input_entity)
|
||||
if result:
|
||||
result.chain = chain or result.chain
|
||||
return result
|
||||
|
||||
result = self._lookup_entity_labeler(input_entity)
|
||||
if result:
|
||||
result.chain = chain or result.chain
|
||||
return result
|
||||
|
||||
if use_arkham:
|
||||
result = await self._lookup_arkham_api(input_entity)
|
||||
if result:
|
||||
result.chain = chain or result.chain
|
||||
return result
|
||||
|
||||
result = self._heuristic_inference(input_entity)
|
||||
if result:
|
||||
result.chain = chain or result.chain
|
||||
return result
|
||||
|
||||
# No match at all
|
||||
return ResolvedEntity(
|
||||
address=address,
|
||||
chain=chain,
|
||||
entity_name="Unknown",
|
||||
category=EntityCategory.UNKNOWN,
|
||||
confidence=0.0,
|
||||
labels=[],
|
||||
tags=[],
|
||||
source=ResolverSource.HEURISTIC,
|
||||
)
|
||||
|
||||
async def resolve_batch(
|
||||
self,
|
||||
addresses: list[str],
|
||||
chain: str | None = None,
|
||||
use_arkham: bool = True,
|
||||
) -> list[ResolvedEntity]:
|
||||
"""
|
||||
Resolve multiple addresses in sequence.
|
||||
|
||||
Args:
|
||||
addresses: List of blockchain addresses.
|
||||
chain: Chain override for all addresses.
|
||||
use_arkham: Whether to attempt Arkham API lookup.
|
||||
|
||||
Returns:
|
||||
List of ResolvedEntity objects.
|
||||
"""
|
||||
results: list[ResolvedEntity] = []
|
||||
for addr in addresses:
|
||||
result = await self.resolve(addr, chain=chain, use_arkham=use_arkham)
|
||||
results.append(result)
|
||||
return results
|
||||
|
||||
|
||||
# ── Aggregation & Reporting ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def analyze_addresses(
|
||||
addresses: list[str],
|
||||
chain: str | None = None,
|
||||
use_arkham: bool = True,
|
||||
) -> EntityReport:
|
||||
"""
|
||||
Main entry point: resolve addresses and produce a structured report.
|
||||
|
||||
Args:
|
||||
addresses: One or more blockchain addresses to resolve.
|
||||
chain: Chain override (auto-detected if None).
|
||||
use_arkham: Whether to query Arkham API.
|
||||
|
||||
Returns:
|
||||
EntityReport with resolved entities and summary statistics.
|
||||
"""
|
||||
if not addresses:
|
||||
return EntityReport(
|
||||
query_addresses=[],
|
||||
chain=chain or "unknown",
|
||||
entities=[],
|
||||
summary={"total": 0, "known": 0, "unknown": 0, "avg_confidence": 0.0},
|
||||
error="No addresses provided",
|
||||
)
|
||||
|
||||
resolver = ArkhamEntityResolver()
|
||||
try:
|
||||
if chain is None:
|
||||
chain = resolver._detect_chain(addresses[0])
|
||||
|
||||
entities = await resolver.resolve_batch(addresses, chain=chain, use_arkham=use_arkham)
|
||||
|
||||
known = [e for e in entities if e.confidence >= 50.0]
|
||||
unknown = [e for e in entities if e.confidence < 50.0]
|
||||
categories: dict[str, int] = {}
|
||||
for e in entities:
|
||||
cat = e.category.value if isinstance(e.category, EntityCategory) else str(e.category)
|
||||
categories[cat] = categories.get(cat, 0) + 1
|
||||
|
||||
avg_conf = sum(e.confidence for e in entities) / len(entities) if entities else 0.0
|
||||
|
||||
summary = {
|
||||
"total": len(entities),
|
||||
"known": len(known),
|
||||
"unknown": len(unknown),
|
||||
"avg_confidence": round(avg_conf, 1),
|
||||
"categories": categories,
|
||||
"sources_used": list({e.source.value if isinstance(e.source, ResolverSource) else e.source for e in entities}),
|
||||
}
|
||||
|
||||
return EntityReport(
|
||||
query_addresses=addresses,
|
||||
chain=chain,
|
||||
entities=entities,
|
||||
summary=summary,
|
||||
)
|
||||
finally:
|
||||
await resolver.close()
|
||||
|
||||
|
||||
def format_report(report: EntityReport) -> str:
|
||||
"""
|
||||
Format an EntityReport as a human-readable string.
|
||||
|
||||
Args:
|
||||
report: The report to format.
|
||||
|
||||
Returns:
|
||||
Formatted string suitable for CLI or API response.
|
||||
"""
|
||||
lines = [
|
||||
"┌────────────────────────────────────────────────────────────┐",
|
||||
"│ Arkham Entity Report │",
|
||||
"├────────────────────────────────────────────────────────────┤",
|
||||
]
|
||||
|
||||
if report.error:
|
||||
lines.append(f"│ ERROR: {report.error}")
|
||||
lines.append("└────────────────────────────────────────────────────────────┘")
|
||||
return "\n".join(lines)
|
||||
|
||||
lines.append(f"│ Chain: {report.chain}")
|
||||
lines.append(f"│ Addresses: {len(report.query_addresses)}")
|
||||
lines.append(f"│ Generated: {report.generated_at}")
|
||||
lines.append("├────────────────────────────────────────────────────────────┤")
|
||||
|
||||
for i, entity in enumerate(report.entities):
|
||||
lines.append(f"│ [{i + 1}] {entity.address[:42]}")
|
||||
lines.append(f"│ Entity: {entity.entity_name}")
|
||||
cat_str = entity.category.value if isinstance(entity.category, EntityCategory) else str(entity.category)
|
||||
lines.append(f"│ Category: {cat_str}")
|
||||
lines.append(f"│ Confidence: {entity.confidence:.0f}/100")
|
||||
src_str = entity.source.value if isinstance(entity.source, ResolverSource) else str(entity.source)
|
||||
lines.append(f"│ Source: {src_str}")
|
||||
if entity.tags:
|
||||
lines.append(f"│ Tags: {', '.join(entity.tags[:5])}")
|
||||
if entity.labels:
|
||||
for label in entity.labels:
|
||||
lines.append(f"│ Label: [{label.source.value}] {label.label} (conf: {label.confidence:.0%})")
|
||||
|
||||
if entity.related_addresses:
|
||||
lines.append(f"│ Related: {len(entity.related_addresses)} addresses")
|
||||
if i < len(report.entities) - 1:
|
||||
lines.append("│" + "─" * 60 + "│")
|
||||
|
||||
# Summary footer
|
||||
lines.append("├────────────────────────────────────────────────────────────┤")
|
||||
s = report.summary
|
||||
lines.append(f"│ Summary: {s.get('total', 0)} total, {s.get('known', 0)} known, {s.get('unknown', 0)} unknown")
|
||||
lines.append(f"│ Avg Confidence: {s.get('avg_confidence', 0):.1f}/100")
|
||||
cats = s.get("categories", {})
|
||||
if cats:
|
||||
cat_str = ", ".join(f"{k}={v}" for k, v in sorted(cats.items()))
|
||||
lines.append(f"│ Categories: {cat_str}")
|
||||
lines.append("└────────────────────────────────────────────────────────────┘")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ── CLI Entry Point ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def main():
|
||||
"""CLI entry point for entity resolution."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Arkham Entity Resolver — map addresses to real-world entities",
|
||||
)
|
||||
parser.add_argument("addresses", nargs="+", help="Blockchain address(es) to resolve")
|
||||
parser.add_argument("--chain", "-c", default=None, help="Chain override (auto-detect if omitted)")
|
||||
parser.add_argument("--no-arkham", action="store_true", help="Skip Arkham API lookup")
|
||||
parser.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
args = parser.parse_args()
|
||||
|
||||
report = await analyze_addresses(
|
||||
addresses=args.addresses,
|
||||
chain=args.chain,
|
||||
use_arkham=not args.no_arkham,
|
||||
)
|
||||
|
||||
if args.json:
|
||||
import json
|
||||
print(json.dumps(report.to_dict(), indent=2))
|
||||
else:
|
||||
print(format_report(report))
|
||||
|
||||
return 0 if not report.error else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit(asyncio.run(main()))
|
||||
|
|
@ -1,744 +0,0 @@
|
|||
"""
|
||||
Fake Audit Report / Security Review Validator
|
||||
=============================================
|
||||
Validates security audit report claims made by tokens/projects.
|
||||
Detects forged audit reports from Certik, Hacken, SlowMist, and
|
||||
other major security firms used to promote scam tokens.
|
||||
|
||||
Signals detected:
|
||||
- Forged report IDs/hashes that don't match auditor databases
|
||||
- Metadata anomalies (dates, auditor name misspellings, logo mismatches)
|
||||
- Template reuse — similar report text across unrelated projects
|
||||
- URL/domain analysis for fake audit hosting pages
|
||||
- Claim-vs-reality discrepancy (audit claims "safe" but contract is malicious)
|
||||
- Report timeline anomalies (audit after deploy, future dates)
|
||||
- Standard language detection (generic copy-paste report text)
|
||||
- Invite-only audit scams (non-existent "Certik Priority" programs)
|
||||
- Verified badge farming via fake audit blogs
|
||||
- Known fake auditor wallet addresses deploying tokens
|
||||
- Cross-referencing with public auditor verified lists
|
||||
- Dashboard embed scams (iframe fake audit dashboards)
|
||||
|
||||
Tier : Premium ($0.08)
|
||||
Price : 80000 atoms
|
||||
Endpoint: POST /api/v1/x402-tools/audit_validate
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Constants ──────────────────────────────────────────────────────
|
||||
|
||||
KNOWN_AUDITORS: dict[str, dict[str, Any]] = {
|
||||
"certik": {
|
||||
"name": "Certik",
|
||||
"url": "https://www.certik.com",
|
||||
"verify_url": "https://www.certik.com/projects/{}",
|
||||
"known_domains": ["certik.com", "certik.org", "certik.io", "skynet.certik.com"],
|
||||
},
|
||||
"hacken": {
|
||||
"name": "Hacken",
|
||||
"url": "https://hacken.io",
|
||||
"verify_url": "https://hacken.io/audits/#{}",
|
||||
"known_domains": ["hacken.io", "hacken.com", "proofofhacken.io"],
|
||||
},
|
||||
"slowmist": {
|
||||
"name": "SlowMist",
|
||||
"url": "https://www.slowmist.com",
|
||||
"verify_url": "https://www.slowmist.com/en/audit-{}.html",
|
||||
"known_domains": ["slowmist.com", "slowmist.io"],
|
||||
},
|
||||
"trailofbits": {
|
||||
"name": "Trail of Bits",
|
||||
"url": "https://www.trailofbits.com",
|
||||
"verify_url": "https://blog.trailofbits.com/?s={}",
|
||||
"known_domains": ["trailofbits.com"],
|
||||
},
|
||||
"consensys": {
|
||||
"name": "ConsenSys Diligence",
|
||||
"url": "https://consensys.io/diligence",
|
||||
"verify_url": "https://consensys.io/diligence/audits/{}",
|
||||
"known_domains": ["consensys.io", "diligence.consensys.io"],
|
||||
},
|
||||
"openzeppelin": {
|
||||
"name": "OpenZeppelin",
|
||||
"url": "https://www.openzeppelin.com/security-audits",
|
||||
"verify_url": "https://blog.openzeppelin.com/{}",
|
||||
"known_domains": ["openzeppelin.com", "docs.openzeppelin.com"],
|
||||
},
|
||||
"quantstamp": {
|
||||
"name": "Quantstamp",
|
||||
"url": "https://quantstamp.com",
|
||||
"verify_url": "https://quantstamp.com/audit/{}",
|
||||
"known_domains": ["quantstamp.com"],
|
||||
},
|
||||
"peckshield": {
|
||||
"name": "PeckShield",
|
||||
"url": "https://peckshield.com",
|
||||
"verify_url": "https://peckshield.com/audit/{}",
|
||||
"known_domains": ["peckshield.com", "peckshield.io"],
|
||||
},
|
||||
"salus": {
|
||||
"name": "Salus Security",
|
||||
"url": "https://salusec.io",
|
||||
"verify_url": "https://salusec.io/audit-reports/{}",
|
||||
"known_domains": ["salusec.io", "salus.xyz"],
|
||||
},
|
||||
"verichains": {
|
||||
"name": "Verichains",
|
||||
"url": "https://www.verichains.io",
|
||||
"verify_url": "https://www.verichains.io/audits/{}",
|
||||
"known_domains": ["verichains.io"],
|
||||
},
|
||||
"solidproof": {
|
||||
"name": "SolidProof",
|
||||
"url": "https://solidproof.io",
|
||||
"verify_url": "https://github.com/solidproof/projects/{}",
|
||||
"known_domains": ["solidproof.io"],
|
||||
},
|
||||
"rugdoc": {
|
||||
"name": "RugDoc",
|
||||
"url": "https://rugdoc.io",
|
||||
"verify_url": "https://rugdoc.io/audit/{}",
|
||||
"known_domains": ["rugdoc.io"],
|
||||
},
|
||||
"goplus": {
|
||||
"name": "GoPlus Security",
|
||||
"url": "https://gopluslabs.io",
|
||||
"verify_url": "https://gopluslabs.io/audits/{}",
|
||||
"known_domains": ["gopluslabs.io"],
|
||||
},
|
||||
}
|
||||
|
||||
# Fake auditor names commonly seen in scam tokens
|
||||
FAKE_AUDITOR_PATTERNS: list[str] = [
|
||||
"certik", "certick", "certi.guide", "certik.pro", "certik-verify",
|
||||
"hacken", "hackenpro", "hacken-verify", "hackenaudit",
|
||||
"slowmist", "slow-mist", "slowmistpro", "slowmist.io",
|
||||
"solidproof", "solid-proof", "solid.proof",
|
||||
"audited", "secured by", "verified by",
|
||||
"pangolin audit", "mythx certified", "mythril scanned",
|
||||
"goplus", "gopluslabs",
|
||||
]
|
||||
|
||||
# Generic / template report sentences that suggest copy-paste audit
|
||||
TEMPLATE_PHRASES: list[str] = [
|
||||
"we have thoroughly reviewed the smart contract",
|
||||
"no critical vulnerabilities were found",
|
||||
"the contract appears to be secure",
|
||||
"our team of experienced auditors",
|
||||
"this report is provided as is",
|
||||
"the audit does not guarantee",
|
||||
"all findings have been resolved",
|
||||
"the code follows best practices",
|
||||
"we found no security issues",
|
||||
"the project has passed our security review",
|
||||
"this confirms the safety of the contract",
|
||||
"we have completed the security audit",
|
||||
"the token contract has been audited",
|
||||
"no centralization risks found",
|
||||
"liquidity is locked permanently",
|
||||
"ownership has been renounced",
|
||||
]
|
||||
|
||||
SUSPICIOUS_TLD_PATTERNS: list[str] = [".xyz", ".top", ".loan", ".click", ".work", ".gq", ".tk", ".ml", ".cf"]
|
||||
|
||||
# Maximum allowed difference between deploy and audit claim dates (days)
|
||||
MAX_AUDIT_ANTEDATE_DAYS = 7 # audit before deploy is suspicious
|
||||
MAX_AUDIT_POSTDATE_DAYS = 180 # audit more than 6 months after deploy unlikely
|
||||
|
||||
SENTENCE_END_RE = re.compile(r"[.!?]\s+")
|
||||
|
||||
# ── Risk Levels ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class AuditRisk(Enum):
|
||||
CRITICAL = "critical"
|
||||
HIGH = "high"
|
||||
MEDIUM = "medium"
|
||||
LOW = "low"
|
||||
SAFE = "safe"
|
||||
|
||||
|
||||
class SignalType(Enum):
|
||||
FORGED_REPORT = "forged_report"
|
||||
METADATA_ANOMALY = "metadata_anomaly"
|
||||
TEMPLATE_REUSE = "template_reuse"
|
||||
SUSPICIOUS_DOMAIN = "suspicious_domain"
|
||||
TIMELINE_ANOMALY = "timeline_anomaly"
|
||||
GENERIC_LANGUAGE = "generic_language"
|
||||
FAKE_AUDITOR_NAME = "fake_auditor_name"
|
||||
CLAIM_DISCREPANCY = "claim_discrepancy"
|
||||
|
||||
|
||||
# ── Data Models ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuditClaim:
|
||||
"""A parsed audit claim from token metadata or website."""
|
||||
auditor_name: str
|
||||
report_url: str | None = None
|
||||
report_id: str | None = None
|
||||
report_date: str | None = None
|
||||
verified_badge_url: str | None = None
|
||||
report_text: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuditSignal:
|
||||
"""A single detection signal."""
|
||||
signal_type: SignalType
|
||||
severity: AuditRisk
|
||||
description: str
|
||||
detail: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuditValidationResult:
|
||||
"""Complete validation result."""
|
||||
token_address: str
|
||||
chain: str
|
||||
risk_level: AuditRisk
|
||||
risk_score: float # 0.0 (safe) to 1.0 (critical)
|
||||
signals: list[AuditSignal] = field(default_factory=list)
|
||||
matched_auditor: str | None = None
|
||||
verified_on_chain: bool = False
|
||||
deploy_timestamp: int | None = None
|
||||
report_timestamp: int | None = None
|
||||
analysis_time: float = 0.0
|
||||
error: str | None = None
|
||||
|
||||
|
||||
# ── Validation Engine ────────────────────────────────────────────
|
||||
|
||||
|
||||
class AuditReportValidator:
|
||||
"""
|
||||
Validates audit report claims for crypto tokens.
|
||||
|
||||
Analyzes:
|
||||
- Report metadata (dates, auditor names, IDs)
|
||||
- URL/domain hosting of audit reports
|
||||
- Report text for template/proof-of-fraud signals
|
||||
- Timeline consistency (deploy vs audit dates)
|
||||
- Cross-referencing with known auditor databases
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._known_auditors_lower = {k.lower(): v for k, v in KNOWN_AUDITORS.items()}
|
||||
|
||||
# ── Public API ──────────────────────────────────────────────
|
||||
|
||||
async def validate(
|
||||
self,
|
||||
token_address: str,
|
||||
chain: str = "ethereum",
|
||||
claims: list[dict[str, Any]] | None = None,
|
||||
deploy_timestamp: int | None = None,
|
||||
) -> AuditValidationResult:
|
||||
"""
|
||||
Validate audit claims for a token.
|
||||
|
||||
Args:
|
||||
token_address: The token contract address.
|
||||
chain: Blockchain name (ethereum, bsc, solana, etc.).
|
||||
claims: List of audit claim dicts. Each dict may contain:
|
||||
auditor_name, report_url, report_id, report_date,
|
||||
verified_badge_url, report_text.
|
||||
deploy_timestamp: Unix timestamp of token deployment (optional).
|
||||
|
||||
Returns:
|
||||
AuditValidationResult with findings.
|
||||
"""
|
||||
start = time.time()
|
||||
result = AuditValidationResult(
|
||||
token_address=token_address,
|
||||
chain=chain,
|
||||
risk_level=AuditRisk.SAFE,
|
||||
risk_score=0.0,
|
||||
deploy_timestamp=deploy_timestamp,
|
||||
)
|
||||
|
||||
if not claims:
|
||||
# No claims means no audit to validate
|
||||
result.analysis_time = time.time() - start
|
||||
return result
|
||||
|
||||
parsed_claims = [AuditClaim(**c) if isinstance(c, dict) else c for c in claims]
|
||||
|
||||
for claim in parsed_claims:
|
||||
signals = await self._validate_claim(claim, deploy_timestamp)
|
||||
result.signals.extend(signals)
|
||||
|
||||
# Score aggregation
|
||||
await self._score_result(result)
|
||||
|
||||
result.analysis_time = time.time() - start
|
||||
return result
|
||||
|
||||
# ── Validation Methods ──────────────────────────────────────
|
||||
|
||||
async def _validate_claim(
|
||||
self,
|
||||
claim: AuditClaim,
|
||||
deploy_ts: int | None,
|
||||
) -> list[AuditSignal]:
|
||||
"""Run all validation checks on a single claim."""
|
||||
signals: list[AuditSignal] = []
|
||||
|
||||
# Signal 1: Fake auditor name detection
|
||||
signals.extend(self._check_auditor_name(claim.auditor_name))
|
||||
|
||||
# Signal 2: Suspicious domain / URL analysis
|
||||
if claim.report_url:
|
||||
signals.extend(self._check_report_url(claim.report_url))
|
||||
|
||||
# Signal 3: Badge URL analysis
|
||||
if claim.verified_badge_url:
|
||||
signals.extend(self._check_badge_url(claim.verified_badge_url))
|
||||
|
||||
# Signal 4: Report ID validation
|
||||
if claim.report_id:
|
||||
signals.extend(self._check_report_id(claim.report_id, claim.auditor_name))
|
||||
|
||||
# Signal 5: Timeline analysis (deploy vs audit date)
|
||||
if claim.report_date:
|
||||
signals.extend(self._check_timeline(claim.report_date, deploy_ts))
|
||||
|
||||
# Signal 6: Template / generic language detection
|
||||
if claim.report_text:
|
||||
signals.extend(self._check_report_text(claim.report_text))
|
||||
|
||||
return signals
|
||||
|
||||
def _check_auditor_name(self, auditor_name: str) -> list[AuditSignal]:
|
||||
"""Signal 1: Detect fake/misspelled auditor names."""
|
||||
signals: list[AuditSignal] = []
|
||||
name_lower = auditor_name.lower().strip()
|
||||
|
||||
# Check for misspellings of known auditors
|
||||
for known_key, known_info in self._known_auditors_lower.items():
|
||||
known_name_lower = known_info["name"].lower()
|
||||
# Levenshtein-like: check character overlap and common misspellings
|
||||
if self._is_fake_auditor_misspelling(name_lower, known_key, known_name_lower):
|
||||
signals.append(AuditSignal(
|
||||
signal_type=SignalType.FAKE_AUDITOR_NAME,
|
||||
severity=AuditRisk.HIGH,
|
||||
description=f"Suspicious auditor name resembling '{known_info['name']}'",
|
||||
detail=f"Claimed: '{auditor_name}' — possible impersonation of {known_info['name']}",
|
||||
))
|
||||
|
||||
# Check for known scam patterns in auditor name
|
||||
for pattern in FAKE_AUDITOR_PATTERNS:
|
||||
if pattern in name_lower and name_lower != pattern:
|
||||
signals.append(AuditSignal(
|
||||
signal_type=SignalType.FAKE_AUDITOR_NAME,
|
||||
severity=AuditRisk.MEDIUM,
|
||||
description=f"Auditor name contains known scam pattern keyword",
|
||||
detail=f"Pattern matched: '{pattern}' in '{auditor_name}'",
|
||||
))
|
||||
break
|
||||
|
||||
# Check for generic sounding auditor names
|
||||
generic_indicators = ["audit", "security", "verified", "certified", "safe", "guard", "labs", "consulting"]
|
||||
generic_count = sum(1 for g in generic_indicators if g in name_lower)
|
||||
if generic_count >= 2 and not any(k in name_lower for k in self._known_auditors_lower):
|
||||
signals.append(AuditSignal(
|
||||
signal_type=SignalType.FAKE_AUDITOR_NAME,
|
||||
severity=AuditRisk.MEDIUM,
|
||||
description="Generic-sounding auditor name not matching known firms",
|
||||
detail=f"Name '{auditor_name}' sounds generic with {generic_count} generic indicators",
|
||||
))
|
||||
|
||||
return signals
|
||||
|
||||
def _normalize_url(self, url: str) -> str:
|
||||
"""Normalize URL: lowercase scheme+host, strip fragments/trailing junk."""
|
||||
try:
|
||||
parsed = urlparse(url.lower().strip())
|
||||
# Reconstruct without fragment
|
||||
clean = f"{parsed.scheme}://{parsed.netloc}{parsed.path}"
|
||||
if parsed.query:
|
||||
clean += f"?{parsed.query}"
|
||||
return clean
|
||||
except Exception:
|
||||
return url.lower().strip()
|
||||
|
||||
def _extract_domain(self, url: str) -> str:
|
||||
"""Extract the full hostname from a URL."""
|
||||
try:
|
||||
parsed = urlparse(url.lower().strip())
|
||||
return parsed.netloc or url.split("://")[-1].split("/")[0].lower()
|
||||
except Exception:
|
||||
return url.lower().strip()
|
||||
|
||||
def _check_report_url(self, url: str) -> list[AuditSignal]:
|
||||
"""Signal 2: Analyze report hosting URL for suspicion."""
|
||||
signals: list[AuditSignal] = []
|
||||
normalized = self._normalize_url(url)
|
||||
url_lower = normalized
|
||||
|
||||
# Check if URL uses a suspicious TLD
|
||||
domain = self._extract_domain(url)
|
||||
for tld in SUSPICIOUS_TLD_PATTERNS:
|
||||
if domain.endswith(tld):
|
||||
signals.append(AuditSignal(
|
||||
signal_type=SignalType.SUSPICIOUS_DOMAIN,
|
||||
severity=AuditRisk.MEDIUM,
|
||||
description=f"Audit report hosted on suspicious TLD '{tld}'",
|
||||
detail=f"Domain: {domain}",
|
||||
))
|
||||
|
||||
# Check if URL impersonates a known auditor domain
|
||||
# Only flag if NONE of the auditor's official domains match
|
||||
for known_key, known_info in self._known_auditors_lower.items():
|
||||
official_domains = known_info.get("known_domains", [])
|
||||
# Check if any official domain matches the URL domain
|
||||
matches_official = any(
|
||||
domain == od or domain.endswith("." + od)
|
||||
for od in official_domains
|
||||
)
|
||||
if not matches_official:
|
||||
# Check if the URL contains the base auditor name
|
||||
base_names = {d.split(".")[-2] if len(d.split(".")) > 1 else d for d in official_domains}
|
||||
for base_name in base_names:
|
||||
if base_name in domain and len(base_name) >= 4:
|
||||
signals.append(AuditSignal(
|
||||
signal_type=SignalType.SUSPICIOUS_DOMAIN,
|
||||
severity=AuditRisk.HIGH,
|
||||
description=f"URL possibly impersonates {known_info['name']} domain",
|
||||
detail=f"Domain '{domain}' contains '{base_name}' but doesn't match official domains",
|
||||
))
|
||||
|
||||
# Check for PDF/image hosting on free file services (should be on official auditor site)
|
||||
free_hosting_patterns = [
|
||||
"drive.google.com", "docs.google.com", "dropbox.com",
|
||||
"github.io", "githubusercontent.com", "ipfs.io",
|
||||
"cdn.discord", "telegra.ph", "imgur.com",
|
||||
"docsend.com", "scribd.com", "docdroid.net",
|
||||
]
|
||||
for pattern in free_hosting_patterns:
|
||||
if pattern in url_lower:
|
||||
signals.append(AuditSignal(
|
||||
signal_type=SignalType.SUSPICIOUS_DOMAIN,
|
||||
severity=AuditRisk.MEDIUM,
|
||||
description=f"Audit report hosted on free file service ({pattern})",
|
||||
detail=f"Legitimate audits are hosted on the auditor's official domain",
|
||||
))
|
||||
|
||||
# Check for suspicious subdomain patterns
|
||||
suspicious_subdomains = ["verify", "audit", "secure", "check", "certify", "validate"]
|
||||
domain_prefix = domain.split(".")[0].lower()
|
||||
if domain_prefix in suspicious_subdomains:
|
||||
signals.append(AuditSignal(
|
||||
signal_type=SignalType.SUSPICIOUS_DOMAIN,
|
||||
severity=AuditRisk.LOW,
|
||||
description=f"URL uses suspicious subdomain '{domain_prefix}'",
|
||||
detail=f"Domain: {domain}",
|
||||
))
|
||||
|
||||
return signals
|
||||
|
||||
def _check_badge_url(self, url: str) -> list[AuditSignal]:
|
||||
"""Signal 3: Validate 'verified' badge embed URLs."""
|
||||
signals: list[AuditSignal] = []
|
||||
url_lower = url.lower()
|
||||
|
||||
# Legitimate badge URLs should come from the auditor's domain
|
||||
found_legitimate = False
|
||||
for known_key, known_info in self._known_auditors_lower.items():
|
||||
for known_domain in known_info.get("known_domains", []):
|
||||
domain_part = known_domain.split("://")[-1].split("/")[0].lower()
|
||||
if domain_part in url_lower:
|
||||
found_legitimate = True
|
||||
break
|
||||
|
||||
if not found_legitimate:
|
||||
# Check if it's an iframe embed or data URL (common in fake badges)
|
||||
if url_lower.startswith("data:") or "iframe" in url_lower or "embed" in url_lower:
|
||||
signals.append(AuditSignal(
|
||||
signal_type=SignalType.FORGED_REPORT,
|
||||
severity=AuditRisk.HIGH,
|
||||
description="Verified badge uses data URI or iframe embed instead of trusted domain",
|
||||
detail=f"Badge URL: {url[:100]}",
|
||||
))
|
||||
elif not url_lower.startswith("http"):
|
||||
signals.append(AuditSignal(
|
||||
signal_type=SignalType.METADATA_ANOMALY,
|
||||
severity=AuditRisk.LOW,
|
||||
description="Unusual badge URL format",
|
||||
detail=f"Badge URL: {url}",
|
||||
))
|
||||
|
||||
return signals
|
||||
|
||||
def _check_report_id(self, report_id: str, auditor_name: str) -> list[AuditSignal]:
|
||||
"""Signal 4: Validate report ID format against known auditor patterns."""
|
||||
signals: list[AuditSignal] = []
|
||||
name_lower = auditor_name.lower().strip()
|
||||
|
||||
# Certik report IDs are typically: "Certik-${project-name}-${number}" or UUID
|
||||
# Hacken: alphanumeric IDs or UUIDs
|
||||
# SlowMist: date-prefixed IDs
|
||||
|
||||
# Check for obviously fake UUIDs/invalid formats
|
||||
for known_key, known_info in self._known_auditors_lower.items():
|
||||
if known_key in name_lower:
|
||||
# Certik IDs should be findable on certik.com
|
||||
if known_key == "certik" and len(report_id) < 5:
|
||||
signals.append(AuditSignal(
|
||||
signal_type=SignalType.METADATA_ANOMALY,
|
||||
severity=AuditRisk.HIGH,
|
||||
description=f"Unusually short Certik report ID: '{report_id}'",
|
||||
detail="Legitimate Certik reports have longer project-specific IDs",
|
||||
))
|
||||
|
||||
# Generic check: report IDs with only digits are suspicious
|
||||
if report_id.isdigit() and len(report_id) > 8:
|
||||
signals.append(AuditSignal(
|
||||
signal_type=SignalType.METADATA_ANOMALY,
|
||||
severity=AuditRisk.LOW,
|
||||
description="Report ID is an all-numeric string",
|
||||
detail="May be auto-generated instead of auditor-issued",
|
||||
))
|
||||
|
||||
return signals
|
||||
|
||||
def _check_timeline(self, report_date_str: str, deploy_ts: int | None) -> list[AuditSignal]:
|
||||
"""Signal 5: Check if audit timeline makes sense."""
|
||||
signals: list[AuditSignal] = []
|
||||
|
||||
try:
|
||||
# Try multiple date formats
|
||||
report_ts = self._parse_date(report_date_str)
|
||||
if report_ts is None:
|
||||
signals.append(AuditSignal(
|
||||
signal_type=SignalType.METADATA_ANOMALY,
|
||||
severity=AuditRisk.LOW,
|
||||
description="Could not parse audit report date",
|
||||
detail=f"Date string: '{report_date_str}'",
|
||||
))
|
||||
return signals
|
||||
|
||||
report_dt = datetime.fromtimestamp(report_ts, tz=timezone.utc)
|
||||
|
||||
# Future dates (report dated in future)
|
||||
now_ts = int(time.time())
|
||||
if report_ts > now_ts + 86400: # More than 1 day in the future
|
||||
signals.append(AuditSignal(
|
||||
signal_type=SignalType.TIMELINE_ANOMALY,
|
||||
severity=AuditRisk.HIGH,
|
||||
description="Audit report dated in the future",
|
||||
detail=f"Report date: {report_dt.date()}. Current: {datetime.now(timezone.utc).date()}",
|
||||
))
|
||||
# Still check deploy-timeline if available
|
||||
if deploy_ts is not None:
|
||||
self._add_deploy_timeline_signal(report_ts, deploy_ts, report_dt, signals)
|
||||
return signals
|
||||
|
||||
# Deploy-timestamp-dependent checks
|
||||
if deploy_ts is not None:
|
||||
self._add_deploy_timeline_signal(report_ts, deploy_ts, report_dt, signals)
|
||||
|
||||
except (ValueError, OverflowError) as e:
|
||||
signals.append(AuditSignal(
|
||||
signal_type=SignalType.METADATA_ANOMALY,
|
||||
severity=AuditRisk.LOW,
|
||||
description=f"Date parsing error: {e}",
|
||||
detail=f"Date string: '{report_date_str}'",
|
||||
))
|
||||
|
||||
return signals
|
||||
|
||||
def _add_deploy_timeline_signal(
|
||||
self, report_ts: int, deploy_ts: int,
|
||||
report_dt: datetime, signals: list[AuditSignal],
|
||||
) -> None:
|
||||
"""Check deploy vs audit date consistency."""
|
||||
deploy_dt = datetime.fromtimestamp(deploy_ts, tz=timezone.utc)
|
||||
diff_days = (report_dt - deploy_dt).days
|
||||
|
||||
# Audit before deploy (or impossible future date)
|
||||
if diff_days < -MAX_AUDIT_ANTEDATE_DAYS:
|
||||
signals.append(AuditSignal(
|
||||
signal_type=SignalType.TIMELINE_ANOMALY,
|
||||
severity=AuditRisk.HIGH,
|
||||
description=f"Audit report dated {-diff_days} days BEFORE token deployment",
|
||||
detail=f"Deploy: {deploy_dt.date()}, Audit: {report_dt.date()}. Impossible for a pre-launch audit.",
|
||||
))
|
||||
elif diff_days < 0:
|
||||
signals.append(AuditSignal(
|
||||
signal_type=SignalType.TIMELINE_ANOMALY,
|
||||
severity=AuditRisk.LOW,
|
||||
description=f"Audit report slightly before deployment ({-diff_days} days)",
|
||||
detail="Possible if audit was performed before deploy, verify dates carefully.",
|
||||
))
|
||||
|
||||
# Audit too long after deploy
|
||||
if diff_days > MAX_AUDIT_POSTDATE_DAYS:
|
||||
signals.append(AuditSignal(
|
||||
signal_type=SignalType.TIMELINE_ANOMALY,
|
||||
severity=AuditRisk.MEDIUM,
|
||||
description=f"Audit report dated {diff_days} days AFTER deployment",
|
||||
detail=f"Late audits may indicate the audit was procured after scam launch.",
|
||||
))
|
||||
|
||||
def _check_report_text(self, text: str) -> list[AuditSignal]:
|
||||
"""Signal 6: Analyze report text for template/generic language."""
|
||||
signals: list[AuditSignal] = []
|
||||
|
||||
# Count template phrases
|
||||
text_lower = text.lower()
|
||||
phrase_matches = [p for p in TEMPLATE_PHRASES if p in text_lower]
|
||||
|
||||
if len(phrase_matches) >= 4:
|
||||
signals.append(AuditSignal(
|
||||
signal_type=SignalType.TEMPLATE_REUSE,
|
||||
severity=AuditRisk.HIGH,
|
||||
description=f"Report text contains {len(phrase_matches)} template/generic phrases",
|
||||
detail=f"Matched phrases: {', '.join(phrase_matches[:5])}",
|
||||
))
|
||||
elif len(phrase_matches) >= 2:
|
||||
signals.append(AuditSignal(
|
||||
signal_type=SignalType.GENERIC_LANGUAGE,
|
||||
severity=AuditRisk.MEDIUM,
|
||||
description=f"Report text contains {len(phrase_matches)} generic phrases",
|
||||
detail=f"May indicate copy-paste/fake report. Matched: {', '.join(phrase_matches[:3])}",
|
||||
))
|
||||
|
||||
# Check for suspiciously short report text
|
||||
word_count = len(text.split())
|
||||
if word_count < 50 and word_count > 0:
|
||||
signals.append(AuditSignal(
|
||||
signal_type=SignalType.GENERIC_LANGUAGE,
|
||||
severity=AuditRisk.MEDIUM,
|
||||
description=f"Report text is surprisingly short ({word_count} words)",
|
||||
detail="Legitimate audit reports are typically 5-50+ pages long",
|
||||
))
|
||||
|
||||
# Check for missing technical terms that should be in a real audit
|
||||
missing_terms: list[str] = []
|
||||
technical_terms = [
|
||||
"reentrancy", "overflow", "access control", "front-running",
|
||||
"timestamp dependence", "tx.origin", "gas limit",
|
||||
"integer overflow", "logic flaw", "centralization risk",
|
||||
]
|
||||
for term in technical_terms:
|
||||
if term not in text_lower:
|
||||
missing_terms.append(term)
|
||||
|
||||
if len(missing_terms) >= 8 and word_count > 100:
|
||||
signals.append(AuditSignal(
|
||||
signal_type=SignalType.GENERIC_LANGUAGE,
|
||||
severity=AuditRisk.LOW,
|
||||
description=f"Report lacks standard security terminology ({len(missing_terms)}/{len(technical_terms)} terms missing)",
|
||||
detail=f"Missing: {', '.join(missing_terms[:5])}",
|
||||
))
|
||||
|
||||
return signals
|
||||
|
||||
# ── Scoring ──────────────────────────────────────────────────
|
||||
|
||||
async def _score_result(self, result: AuditValidationResult) -> None:
|
||||
"""Compute aggregate risk score from signals."""
|
||||
if not result.signals:
|
||||
result.risk_level = AuditRisk.SAFE
|
||||
result.risk_score = 0.0
|
||||
return
|
||||
|
||||
severity_scores = {
|
||||
AuditRisk.CRITICAL: 1.0,
|
||||
AuditRisk.HIGH: 0.7,
|
||||
AuditRisk.MEDIUM: 0.4,
|
||||
AuditRisk.LOW: 0.15,
|
||||
}
|
||||
|
||||
total = 0.0
|
||||
count = len(result.signals)
|
||||
for signal in result.signals:
|
||||
total += severity_scores.get(signal.severity, 0.1)
|
||||
|
||||
# Base score is average severity
|
||||
base_score = total / max(count, 1)
|
||||
|
||||
# Boost for multiple signals
|
||||
multiplier = min(1.0 + (count - 1) * 0.15, 1.5)
|
||||
|
||||
# Boost for critical signals
|
||||
critical_count = sum(1 for s in result.signals if s.severity == AuditRisk.CRITICAL)
|
||||
multiplier += critical_count * 0.2
|
||||
|
||||
result.risk_score = round(min(base_score * multiplier, 1.0), 4)
|
||||
|
||||
# Map score to risk level
|
||||
if result.risk_score >= 0.7:
|
||||
result.risk_level = AuditRisk.CRITICAL
|
||||
elif result.risk_score >= 0.45:
|
||||
result.risk_level = AuditRisk.HIGH
|
||||
elif result.risk_score >= 0.2:
|
||||
result.risk_level = AuditRisk.MEDIUM
|
||||
elif result.risk_score > 0:
|
||||
result.risk_level = AuditRisk.LOW
|
||||
else:
|
||||
result.risk_level = AuditRisk.SAFE
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────
|
||||
|
||||
def _is_fake_auditor_misspelling(self, name: str, known_key: str, known_name: str) -> bool:
|
||||
"""Check if name is a likely misspelling/impersonation of a known auditor."""
|
||||
if name == known_name or name == known_key:
|
||||
return False
|
||||
|
||||
# Check character-level similarity (simple approach)
|
||||
known_key_len = len(known_key)
|
||||
if known_key_len >= 4 and known_key in name:
|
||||
# If the known key is a substring but not the whole thing: impersonation
|
||||
remaining = name.replace(known_key, "").strip()
|
||||
if remaining in {"", "-", "_", "."}:
|
||||
return False # exact match after stripping
|
||||
# It's a misspelling variant like "certikpro", "hackenverify"
|
||||
return True
|
||||
|
||||
# Check for common character swaps/typos
|
||||
if known_key_len >= 4 and len(name) >= 4:
|
||||
# Simple edit distance check - if they share first 3 chars and last 2
|
||||
if name[:3] == known_key[:3] and name[-2:] == known_key[-2:]:
|
||||
if name != known_key:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _parse_date(self, date_str: str) -> int | None:
|
||||
"""Parse various date formats into a Unix timestamp."""
|
||||
formats = [
|
||||
"%Y-%m-%d",
|
||||
"%Y/%m/%d",
|
||||
"%d/%m/%Y",
|
||||
"%m/%d/%Y",
|
||||
"%d %B %Y",
|
||||
"%B %d, %Y",
|
||||
"%d %b %Y",
|
||||
"%b %d, %Y",
|
||||
"%Y-%m-%dT%H:%M:%S",
|
||||
"%Y-%m-%dT%H:%M:%SZ",
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
]
|
||||
date_str_clean = date_str.strip().strip('"').strip("'")
|
||||
for fmt in formats:
|
||||
try:
|
||||
return int(datetime.strptime(date_str_clean, fmt).timestamp())
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
|
@ -1,876 +0,0 @@
|
|||
"""
|
||||
Supply Manipulation / Bundler Detector
|
||||
=======================================
|
||||
Detects bundled token launches where insiders control disproportionate
|
||||
supply through sniper-controlled wallet distributions.
|
||||
|
||||
Signals detected:
|
||||
- Bundled initial buys (multiple wallets funded from same source,
|
||||
buying within same block/seconds)
|
||||
- Supply concentration across linked wallets (top holders controlled
|
||||
by same entity)
|
||||
- Fund flow analysis (same funding source → multiple snipers)
|
||||
- TIMEO (This Is My Eyes Only) token distribution patterns
|
||||
- Sniper cluster detection (wallets that only buy this token)
|
||||
- Launch timing anomalies (coordinated buys in first blocks)
|
||||
- Holder overlap with known bundler addresses
|
||||
- Supply distribution entropy analysis
|
||||
|
||||
Tier : Premium ($0.08)
|
||||
Price : 80000 atoms
|
||||
Endpoint: POST /api/v1/x402-tools/bundler_detect
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Constants ──────────────────────────────────────────────────────
|
||||
|
||||
SOLANA_ADDR_RE = re.compile(r"^[1-9A-HJ-NP-Za-km-z]{32,44}$")
|
||||
EVM_ADDR_RE = re.compile(r"^0x[a-fA-F0-9]{40}$")
|
||||
|
||||
EVM_CHAINS = frozenset(
|
||||
{
|
||||
"ethereum",
|
||||
"bsc",
|
||||
"polygon",
|
||||
"arbitrum",
|
||||
"optimism",
|
||||
"avalanche",
|
||||
"base",
|
||||
"fantom",
|
||||
"linea",
|
||||
"zksync",
|
||||
"scroll",
|
||||
"mantle",
|
||||
}
|
||||
)
|
||||
|
||||
SUPPORTED_CHAINS = [*EVM_CHAINS, "solana"]
|
||||
|
||||
# DEX API endpoints
|
||||
DEXSCREENER_API = "https://api.dexscreener.com/latest/dex"
|
||||
|
||||
# Free Solana RPC for account info
|
||||
SOLANA_RPC = "https://api.mainnet-beta.solana.com"
|
||||
|
||||
# Birdeye public API (no key needed for basic queries)
|
||||
BIRDEYE_PUBLIC = "https://public-api.birdeye.so"
|
||||
|
||||
# Known bundler wallet addresses (publicly flagged on-chain)
|
||||
KNOWN_BUNDLER_SEEDS: set[str] = set()
|
||||
|
||||
# ── Risk Levels ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class BundlerRisk(Enum):
|
||||
CRITICAL = "critical"
|
||||
HIGH = "high"
|
||||
MEDIUM = "medium"
|
||||
LOW = "low"
|
||||
NONE = "none"
|
||||
|
||||
|
||||
# ── Data Models ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class BundledBuy:
|
||||
"""A single suspicious buy event identified as potentially bundled."""
|
||||
|
||||
wallet: str
|
||||
amount_usd: float
|
||||
buy_block: int
|
||||
buy_timestamp: float
|
||||
tx_hash: str = ""
|
||||
funding_source: str = ""
|
||||
is_sniper: bool = False
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"wallet": self.wallet,
|
||||
"amount_usd": round(self.amount_usd, 2),
|
||||
"buy_block": self.buy_block,
|
||||
"buy_timestamp": self.buy_timestamp,
|
||||
"tx_hash": self.tx_hash,
|
||||
"funding_source": self.funding_source,
|
||||
"is_sniper": self.is_sniper,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class HolderCluster:
|
||||
"""A cluster of wallets suspected to be controlled by one entity."""
|
||||
|
||||
wallets: list[str]
|
||||
total_supply_pct: float
|
||||
funding_overlap_score: float # 0-1, how much funding sources overlap
|
||||
buy_time_similarity: float # 0-1, how clustered buys were in time
|
||||
common_funding_source: str = ""
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"wallet_count": len(self.wallets),
|
||||
"wallets": self.wallets[:20], # cap at 20 in output
|
||||
"total_supply_pct": round(self.total_supply_pct, 2),
|
||||
"funding_overlap_score": round(self.funding_overlap_score, 3),
|
||||
"buy_time_similarity": round(self.buy_time_similarity, 3),
|
||||
"common_funding_source": self.common_funding_source,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class BundlerReport:
|
||||
"""Full supply manipulation analysis result."""
|
||||
|
||||
token_address: str
|
||||
chain: str
|
||||
name: str = ""
|
||||
symbol: str = ""
|
||||
|
||||
# Core scores (0-100)
|
||||
bundler_score: float = 0.0
|
||||
supply_concentration_score: float = 0.0
|
||||
sniper_cluster_score: float = 0.0
|
||||
launch_timing_anomaly_score: float = 0.0
|
||||
fund_flow_risk_score: float = 0.0
|
||||
|
||||
# Findings
|
||||
suspected_bundled_buys: list[BundledBuy] = field(default_factory=list)
|
||||
holder_clusters: list[HolderCluster] = field(default_factory=list)
|
||||
top_10_holder_concentration: float = 0.0
|
||||
dev_hold_pct: float = 0.0
|
||||
unique_buyers_first_block: int = 0
|
||||
total_buys_first_blocks: int = 0
|
||||
buys_from_same_funding: int = 0
|
||||
estimated_unique_entities: int = 0
|
||||
|
||||
risk_label: str = "none"
|
||||
errors: list[str] = field(default_factory=list)
|
||||
raw: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"token_address": self.token_address,
|
||||
"chain": self.chain,
|
||||
"name": self.name,
|
||||
"symbol": self.symbol,
|
||||
"bundler_score": round(self.bundler_score, 1),
|
||||
"risk_label": self.risk_label,
|
||||
"signals": {
|
||||
"supply_concentration": round(self.supply_concentration_score, 1),
|
||||
"sniper_cluster": round(self.sniper_cluster_score, 1),
|
||||
"launch_timing_anomaly": round(self.launch_timing_anomaly_score, 1),
|
||||
"fund_flow_risk": round(self.fund_flow_risk_score, 1),
|
||||
},
|
||||
"suspected_bundled_buys": [b.to_dict() for b in self.suspected_bundled_buys[:50]],
|
||||
"holder_clusters": [c.to_dict() for c in self.holder_clusters[:10]],
|
||||
"top_10_holder_concentration": round(self.top_10_holder_concentration, 2),
|
||||
"dev_hold_pct": round(self.dev_hold_pct, 2),
|
||||
"unique_buyers_first_block": self.unique_buyers_first_block,
|
||||
"total_buys_first_blocks": self.total_buys_first_blocks,
|
||||
"buys_from_same_funding": self.buys_from_same_funding,
|
||||
"estimated_unique_entities": self.estimated_unique_entities,
|
||||
}
|
||||
|
||||
def summary(self) -> str:
|
||||
flags = []
|
||||
if self.top_10_holder_concentration > 80:
|
||||
flags.append(f"top10hld:{self.top_10_holder_concentration:.0f}%")
|
||||
if self.buys_from_same_funding > 3:
|
||||
flags.append(f"shared_fund:{self.buys_from_same_funding}x")
|
||||
if self.suspected_bundled_buys:
|
||||
flags.append(f"bundled:{len(self.suspected_bundled_buys)}buys")
|
||||
if self.holder_clusters:
|
||||
total_cluster_pct = sum(c.total_supply_pct for c in self.holder_clusters)
|
||||
flags.append(f"clustered:{total_cluster_pct:.0f}%")
|
||||
flag_str = f" [{', '.join(flags)}]" if flags else ""
|
||||
return (
|
||||
f"[{self.risk_label.upper()}] {self.token_address[:14]}... "
|
||||
f"({self.name}/{self.symbol}) — "
|
||||
f"Bundler score: {self.bundler_score:.0f}/100 | "
|
||||
f"{len(self.holder_clusters)} clusters | "
|
||||
f"{self.estimated_unique_entities} entities estimated"
|
||||
f"{flag_str}"
|
||||
)
|
||||
|
||||
|
||||
# ── Scoring Helpers ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def _gini_coefficient(values: list[float]) -> float:
|
||||
"""Compute Gini coefficient for supply distribution (0=equal, 1=max concentration)."""
|
||||
if not values:
|
||||
return 0.0
|
||||
sorted_vals = sorted(values)
|
||||
n = len(sorted_vals)
|
||||
cumulative = 0.0
|
||||
for i, v in enumerate(sorted_vals):
|
||||
cumulative += (i + 1) * v
|
||||
gini = (2 * cumulative) / (n * sum(sorted_vals)) - (n + 1) / n
|
||||
return max(0.0, min(gini, 1.0))
|
||||
|
||||
|
||||
def _entropy(values: list[float]) -> float:
|
||||
"""Shannon entropy of a distribution (lower = more concentrated).
|
||||
Returns normalized [0, 1] where 1 = perfectly uniform, 0 = fully concentrated.
|
||||
"""
|
||||
total = sum(values)
|
||||
if total <= 0:
|
||||
return 0.0
|
||||
n = len(values)
|
||||
if n <= 1:
|
||||
return 1.0 # Single bin = trivially uniform
|
||||
raw = 0.0
|
||||
for v in values:
|
||||
p = v / total
|
||||
if p > 0:
|
||||
raw -= p * math.log2(p)
|
||||
max_entropy = math.log2(n)
|
||||
return raw / max_entropy if max_entropy > 0 else 0.0
|
||||
|
||||
|
||||
def _time_cluster_similarity(timestamps: list[float]) -> float:
|
||||
"""Score how tightly clustered timestamps are (0=spread, 1=all at once)."""
|
||||
if len(timestamps) < 2:
|
||||
return 0.0
|
||||
min_ts = min(timestamps)
|
||||
max_ts = max(timestamps)
|
||||
span = max_ts - min_ts
|
||||
if span == 0:
|
||||
return 1.0
|
||||
# If all buys happened within 60 seconds, high similarity
|
||||
if span <= 60:
|
||||
return 1.0 - (span / 60) * 0.5 # 0.5-1.0
|
||||
# If within 5 minutes, medium
|
||||
if span <= 300:
|
||||
return 0.5 - (span - 60) / (300 - 60) * 0.3 # 0.2-0.5
|
||||
return max(0.0, 0.2 - (span - 300) / 3600)
|
||||
|
||||
|
||||
def _funding_overlap(funding_sources: list[str]) -> float:
|
||||
"""Score how many wallets share the same funding source (0-1)."""
|
||||
if not funding_sources:
|
||||
return 0.0
|
||||
total = len(funding_sources)
|
||||
if total < 2:
|
||||
return 0.0
|
||||
# Count how many share a source with at least one other
|
||||
from collections import Counter
|
||||
|
||||
source_counts = Counter(funding_sources)
|
||||
shared = sum(c for c in source_counts.values() if c > 1)
|
||||
return shared / total
|
||||
|
||||
|
||||
def _label_risk(score: float) -> str:
|
||||
if score >= 75:
|
||||
return "critical"
|
||||
if score >= 50:
|
||||
return "high"
|
||||
if score >= 25:
|
||||
return "medium"
|
||||
if score > 0:
|
||||
return "low"
|
||||
return "none"
|
||||
|
||||
|
||||
# ── Core Detector ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class BundlerDetector:
|
||||
"""Main detector for bundled/supply-manipulated token launches."""
|
||||
|
||||
def __init__(self, http_timeout: float = 15.0):
|
||||
self.http = httpx.AsyncClient(timeout=http_timeout)
|
||||
self._birdeye_api_key = os.environ.get("BIRDEYE_API_KEY", "")
|
||||
|
||||
async def close(self):
|
||||
await self.http.aclose()
|
||||
|
||||
# ── Public API ──────────────────────────────────────────────
|
||||
|
||||
async def scan(self, address: str, chain: str) -> BundlerReport:
|
||||
"""Full supply manipulation analysis for a token."""
|
||||
if not self._validate_address(address, chain):
|
||||
return BundlerReport(
|
||||
token_address=address,
|
||||
chain=chain,
|
||||
errors=[f"Invalid address format for chain: {chain}"],
|
||||
risk_label="error",
|
||||
)
|
||||
|
||||
chain = chain.lower()
|
||||
if chain not in SUPPORTED_CHAINS:
|
||||
return BundlerReport(
|
||||
token_address=address,
|
||||
chain=chain,
|
||||
errors=[f"Unsupported chain: {chain}"],
|
||||
risk_label="error",
|
||||
)
|
||||
|
||||
report = BundlerReport(token_address=address, chain=chain)
|
||||
|
||||
try:
|
||||
# 1. Fetch token metadata and pair info
|
||||
metadata = await self._fetch_metadata(address, chain)
|
||||
report.name = metadata.get("name", "Unknown")
|
||||
report.symbol = metadata.get("symbol", "UNKNOWN")
|
||||
report.raw["metadata"] = metadata
|
||||
|
||||
# 2. Fetch holder data
|
||||
holders = await self._fetch_holders(address, chain)
|
||||
report.raw["holders_raw"] = holders
|
||||
|
||||
if not holders:
|
||||
report.errors.append("No holder data available")
|
||||
report.risk_label = "error"
|
||||
return report
|
||||
|
||||
# 3. Compute supply concentration
|
||||
top10_pct = self._compute_top_holder_pct(holders, 10)
|
||||
report.top_10_holder_concentration = top10_pct
|
||||
report.dev_hold_pct = self._extract_dev_hold_pct(holders, metadata)
|
||||
|
||||
# 4. Fetch and analyze buys for bundling patterns
|
||||
buys = await self._fetch_buys(address, chain)
|
||||
report.raw["buys_raw"] = buys
|
||||
|
||||
# 5. Detect bundled buys (same funding source, same block)
|
||||
bundled_buys, buys_from_same_funding = self._detect_bundled_buys(buys)
|
||||
report.suspected_bundled_buys = bundled_buys
|
||||
report.buys_from_same_funding = buys_from_same_funding
|
||||
|
||||
# 6. Analyze launch timing
|
||||
timing_info = self._analyze_launch_timing(buys)
|
||||
report.unique_buyers_first_block = timing_info["unique_buyers_first_block"]
|
||||
report.total_buys_first_blocks = timing_info["total_buys_first_blocks"]
|
||||
|
||||
# 7. Cluster wallets by funding source and timing
|
||||
clusters = self._cluster_wallets(buys, holders)
|
||||
report.holder_clusters = clusters
|
||||
|
||||
# 8. Estimate unique entities
|
||||
report.estimated_unique_entities = self._estimate_entities(holders, clusters, len(bundled_buys))
|
||||
|
||||
# 9. Compute all scores
|
||||
report.supply_concentration_score = self._score_supply_concentration(holders, top10_pct)
|
||||
report.sniper_cluster_score = self._score_sniper_clusters(clusters, bundled_buys)
|
||||
report.launch_timing_anomaly_score = self._score_launch_timing(timing_info, buys, holders)
|
||||
report.fund_flow_risk_score = self._score_fund_flow(bundled_buys, buys_from_same_funding, clusters)
|
||||
|
||||
# 10. Composite bundler score
|
||||
report.bundler_score = self._compute_bundler_score(report)
|
||||
report.risk_label = _label_risk(report.bundler_score)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Bundler scan error for {address}: {e}")
|
||||
report.errors.append(str(e))
|
||||
report.risk_label = "error"
|
||||
|
||||
return report
|
||||
|
||||
async def quick_check(self, address: str, chain: str) -> dict[str, Any]:
|
||||
"""Quick supply concentration check — holder data only."""
|
||||
if not self._validate_address(address, chain):
|
||||
return {"error": f"Invalid address for chain {chain}"}
|
||||
|
||||
chain = chain.lower()
|
||||
metadata = await self._fetch_metadata(address, chain)
|
||||
holders = await self._fetch_holders(address, chain)
|
||||
|
||||
if not holders:
|
||||
return {
|
||||
"address": address,
|
||||
"chain": chain,
|
||||
"name": metadata.get("name", ""),
|
||||
"symbol": metadata.get("symbol", ""),
|
||||
"error": "No holder data available",
|
||||
}
|
||||
|
||||
top10 = self._compute_top_holder_pct(holders, 10)
|
||||
gini = _gini_coefficient([h.get("percentage", 0) for h in holders[:100]])
|
||||
|
||||
score = 0.0
|
||||
if top10 > 80:
|
||||
score += 40
|
||||
elif top10 > 60:
|
||||
score += 25
|
||||
if gini > 0.8:
|
||||
score += 30
|
||||
elif gini > 0.6:
|
||||
score += 15
|
||||
|
||||
return {
|
||||
"address": address,
|
||||
"chain": chain,
|
||||
"name": metadata.get("name", ""),
|
||||
"symbol": metadata.get("symbol", ""),
|
||||
"supply_concentration_score": min(score, 100),
|
||||
"risk_label": _label_risk(min(score, 100)),
|
||||
"top_10_holder_pct": round(top10, 2),
|
||||
"gini_coefficient": round(gini, 3),
|
||||
}
|
||||
|
||||
# ── Validation ──────────────────────────────────────────────
|
||||
|
||||
def _validate_address(self, address: str, chain: str) -> bool:
|
||||
chain = chain.lower()
|
||||
if chain == "solana":
|
||||
return bool(SOLANA_ADDR_RE.match(address))
|
||||
if chain in EVM_CHAINS:
|
||||
return bool(EVM_ADDR_RE.match(address))
|
||||
return bool(EVM_ADDR_RE.match(address) or SOLANA_ADDR_RE.match(address))
|
||||
|
||||
# ── Data Fetching ───────────────────────────────────────────
|
||||
|
||||
async def _fetch_metadata(self, address: str, chain: str) -> dict[str, Any]:
|
||||
"""Fetch token metadata from DexScreener."""
|
||||
try:
|
||||
url = f"{DEXSCREENER_API}/tokens/{address}"
|
||||
resp = await self.http.get(url, timeout=10)
|
||||
if resp.status_code != 200:
|
||||
return {}
|
||||
data = resp.json()
|
||||
pairs = data.get("pairs", [])
|
||||
if not pairs:
|
||||
return {}
|
||||
|
||||
pair = pairs[0]
|
||||
return {
|
||||
"name": pair.get("baseToken", {}).get("name", ""),
|
||||
"symbol": pair.get("baseToken", {}).get("symbol", ""),
|
||||
"decimals": pair.get("baseToken", {}).get("decimals"),
|
||||
"price_usd": pair.get("priceUsd", ""),
|
||||
"liquidity_usd": pair.get("liquidity", {}).get("usd", 0),
|
||||
"fdv": pair.get("fdv", 0),
|
||||
"pair_address": pair.get("pairAddress", ""),
|
||||
"dex": pair.get("dexId", ""),
|
||||
"url": pair.get("url", ""),
|
||||
"social": {
|
||||
"twitter": pair.get("info", {}).get("twitter", ""),
|
||||
"website": pair.get("info", {}).get("website", ""),
|
||||
"telegram": pair.get("info", {}).get("telegram", ""),
|
||||
},
|
||||
"creation_block": None, # May not be available
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug(f"Metadata fetch error: {e}")
|
||||
return {}
|
||||
|
||||
async def _fetch_holders(self, address: str, chain: str) -> list[dict[str, Any]]:
|
||||
"""Fetch top holders from Birdeye public API or Solscan."""
|
||||
try:
|
||||
if chain == "solana":
|
||||
return await self._fetch_solana_holders(address)
|
||||
# EVM chains — try Birdeye first
|
||||
return await self._fetch_evm_holders(address, chain)
|
||||
except Exception as e:
|
||||
logger.debug(f"Holder fetch error: {e}")
|
||||
return []
|
||||
|
||||
async def _fetch_solana_holders(self, address: str) -> list[dict[str, Any]]:
|
||||
"""Fetch Solana token holders via Birdeye public API."""
|
||||
try:
|
||||
url = f"{BIRDEYE_PUBLIC}/defi/holder/tokenlist?tokenAddress={address}&limit=100"
|
||||
headers = {"Accept": "application/json"}
|
||||
if self._birdeye_api_key:
|
||||
headers["X-API-KEY"] = self._birdeye_api_key
|
||||
|
||||
resp = await self.http.get(url, headers=headers, timeout=10)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
items = data.get("data", {}).get("items", [])
|
||||
return [
|
||||
{
|
||||
"address": h.get("holder", ""),
|
||||
"amount": h.get("amount", 0),
|
||||
"percentage": h.get("percent", 0),
|
||||
}
|
||||
for h in items
|
||||
]
|
||||
except Exception as e:
|
||||
logger.debug(f"Solana holder fetch error: {e}")
|
||||
|
||||
# Fallback: Solscan free API
|
||||
try:
|
||||
url = f"https://public-api.solscan.io/token/holders?tokenAddress={address}&limit=100&offset=0"
|
||||
resp = await self.http.get(url, timeout=10)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
items = data if isinstance(data, list) else data.get("data", [])
|
||||
return [
|
||||
{
|
||||
"address": h.get("owner", h.get("address", "")),
|
||||
"amount": h.get("amount", h.get("balance", 0)),
|
||||
"percentage": h.get("percentage", h.get("percent", 0)),
|
||||
}
|
||||
for h in items
|
||||
]
|
||||
except Exception as e:
|
||||
logger.debug(f"Solscan holder fallback error: {e}")
|
||||
|
||||
return []
|
||||
|
||||
async def _fetch_evm_holders(self, address: str, chain: str) -> list[dict[str, Any]]:
|
||||
"""Fetch EVM token holders via Birdeye public API."""
|
||||
try:
|
||||
url = f"{BIRDEYE_PUBLIC}/defi/holder/tokenlist?tokenAddress={address}&limit=100"
|
||||
headers = {"Accept": "application/json"}
|
||||
if self._birdeye_api_key:
|
||||
headers["X-API-KEY"] = self._birdeye_api_key
|
||||
|
||||
resp = await self.http.get(url, headers=headers, timeout=10)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
items = data.get("data", {}).get("items", [])
|
||||
return [
|
||||
{
|
||||
"address": h.get("holder", ""),
|
||||
"amount": h.get("amount", 0),
|
||||
"percentage": h.get("percent", 0),
|
||||
}
|
||||
for h in items
|
||||
]
|
||||
except Exception as e:
|
||||
logger.debug(f"EVM holder fetch error: {e}")
|
||||
|
||||
return []
|
||||
|
||||
async def _fetch_buys(self, address: str, chain: str) -> list[dict[str, Any]]:
|
||||
"""Fetch recent buy transactions for the token."""
|
||||
buys: list[dict[str, Any]] = []
|
||||
try:
|
||||
url = f"{DEXSCREENER_API}/tokens/{address}"
|
||||
resp = await self.http.get(url, timeout=10)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
pairs = data.get("pairs", [])
|
||||
for pair in pairs[:5]: # Check top 5 pairs
|
||||
txns = pair.get("txns", {})
|
||||
# Extract buys from recent transactions
|
||||
m5 = txns.get("m5", {}) or {}
|
||||
h1 = txns.get("h1", {}) or {}
|
||||
h6 = txns.get("h6", {}) or {}
|
||||
buys.append(
|
||||
{
|
||||
"type": "buy",
|
||||
"m5_buys": m5.get("buys", 0),
|
||||
"m5_sells": m5.get("sells", 0),
|
||||
"h1_buys": h1.get("buys", 0),
|
||||
"h1_sells": h1.get("sells", 0),
|
||||
"h6_buys": h6.get("buys", 0),
|
||||
"h6_sells": h6.get("sells", 0),
|
||||
"pair_address": pair.get("pairAddress", ""),
|
||||
"creation_block": None, # May not be available
|
||||
}
|
||||
)
|
||||
|
||||
# Try to get volume per tx for bundling analysis
|
||||
volume_m5 = pair.get("volume", {}).get("m5", 0) or 0
|
||||
if m5.get("buys", 0) > 0:
|
||||
avg_buy = float(volume_m5) / max(1, m5.get("buys", 1))
|
||||
buys[-1]["avg_buy_value"] = avg_buy
|
||||
except Exception as e:
|
||||
logger.debug(f"Buy fetch error: {e}")
|
||||
|
||||
return buys
|
||||
|
||||
# ── Analysis ────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _compute_top_holder_pct(holders: list[dict[str, Any]], top_n: int) -> float:
|
||||
"""Calculate the percentage of supply held by top N holders."""
|
||||
sorted_h = sorted(holders, key=lambda h: h.get("percentage", 0), reverse=True)
|
||||
top = sorted_h[:top_n]
|
||||
return sum(h.get("percentage", 0) for h in top if h.get("percentage") is not None)
|
||||
|
||||
@staticmethod
|
||||
def _extract_dev_hold_pct(holders: list[dict[str, Any]], metadata: dict[str, Any]) -> float:
|
||||
"""Extract developer/allocation wallet holding percentage."""
|
||||
if not holders:
|
||||
return 0.0
|
||||
return holders[0].get("percentage", 0) if holders else 0.0
|
||||
|
||||
def _detect_bundled_buys(self, buys: list[dict[str, Any]]) -> tuple[list[BundledBuy], int]:
|
||||
"""Detect buys that appear bundled (same source, time clustering)."""
|
||||
bundled: list[BundledBuy] = []
|
||||
same_funding_count = 0
|
||||
|
||||
# From aggregated transaction data, detect anomalous patterns
|
||||
for buy in buys:
|
||||
m5_buys = buy.get("m5_buys", 0)
|
||||
h1_buys = buy.get("h1_buys", 0)
|
||||
|
||||
# If buys/minute in first 5min is very high relative to later
|
||||
if m5_buys > 0 and h1_buys > 0:
|
||||
m5_rate = m5_buys / 5
|
||||
h1_rate = h1_buys / 60
|
||||
if m5_rate > h1_rate * 3 and m5_buys >= 10:
|
||||
# High initial buy concentration — suspicious
|
||||
bundled.append(
|
||||
BundledBuy(
|
||||
wallet=f"cluster:{buy.get('pair_address', '')[:12]}",
|
||||
amount_usd=0, # aggregated
|
||||
buy_block=0,
|
||||
buy_timestamp=time.time(),
|
||||
tx_hash="",
|
||||
funding_source="aggregated",
|
||||
is_sniper=True,
|
||||
)
|
||||
)
|
||||
same_funding_count += m5_buys
|
||||
|
||||
return bundled, same_funding_count
|
||||
|
||||
def _analyze_launch_timing(self, buys: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""Analyze launch timing for anomalous patterns."""
|
||||
result = {
|
||||
"unique_buyers_first_block": 0,
|
||||
"total_buys_first_blocks": 0,
|
||||
"buy_concentration_ratio": 0.0,
|
||||
}
|
||||
|
||||
for buy in buys:
|
||||
m5_buys = buy.get("m5_buys", 0)
|
||||
h1_buys = buy.get("h1_buys", 0)
|
||||
h6_buys = buy.get("h6_buys", 0)
|
||||
total = m5_buys + h1_buys + h6_buys
|
||||
|
||||
if total > 0:
|
||||
# What % of all buys happened in first 5 minutes?
|
||||
first_5m_pct = m5_buys / total if total > 0 else 0
|
||||
result["buy_concentration_ratio"] = max(result["buy_concentration_ratio"], first_5m_pct)
|
||||
result["total_buys_first_blocks"] += m5_buys
|
||||
# Estimate unique from m5 vs h1 ratio
|
||||
if h1_buys > 0 and m5_buys > 0:
|
||||
result["unique_buyers_first_block"] = max(
|
||||
result["unique_buyers_first_block"],
|
||||
min(m5_buys, h1_buys), # rough proxy
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def _cluster_wallets(self, buys: list[dict[str, Any]], holders: list[dict[str, Any]]) -> list[HolderCluster]:
|
||||
"""Cluster wallets by funding overlap and timing patterns."""
|
||||
clusters: list[HolderCluster] = []
|
||||
|
||||
if not holders:
|
||||
return clusters
|
||||
|
||||
# Identify clusters based on supply concentration
|
||||
sorted_h = sorted(holders, key=lambda h: h.get("percentage", 0), reverse=True)
|
||||
|
||||
# If top 3 holders control >60%, they form a natural cluster
|
||||
top3 = sorted_h[:3]
|
||||
top3_pct = sum(h.get("percentage", 0) for h in top3 if h.get("percentage") is not None)
|
||||
if top3_pct > 60 and len(top3) >= 2:
|
||||
clusters.append(
|
||||
HolderCluster(
|
||||
wallets=[h.get("address", "") for h in top3 if h.get("address")],
|
||||
total_supply_pct=top3_pct,
|
||||
funding_overlap_score=0.7 if top3_pct > 80 else 0.5,
|
||||
buy_time_similarity=0.8 if top3_pct > 80 else 0.6,
|
||||
common_funding_source="top_holders_cluster",
|
||||
)
|
||||
)
|
||||
|
||||
# Check for wallet groupings with 5-15% each (typical bundler pattern)
|
||||
cluster_wallets: list[dict[str, Any]] = []
|
||||
cluster_pct = 0.0
|
||||
for h in sorted_h[3:]: # Skip top 3
|
||||
pct = h.get("percentage", 0)
|
||||
if pct and 2 <= pct <= 15:
|
||||
cluster_wallets.append(h)
|
||||
cluster_pct += pct
|
||||
if len(cluster_wallets) >= 5 and cluster_pct >= 15:
|
||||
break
|
||||
|
||||
if len(cluster_wallets) >= 5 and cluster_pct >= 15:
|
||||
clusters.append(
|
||||
HolderCluster(
|
||||
wallets=[h.get("address", "") for h in cluster_wallets],
|
||||
total_supply_pct=cluster_pct,
|
||||
funding_overlap_score=0.6,
|
||||
buy_time_similarity=0.7,
|
||||
common_funding_source="mid_holder_belt",
|
||||
)
|
||||
)
|
||||
|
||||
return clusters
|
||||
|
||||
@staticmethod
|
||||
def _estimate_entities(
|
||||
holders: list[dict[str, Any]],
|
||||
clusters: list[HolderCluster],
|
||||
bundled_buys_count: int,
|
||||
) -> int:
|
||||
"""Estimate number of truly independent entities behind the token."""
|
||||
total_holders = len(holders)
|
||||
|
||||
# Each cluster represents 1 entity instead of N wallets
|
||||
cluster_wallet_count = sum(len(c.wallets) for c in clusters)
|
||||
|
||||
# Reduce estimated entities by clustered wallets
|
||||
entities = max(1, total_holders - cluster_wallet_count)
|
||||
|
||||
# Further reduce if many bundled buys detected
|
||||
if bundled_buys_count > 20:
|
||||
entities = max(1, entities - bundled_buys_count // 5)
|
||||
|
||||
return entities
|
||||
|
||||
# ── Scoring ─────────────────────────────────────────────────
|
||||
|
||||
def _score_supply_concentration(self, holders: list[dict[str, Any]], top10_pct: float) -> float:
|
||||
"""Score supply distribution risk (0-100)."""
|
||||
score = 0.0
|
||||
|
||||
# Top 10 concentration
|
||||
if top10_pct >= 90:
|
||||
score += 50
|
||||
elif top10_pct >= 75:
|
||||
score += 35
|
||||
elif top10_pct >= 50:
|
||||
score += 20
|
||||
elif top10_pct >= 30:
|
||||
score += 10
|
||||
|
||||
# Gini coefficient
|
||||
amounts = [h.get("percentage", 0) for h in holders[:100] if h.get("percentage") is not None]
|
||||
gini = _gini_coefficient(amounts)
|
||||
if gini >= 0.9:
|
||||
score += 40
|
||||
elif gini >= 0.8:
|
||||
score += 30
|
||||
elif gini >= 0.6:
|
||||
score += 15
|
||||
|
||||
# Entropy (low entropy = concentrated)
|
||||
ent = _entropy(amounts)
|
||||
if ent < 0.3:
|
||||
score += 15
|
||||
elif ent < 0.5:
|
||||
score += 8
|
||||
|
||||
return min(score, 100)
|
||||
|
||||
def _score_sniper_clusters(self, clusters: list[HolderCluster], bundled_buys: list[BundledBuy]) -> float:
|
||||
"""Score sniper cluster risk (0-100)."""
|
||||
score = 0.0
|
||||
|
||||
# High-funding-overlap clusters
|
||||
high_overlap = [c for c in clusters if c.funding_overlap_score > 0.6]
|
||||
if high_overlap:
|
||||
total_pct = sum(c.total_supply_pct for c in high_overlap)
|
||||
if total_pct >= 50:
|
||||
score += 50
|
||||
elif total_pct >= 30:
|
||||
score += 35
|
||||
elif total_pct >= 15:
|
||||
score += 20
|
||||
|
||||
# Bundled buys
|
||||
if bundled_buys:
|
||||
score += min(len(bundled_buys) * 5, 30)
|
||||
|
||||
# Time clustering in clusters
|
||||
high_time = [c for c in clusters if c.buy_time_similarity > 0.7]
|
||||
if high_time:
|
||||
score += min(len(high_time) * 10, 25)
|
||||
|
||||
return min(score, 100)
|
||||
|
||||
def _score_launch_timing(
|
||||
self,
|
||||
timing_info: dict[str, Any],
|
||||
buys: list[dict[str, Any]],
|
||||
holders: list[dict[str, Any]],
|
||||
) -> float:
|
||||
"""Score launch timing anomalies (0-100)."""
|
||||
score = 0.0
|
||||
|
||||
# High buy concentration in first 5 minutes
|
||||
ratio = timing_info.get("buy_concentration_ratio", 0)
|
||||
if ratio >= 0.8:
|
||||
score += 50
|
||||
elif ratio >= 0.6:
|
||||
score += 35
|
||||
elif ratio >= 0.4:
|
||||
score += 20
|
||||
|
||||
# Very few unique buyers relative to total buys
|
||||
unique = timing_info.get("unique_buyers_first_block", 0)
|
||||
total = timing_info.get("total_buys_first_blocks", 0)
|
||||
if total > 0 and unique > 0:
|
||||
repeat_rate = total / max(1, unique)
|
||||
if repeat_rate >= 5:
|
||||
score += 30
|
||||
elif repeat_rate >= 3:
|
||||
score += 20
|
||||
|
||||
# Holder count vs buy count mismatch
|
||||
holder_count = len(holders)
|
||||
if holder_count > 0 and total > 0:
|
||||
buys_per_holder = total / holder_count
|
||||
if buys_per_holder >= 3:
|
||||
score += 15
|
||||
|
||||
return min(score, 100)
|
||||
|
||||
def _score_fund_flow(
|
||||
self,
|
||||
bundled_buys: list[BundledBuy],
|
||||
same_funding_count: int,
|
||||
clusters: list[HolderCluster],
|
||||
) -> float:
|
||||
"""Score fund flow risk (0-100)."""
|
||||
score = 0.0
|
||||
|
||||
# Same funding source buys
|
||||
if same_funding_count >= 20:
|
||||
score += 45
|
||||
elif same_funding_count >= 10:
|
||||
score += 30
|
||||
elif same_funding_count >= 5:
|
||||
score += 15
|
||||
|
||||
# Clusters with high funding overlap
|
||||
high_overlap = [c for c in clusters if c.funding_overlap_score > 0.7]
|
||||
if high_overlap:
|
||||
score += min(len(high_overlap) * 15, 30)
|
||||
|
||||
# Overall cluster funding overlap average
|
||||
if clusters:
|
||||
avg_overlap = sum(c.funding_overlap_score for c in clusters) / len(clusters)
|
||||
score += avg_overlap * 20
|
||||
|
||||
return min(score, 100)
|
||||
|
||||
def _compute_bundler_score(self, report: BundlerReport) -> float:
|
||||
"""Weighted composite bundler score."""
|
||||
weights = {
|
||||
"supply_concentration": 0.30,
|
||||
"sniper_cluster": 0.25,
|
||||
"launch_timing_anomaly": 0.20,
|
||||
"fund_flow_risk": 0.25,
|
||||
}
|
||||
|
||||
score = (
|
||||
report.supply_concentration_score * weights["supply_concentration"]
|
||||
+ report.sniper_cluster_score * weights["sniper_cluster"]
|
||||
+ report.launch_timing_anomaly_score * weights["launch_timing_anomaly"]
|
||||
+ report.fund_flow_risk_score * weights["fund_flow_risk"]
|
||||
)
|
||||
|
||||
return min(score, 100)
|
||||
|
|
@ -1,932 +0,0 @@
|
|||
"""
|
||||
Canonical Tool Prices — Single Source of Truth
|
||||
127 tools. Enforcement + databus merged.
|
||||
This file is THE authoritative list. All endpoints (MCP discovery, x402 catalog, human marketplace) read from this.
|
||||
"""
|
||||
|
||||
CANONICAL_TOOL_PRICES = {
|
||||
"airdrop_check": {
|
||||
"price_usd": 0.05,
|
||||
"price_atoms": "50000",
|
||||
"category": "market",
|
||||
"trial_free": 2,
|
||||
"description": "airdrop_check",
|
||||
},
|
||||
"airdrop_finder": {
|
||||
"price_usd": 0.05,
|
||||
"price_atoms": "50000",
|
||||
"category": "intelligence",
|
||||
"trial_free": 2,
|
||||
"description": "airdrop_finder",
|
||||
},
|
||||
"alpha_digest": {
|
||||
"price_usd": 0.1,
|
||||
"price_atoms": "100000",
|
||||
"category": "intelligence",
|
||||
"trial_free": 1,
|
||||
"description": "alpha_digest",
|
||||
},
|
||||
"anomaly": {
|
||||
"price_usd": 0.08,
|
||||
"price_atoms": "80000",
|
||||
"category": "security",
|
||||
"trial_free": 0,
|
||||
"description": "anomaly",
|
||||
},
|
||||
"arbitrage_scan": {
|
||||
"price_usd": 0.05,
|
||||
"price_atoms": "50000",
|
||||
"category": "market",
|
||||
"trial_free": 2,
|
||||
"description": "arbitrage_scan",
|
||||
},
|
||||
"arkham_counterparties": {
|
||||
"price_usd": 0.2,
|
||||
"price_atoms": "200000",
|
||||
"category": "elite",
|
||||
"trial_free": 0,
|
||||
"description": "Counterparty intelligence — entity relationship graph and money flow analysis",
|
||||
},
|
||||
"arkham_entity": {
|
||||
"price_usd": 0.1,
|
||||
"price_atoms": "100000",
|
||||
"category": "premium",
|
||||
"trial_free": 1,
|
||||
"description": "Entity resolution — map any address to its real-world owner with confidence scoring",
|
||||
},
|
||||
"arkham_labels": {
|
||||
"price_usd": 0.1,
|
||||
"price_atoms": "100000",
|
||||
"category": "premium",
|
||||
"trial_free": 1,
|
||||
"description": "Institutional entity labels — fund names, exchange wallets, known addresses",
|
||||
},
|
||||
"arkham_portfolio": {
|
||||
"price_usd": 0.25,
|
||||
"price_atoms": "250000",
|
||||
"category": "elite",
|
||||
"trial_free": 0,
|
||||
"description": "Institutional portfolio intelligence — complete holdings, historical performance, attribution",
|
||||
},
|
||||
"arkham_transfers": {
|
||||
"price_usd": 0.2,
|
||||
"price_atoms": "200000",
|
||||
"category": "elite",
|
||||
"trial_free": 0,
|
||||
"description": "Cross-chain transfer tracer — full movement history with entity labeling",
|
||||
},
|
||||
"audit": {
|
||||
"price_usd": 0.05,
|
||||
"price_atoms": "50000",
|
||||
"category": "security",
|
||||
"trial_free": 1,
|
||||
"description": "audit",
|
||||
},
|
||||
"bridge_security": {
|
||||
"price_usd": 0.08,
|
||||
"price_atoms": "80000",
|
||||
"category": "security",
|
||||
"trial_free": 0,
|
||||
"description": "bridge_security",
|
||||
},
|
||||
"bubble_map": {
|
||||
"price_usd": 0.02,
|
||||
"price_atoms": "20000",
|
||||
"category": "basic",
|
||||
"trial_free": 3,
|
||||
"description": "Holder concentration map — visualize whale clusters and distribution",
|
||||
},
|
||||
"bundle_detect": {
|
||||
"price_usd": 0.08,
|
||||
"price_atoms": "80000",
|
||||
"category": "premium",
|
||||
"trial_free": 1,
|
||||
"description": "Bot detector — same-block bundling, MEV patterns, sniper wallet identification",
|
||||
},
|
||||
"bundler_detect": {
|
||||
"price_usd": 0.08,
|
||||
"price_atoms": "80000",
|
||||
"category": "security",
|
||||
"trial_free": 1,
|
||||
"description": "Supply Manipulation Detector — bundled launches, sniper-clustered distributions, and multi-wallet insider patterns",
|
||||
},
|
||||
"catalog": {
|
||||
"price_usd": 0.0,
|
||||
"price_atoms": "0",
|
||||
"category": "api",
|
||||
"trial_free": 999,
|
||||
"description": "Browse available tools, pricing, and chain support",
|
||||
},
|
||||
"chain_health": {
|
||||
"price_usd": 0.05,
|
||||
"price_atoms": "50000",
|
||||
"category": "market",
|
||||
"trial_free": 0,
|
||||
"description": "chain_health",
|
||||
},
|
||||
"clone_detect": {
|
||||
"price_usd": 0.02,
|
||||
"price_atoms": "20000",
|
||||
"category": "security",
|
||||
"trial_free": 3,
|
||||
"description": "clone_detect",
|
||||
},
|
||||
"cluster": {
|
||||
"price_usd": 0.05,
|
||||
"price_atoms": "50000",
|
||||
"category": "intelligence",
|
||||
"trial_free": 0,
|
||||
"description": "cluster",
|
||||
},
|
||||
"composite_score": {
|
||||
"price_usd": 0.25,
|
||||
"price_atoms": "250000",
|
||||
"category": "premium",
|
||||
"trial_free": 1,
|
||||
"description": "RMI Composite Score — one number combining ALL signals for instant buy/sell/avoid decisions",
|
||||
},
|
||||
"comprehensive_audit": {
|
||||
"price_usd": 0.5,
|
||||
"price_atoms": "500000",
|
||||
"category": "security",
|
||||
"trial_free": 1,
|
||||
"description": "comprehensive_audit",
|
||||
},
|
||||
"contract_scan": {
|
||||
"price_usd": 0.08,
|
||||
"price_atoms": "80000",
|
||||
"category": "premium",
|
||||
"trial_free": 1,
|
||||
"description": "Deep contract audit — static analysis, honeypot detection, vulnerability scan",
|
||||
},
|
||||
"copy_trade_finder": {
|
||||
"price_usd": 0.1,
|
||||
"price_atoms": "100000",
|
||||
"category": "intelligence",
|
||||
"trial_free": 0,
|
||||
"description": "copy_trade_finder",
|
||||
},
|
||||
"cross_chain": {
|
||||
"price_usd": 0.08,
|
||||
"price_atoms": "80000",
|
||||
"category": "premium",
|
||||
"trial_free": 1,
|
||||
"description": "Cross-chain activity — find the same entity across multiple blockchains",
|
||||
},
|
||||
"defi_position": {
|
||||
"price_usd": 0.15,
|
||||
"price_atoms": "150000",
|
||||
"category": "defi",
|
||||
"trial_free": 1,
|
||||
"description": "DeFi position analyzer — LP holdings, impermanent loss estimation, yield sustainability, protocol risk",
|
||||
},
|
||||
"defi_protocols": {
|
||||
"price_usd": 0.01,
|
||||
"price_atoms": "10000",
|
||||
"category": "basic",
|
||||
"trial_free": 5,
|
||||
"description": "DeFi protocol tracker — TVL, chains, categories, revenue metrics",
|
||||
},
|
||||
"defi_yield_scanner": {
|
||||
"price_usd": 0.08,
|
||||
"price_atoms": "80000",
|
||||
"category": "market",
|
||||
"trial_free": 1,
|
||||
"description": "defi_yield_scanner",
|
||||
},
|
||||
"deployer_history": {
|
||||
"price_usd": 0.05,
|
||||
"price_atoms": "50000",
|
||||
"category": "security",
|
||||
"trial_free": 2,
|
||||
"description": "deployer_history",
|
||||
},
|
||||
"dex_data": {
|
||||
"price_usd": 0.01,
|
||||
"price_atoms": "10000",
|
||||
"category": "basic",
|
||||
"trial_free": 5,
|
||||
"description": "DEX pool data — liquidity depth, volume, price impact for any token",
|
||||
},
|
||||
"entity_intel": {
|
||||
"price_usd": 0.1,
|
||||
"price_atoms": "100000",
|
||||
"category": "premium",
|
||||
"trial_free": 1,
|
||||
"description": "Entity intelligence — who is this wallet, linked addresses, risk assessment",
|
||||
},
|
||||
"fee_manipulation_detect": {
|
||||
"price_usd": 0.08,
|
||||
"price_atoms": "80000",
|
||||
"category": "security",
|
||||
"trial_free": 1,
|
||||
"description": "Fee & Slippage Manipulation Detector — hidden token taxes, dynamic fees, cooldowns, blacklists, and sell traps",
|
||||
},
|
||||
"forensic_pack": {
|
||||
"price_usd": 0.35,
|
||||
"price_atoms": "350000",
|
||||
"category": "bundle",
|
||||
"trial_free": 1,
|
||||
"description": "Forensic Investigation Pack — valuation + OSINT + report at 33% discount",
|
||||
},
|
||||
"forensic_valuation": {
|
||||
"price_usd": 0.25,
|
||||
"price_atoms": "250000",
|
||||
"category": "premium",
|
||||
"trial_free": 1,
|
||||
"description": "Institutional-grade token valuation — DCF intrinsic value, comparable analysis with outlier detection, scam probability scoring",
|
||||
},
|
||||
"forensics": {
|
||||
"price_usd": 0.1,
|
||||
"price_atoms": "100000",
|
||||
"category": "analysis",
|
||||
"trial_free": 1,
|
||||
"description": "forensics",
|
||||
},
|
||||
"fresh_pair": {
|
||||
"price_usd": 0.03,
|
||||
"price_atoms": "30000",
|
||||
"category": "security",
|
||||
"trial_free": 3,
|
||||
"description": "fresh_pair",
|
||||
},
|
||||
"funding_source": {
|
||||
"price_usd": 0.08,
|
||||
"price_atoms": "80000",
|
||||
"category": "premium",
|
||||
"trial_free": 1,
|
||||
"description": "Trace where a wallet's funds came from — multi-hop origin analysis",
|
||||
},
|
||||
"gas_forecast": {
|
||||
"price_usd": 0.05,
|
||||
"price_atoms": "50000",
|
||||
"category": "market",
|
||||
"trial_free": 0,
|
||||
"description": "gas_forecast",
|
||||
},
|
||||
"gmgn_smart_money": {
|
||||
"price_usd": 0.05,
|
||||
"price_atoms": "50000",
|
||||
"category": "premium",
|
||||
"trial_free": 1,
|
||||
"description": "Smart money narratives — trending wallets and their trade patterns",
|
||||
},
|
||||
"history": {
|
||||
"price_usd": 0.08,
|
||||
"price_atoms": "80000",
|
||||
"category": "analysis",
|
||||
"trial_free": 2,
|
||||
"description": "Historical scanner time-series — risk/liquidity/volume/price trends over hours",
|
||||
},
|
||||
"honeypot_check": {
|
||||
"price_usd": 0.05,
|
||||
"price_atoms": "50000",
|
||||
"category": "security",
|
||||
"trial_free": 2,
|
||||
"description": "honeypot_check",
|
||||
},
|
||||
"human-execute": {
|
||||
"price_usd": 0.02,
|
||||
"price_atoms": "20000",
|
||||
"category": "api",
|
||||
"trial_free": 2,
|
||||
"description": "Human-in-the-loop execution — wallet-based payment for manual crypto investigation tasks",
|
||||
},
|
||||
"insider": {
|
||||
"price_usd": 0.1,
|
||||
"price_atoms": "100000",
|
||||
"category": "intelligence",
|
||||
"trial_free": 0,
|
||||
"description": "insider",
|
||||
},
|
||||
"insider_network": {
|
||||
"price_usd": 0.1,
|
||||
"price_atoms": "100000",
|
||||
"category": "intelligence",
|
||||
"trial_free": 0,
|
||||
"description": "insider_network",
|
||||
},
|
||||
"investigation_report": {
|
||||
"price_usd": 0.2,
|
||||
"price_atoms": "200000",
|
||||
"category": "premium",
|
||||
"trial_free": 1,
|
||||
"description": "Full investigation report — on-chain forensics, financial valuation, OSINT findings, scam scoring in one deliverable",
|
||||
},
|
||||
"kol_performance": {
|
||||
"price_usd": 0.1,
|
||||
"price_atoms": "100000",
|
||||
"category": "intelligence",
|
||||
"trial_free": 0,
|
||||
"description": "kol_performance",
|
||||
},
|
||||
"launch": {
|
||||
"price_usd": 0.03,
|
||||
"price_atoms": "30000",
|
||||
"category": "launchpad",
|
||||
"trial_free": 2,
|
||||
"description": "launch",
|
||||
},
|
||||
"launch_intel": {
|
||||
"price_usd": 0.05,
|
||||
"price_atoms": "50000",
|
||||
"category": "launchpad",
|
||||
"trial_free": 2,
|
||||
"description": "launch_intel",
|
||||
},
|
||||
"liquidity_depth": {
|
||||
"price_usd": 0.05,
|
||||
"price_atoms": "50000",
|
||||
"category": "market",
|
||||
"trial_free": 2,
|
||||
"description": "liquidity_depth",
|
||||
},
|
||||
"liquidity_flow": {
|
||||
"price_usd": 0.08,
|
||||
"price_atoms": "80000",
|
||||
"category": "intelligence",
|
||||
"trial_free": 0,
|
||||
"description": "liquidity_flow",
|
||||
},
|
||||
"liquidity_migration": {
|
||||
"price_usd": 0.05,
|
||||
"price_atoms": "50000",
|
||||
"category": "security",
|
||||
"trial_free": 2,
|
||||
"description": "liquidity_migration",
|
||||
},
|
||||
"listing_predictor": {
|
||||
"price_usd": 0.08,
|
||||
"price_atoms": "80000",
|
||||
"category": "intelligence",
|
||||
"trial_free": 1,
|
||||
"description": "listing_predictor",
|
||||
},
|
||||
"launch_fairness": {
|
||||
"price_usd": 0.10,
|
||||
"price_atoms": "100000",
|
||||
"category": "security",
|
||||
"trial_free": 2,
|
||||
"description": "Launch fairness & bot activity analyzer — sniped distributions, bundled launches, LP manipulation, bot activity, presale concentration with 0-100 fairness score",
|
||||
},
|
||||
"market_movers": {
|
||||
"price_usd": 0.01,
|
||||
"price_atoms": "10000",
|
||||
"category": "basic",
|
||||
"trial_free": 5,
|
||||
"description": "Top gainers, losers, and volume movers across all chains",
|
||||
},
|
||||
"market_overview": {
|
||||
"price_usd": 0.05,
|
||||
"price_atoms": "50000",
|
||||
"category": "market",
|
||||
"trial_free": 0,
|
||||
"description": "market_overview",
|
||||
},
|
||||
"mcp-proxy": {
|
||||
"price_usd": 0.01,
|
||||
"price_atoms": "10000",
|
||||
"category": "api",
|
||||
"trial_free": 5,
|
||||
"description": "MCP protocol proxy — route tool calls through the x402 payment layer",
|
||||
},
|
||||
"meme_vibe_score": {
|
||||
"price_usd": 0.01,
|
||||
"price_atoms": "10000",
|
||||
"category": "social",
|
||||
"trial_free": 3,
|
||||
"description": "Meme token vibe scoring — sentiment, community strength, and virality analysis",
|
||||
},
|
||||
"mev_alert": {
|
||||
"price_usd": 0.08,
|
||||
"price_atoms": "80000",
|
||||
"category": "security",
|
||||
"trial_free": 1,
|
||||
"description": "mev_alert",
|
||||
},
|
||||
"mev_detect": {
|
||||
"price_usd": 0.15,
|
||||
"price_atoms": "150000",
|
||||
"category": "security",
|
||||
"trial_free": 2,
|
||||
"description": "MEV/Sandwich attack detection — sandwich attacks, frontrunning, arbitrage extraction, MEV bot identification",
|
||||
},
|
||||
"mev_protection": {
|
||||
"price_usd": 0.08,
|
||||
"price_atoms": "80000",
|
||||
"category": "security",
|
||||
"trial_free": 0,
|
||||
"description": "mev_protection",
|
||||
},
|
||||
"nansen_labels": {
|
||||
"price_usd": 0.15,
|
||||
"price_atoms": "150000",
|
||||
"category": "elite",
|
||||
"trial_free": 0,
|
||||
"description": "Smart money labels — fund tags, whale classifications, and institutional wallet mapping",
|
||||
},
|
||||
"nansen_smart_money": {
|
||||
"price_usd": 0.15,
|
||||
"price_atoms": "150000",
|
||||
"category": "elite",
|
||||
"trial_free": 0,
|
||||
"description": "Smart money tracker — top trader activity, position tracking, alpha signals",
|
||||
},
|
||||
"narrative": {
|
||||
"price_usd": 0.05,
|
||||
"price_atoms": "50000",
|
||||
"category": "social",
|
||||
"trial_free": 3,
|
||||
"description": "Market narrative engine — what is the market saying about this token RIGHT NOW",
|
||||
},
|
||||
"news": {
|
||||
"price_usd": 0.01,
|
||||
"price_atoms": "10000",
|
||||
"category": "basic",
|
||||
"trial_free": 5,
|
||||
"description": "Crypto news feed — aggregated headlines, filtered by topic",
|
||||
},
|
||||
"nft_wash_detector": {
|
||||
"price_usd": 0.1,
|
||||
"price_atoms": "100000",
|
||||
"category": "analysis",
|
||||
"trial_free": 1,
|
||||
"description": "nft_wash_detector",
|
||||
},
|
||||
"osint_identity_hunt": {
|
||||
"price_usd": 0.15,
|
||||
"price_atoms": "150000",
|
||||
"category": "premium",
|
||||
"trial_free": 2,
|
||||
"description": "Cross-platform OSINT investigation — hunt usernames across 400+ networks, domain intelligence, stealth page capture",
|
||||
},
|
||||
"portfolio": {
|
||||
"price_usd": 0.15,
|
||||
"price_atoms": "150000",
|
||||
"category": "elite",
|
||||
"trial_free": 0,
|
||||
"description": "Multi-wallet portfolio — consolidated holdings, PnL, and risk across all wallets",
|
||||
},
|
||||
"portfolio_aggregate": {
|
||||
"price_usd": 0.1,
|
||||
"price_atoms": "100000",
|
||||
"category": "analysis",
|
||||
"trial_free": 1,
|
||||
"description": "portfolio_aggregate",
|
||||
},
|
||||
"portfolio_risk": {
|
||||
"price_usd": 0.2,
|
||||
"price_atoms": "200000",
|
||||
"category": "premium",
|
||||
"trial_free": 1,
|
||||
"description": "Cross-chain portfolio risk dashboard — unified risk across multiple wallets and chains",
|
||||
},
|
||||
"portfolio_tracker": {
|
||||
"price_usd": 0.1,
|
||||
"price_atoms": "100000",
|
||||
"category": "analysis",
|
||||
"trial_free": 0,
|
||||
"description": "portfolio_tracker",
|
||||
},
|
||||
"prediction_markets": {
|
||||
"price_usd": 0.02,
|
||||
"price_atoms": "20000",
|
||||
"category": "basic",
|
||||
"trial_free": 3,
|
||||
"description": "Prediction market odds — event probabilities and trading volumes",
|
||||
},
|
||||
"prediction_signals": {
|
||||
"price_usd": 0.02,
|
||||
"price_atoms": "20000",
|
||||
"category": "basic",
|
||||
"trial_free": 3,
|
||||
"description": "Trading signals — sentiment, momentum, and contrarian indicators",
|
||||
},
|
||||
"profile_flip": {
|
||||
"price_usd": 0.03,
|
||||
"price_atoms": "30000",
|
||||
"category": "security",
|
||||
"trial_free": 3,
|
||||
"description": "profile_flip",
|
||||
},
|
||||
"protocol_risk": {
|
||||
"price_usd": 0.08,
|
||||
"price_atoms": "80000",
|
||||
"category": "security",
|
||||
"trial_free": 1,
|
||||
"description": "protocol_risk",
|
||||
},
|
||||
"pulse": {
|
||||
"price_usd": 0.01,
|
||||
"price_atoms": "10000",
|
||||
"category": "market",
|
||||
"trial_free": 3,
|
||||
"description": "pulse",
|
||||
},
|
||||
"rag_search": {
|
||||
"price_usd": 0.05,
|
||||
"price_atoms": "50000",
|
||||
"category": "premium",
|
||||
"trial_free": 2,
|
||||
"description": "Knowledge search — query 17K+ crypto documents for research, analysis, and deep answers",
|
||||
},
|
||||
"reputation_score": {
|
||||
"price_usd": 0.1,
|
||||
"price_atoms": "100000",
|
||||
"category": "premium",
|
||||
"trial_free": 1,
|
||||
"description": "Comprehensive 0-100 trust score combining wallet labels, scam databases, deployer history, and RAG similarity matching",
|
||||
},
|
||||
"risk_monitor": {
|
||||
"price_usd": 0.05,
|
||||
"price_atoms": "50000",
|
||||
"category": "security",
|
||||
"trial_free": 1,
|
||||
"description": "risk_monitor",
|
||||
},
|
||||
"risk_scan": {
|
||||
"price_usd": 0.02,
|
||||
"price_atoms": "20000",
|
||||
"category": "basic",
|
||||
"trial_free": 3,
|
||||
"description": "Quick rug risk scan — honeypot, liquidity lock, ownership, and contract flags",
|
||||
},
|
||||
"rug_probability": {
|
||||
"price_usd": 0.15,
|
||||
"price_atoms": "150000",
|
||||
"category": "premium",
|
||||
"trial_free": 1,
|
||||
"description": "Predictive rug pull probability 0-100 — honeypot + liquidity + deployer + social signals",
|
||||
},
|
||||
"rug_pull_predictor": {
|
||||
"price_usd": 0.1,
|
||||
"price_atoms": "100000",
|
||||
"category": "security",
|
||||
"trial_free": 0,
|
||||
"description": "rug_pull_predictor",
|
||||
},
|
||||
"rugmaps_analysis": {
|
||||
"price_usd": 0.02,
|
||||
"price_atoms": "20000",
|
||||
"category": "basic",
|
||||
"trial_free": 3,
|
||||
"description": "Holder distribution analysis — risk scoring, dump patterns, concentration",
|
||||
},
|
||||
"rugshield": {
|
||||
"price_usd": 0.02,
|
||||
"price_atoms": "20000",
|
||||
"category": "security",
|
||||
"trial_free": 3,
|
||||
"description": "rugshield",
|
||||
},
|
||||
"scam_database": {
|
||||
"price_usd": 0.03,
|
||||
"price_atoms": "30000",
|
||||
"category": "security",
|
||||
"trial_free": 3,
|
||||
"description": "scam_database",
|
||||
},
|
||||
"sentiment": {
|
||||
"price_usd": 0.03,
|
||||
"price_atoms": "30000",
|
||||
"category": "social",
|
||||
"trial_free": 0,
|
||||
"description": "sentiment",
|
||||
},
|
||||
"sentiment_spike": {
|
||||
"price_usd": 0.05,
|
||||
"price_atoms": "50000",
|
||||
"category": "social",
|
||||
"trial_free": 2,
|
||||
"description": "sentiment_spike",
|
||||
},
|
||||
"sentinel_deep": {
|
||||
"price_usd": 0.1,
|
||||
"price_atoms": "100000",
|
||||
"category": "premium",
|
||||
"trial_free": 1,
|
||||
"description": "Full threat scan — deep contract analysis, risk scoring, threat intelligence",
|
||||
},
|
||||
"smart_money": {
|
||||
"price_usd": 0.2,
|
||||
"price_atoms": "200000",
|
||||
"category": "intelligence",
|
||||
"trial_free": 1,
|
||||
"description": "Smart Money P&L Tracker — real profitability-based wallet tracking, find the actual profitable traders",
|
||||
},
|
||||
"smart_money_alpha": {
|
||||
"price_usd": 0.01,
|
||||
"price_atoms": "10000",
|
||||
"category": "intelligence",
|
||||
"trial_free": 3,
|
||||
"description": "Smart money alpha signals — track wallets that consistently outperform the market",
|
||||
},
|
||||
"smartmoney": {
|
||||
"price_usd": 0.05,
|
||||
"price_atoms": "50000",
|
||||
"category": "intelligence",
|
||||
"trial_free": 1,
|
||||
"description": "smartmoney",
|
||||
},
|
||||
"sniper_alert": {
|
||||
"price_usd": 0.05,
|
||||
"price_atoms": "50000",
|
||||
"category": "launchpad",
|
||||
"trial_free": 2,
|
||||
"description": "sniper_alert",
|
||||
},
|
||||
"sniper_detect": {
|
||||
"price_usd": 0.08,
|
||||
"price_atoms": "80000",
|
||||
"category": "intelligence",
|
||||
"trial_free": 1,
|
||||
"description": "sniper_detect",
|
||||
},
|
||||
"social_feed": {
|
||||
"price_usd": 0.01,
|
||||
"price_atoms": "10000",
|
||||
"category": "basic",
|
||||
"trial_free": 5,
|
||||
"description": "Social sentiment feed — what crypto Twitter and Telegram are saying",
|
||||
},
|
||||
"social_signal": {
|
||||
"price_usd": 0.1,
|
||||
"price_atoms": "100000",
|
||||
"category": "social",
|
||||
"trial_free": 0,
|
||||
"description": "social_signal",
|
||||
},
|
||||
"socialfi_resolve": {
|
||||
"price_usd": 0.01,
|
||||
"price_atoms": "10000",
|
||||
"category": "basic",
|
||||
"trial_free": 3,
|
||||
"description": "Resolve social identity — ENS names, Farcaster profiles, linked addresses",
|
||||
},
|
||||
"syndicate_scan": {
|
||||
"price_usd": 0.08,
|
||||
"price_atoms": "80000",
|
||||
"category": "intelligence",
|
||||
"trial_free": 1,
|
||||
"description": "syndicate_scan",
|
||||
},
|
||||
"syndicate_track": {
|
||||
"price_usd": 0.1,
|
||||
"price_atoms": "100000",
|
||||
"category": "intelligence",
|
||||
"trial_free": 1,
|
||||
"description": "syndicate_track",
|
||||
},
|
||||
"threat_check": {
|
||||
"price_usd": 0.02,
|
||||
"price_atoms": "20000",
|
||||
"category": "basic",
|
||||
"trial_free": 3,
|
||||
"description": "Threat intelligence check — known scams, malicious patterns, risk scoring",
|
||||
},
|
||||
"token_age": {
|
||||
"price_usd": 0.01,
|
||||
"price_atoms": "10000",
|
||||
"category": "security",
|
||||
"trial_free": 5,
|
||||
"description": "token_age",
|
||||
},
|
||||
"token_comparison": {
|
||||
"price_usd": 0.08,
|
||||
"price_atoms": "80000",
|
||||
"category": "analysis",
|
||||
"trial_free": 0,
|
||||
"description": "token_comparison",
|
||||
},
|
||||
"token_deep_dive": {
|
||||
"price_usd": 0.1,
|
||||
"price_atoms": "100000",
|
||||
"category": "analysis",
|
||||
"trial_free": 0,
|
||||
"description": "token_deep_dive",
|
||||
},
|
||||
"token_detail": {
|
||||
"price_usd": 0.02,
|
||||
"price_atoms": "20000",
|
||||
"category": "basic",
|
||||
"trial_free": 3,
|
||||
"description": "Full token intelligence — market cap, volume, liquidity, holders, risk flags",
|
||||
},
|
||||
"token_price": {
|
||||
"price_usd": 0.01,
|
||||
"price_atoms": "10000",
|
||||
"category": "basic",
|
||||
"trial_free": 5,
|
||||
"description": "Get real-time token price with consensus from multiple sources",
|
||||
},
|
||||
"token_watch_alerts": {
|
||||
"price_usd": 0.0,
|
||||
"price_atoms": "0",
|
||||
"category": "monitoring",
|
||||
"trial_free": 999,
|
||||
"description": "token_watch_alerts",
|
||||
},
|
||||
"token_watch_check": {
|
||||
"price_usd": 0.03,
|
||||
"price_atoms": "30000",
|
||||
"category": "monitoring",
|
||||
"trial_free": 5,
|
||||
"description": "One-shot token status check — current LP, price, volume, and rug risk warnings",
|
||||
},
|
||||
"token_watch_create": {
|
||||
"price_usd": 0.05,
|
||||
"price_atoms": "50000",
|
||||
"category": "monitoring",
|
||||
"trial_free": 3,
|
||||
"description": "Set token monitoring watch — alerts when LP drops, price changes, or rug indicators detected",
|
||||
},
|
||||
"token_watch_list": {
|
||||
"price_usd": 0.0,
|
||||
"price_atoms": "0",
|
||||
"category": "monitoring",
|
||||
"trial_free": 999,
|
||||
"description": "token_watch_list",
|
||||
},
|
||||
"trending": {
|
||||
"price_usd": 0.01,
|
||||
"price_atoms": "10000",
|
||||
"category": "basic",
|
||||
"trial_free": 5,
|
||||
"description": "Trending tokens across chains — hottest movers right now",
|
||||
},
|
||||
"tvl": {
|
||||
"price_usd": 0.01,
|
||||
"price_atoms": "10000",
|
||||
"category": "basic",
|
||||
"trial_free": 5,
|
||||
"description": "DeFi TVL data — protocol-level totals, chain breakdowns, yields",
|
||||
},
|
||||
"tw_profile": {
|
||||
"price_usd": 0.01,
|
||||
"price_atoms": "10000",
|
||||
"category": "social",
|
||||
"trial_free": 0,
|
||||
"description": "tw_profile",
|
||||
},
|
||||
"tw_search": {
|
||||
"price_usd": 0.01,
|
||||
"price_atoms": "10000",
|
||||
"category": "social",
|
||||
"trial_free": 0,
|
||||
"description": "tw_search",
|
||||
},
|
||||
"tw_timeline": {
|
||||
"price_usd": 0.01,
|
||||
"price_atoms": "10000",
|
||||
"category": "social",
|
||||
"trial_free": 0,
|
||||
"description": "tw_timeline",
|
||||
},
|
||||
"unlock_calendar": {
|
||||
"price_usd": 0.03,
|
||||
"price_atoms": "30000",
|
||||
"category": "market",
|
||||
"trial_free": 3,
|
||||
"description": "unlock_calendar",
|
||||
},
|
||||
"urlcheck": {
|
||||
"price_usd": 0.01,
|
||||
"price_atoms": "10000",
|
||||
"category": "security",
|
||||
"trial_free": 3,
|
||||
"description": "urlcheck",
|
||||
},
|
||||
"wallet": {
|
||||
"price_usd": 0.05,
|
||||
"price_atoms": "50000",
|
||||
"category": "analysis",
|
||||
"trial_free": 1,
|
||||
"description": "wallet",
|
||||
},
|
||||
"wallet_balance": {
|
||||
"price_usd": 0.01,
|
||||
"price_atoms": "10000",
|
||||
"category": "basic",
|
||||
"trial_free": 3,
|
||||
"description": "Check any wallet's balance across chains — multi-chain support",
|
||||
},
|
||||
"wallet_cluster": {
|
||||
"price_usd": 0.08,
|
||||
"price_atoms": "80000",
|
||||
"category": "premium",
|
||||
"trial_free": 1,
|
||||
"description": "Syndicate mapper — find related wallets via funding patterns and heuristics",
|
||||
},
|
||||
"wallet_graph": {
|
||||
"price_usd": 0.1,
|
||||
"price_atoms": "100000",
|
||||
"category": "intelligence",
|
||||
"trial_free": 0,
|
||||
"description": "wallet_graph",
|
||||
},
|
||||
"wallet_labels": {
|
||||
"price_usd": 0.02,
|
||||
"price_atoms": "20000",
|
||||
"category": "basic",
|
||||
"trial_free": 3,
|
||||
"description": "Identify who owns a wallet — entity labels, tags, and known affiliations",
|
||||
},
|
||||
"wallet_pnl": {
|
||||
"price_usd": 0.1,
|
||||
"price_atoms": "100000",
|
||||
"category": "analysis",
|
||||
"trial_free": 0,
|
||||
"description": "wallet_pnl",
|
||||
},
|
||||
"wallet_profile": {
|
||||
"price_usd": 0.05,
|
||||
"price_atoms": "50000",
|
||||
"category": "premium",
|
||||
"trial_free": 1,
|
||||
"description": "Complete wallet profile — labels, PnL summary, risk score, related wallets",
|
||||
},
|
||||
"wallet_tokens": {
|
||||
"price_usd": 0.05,
|
||||
"price_atoms": "50000",
|
||||
"category": "premium",
|
||||
"trial_free": 1,
|
||||
"description": "All tokens held by a wallet — balances, USD values, allocation breakdown",
|
||||
},
|
||||
"wash_trade_detect": {
|
||||
"price_usd": 0.15,
|
||||
"price_atoms": "150000",
|
||||
"category": "security",
|
||||
"trial_free": 2,
|
||||
"description": "Wash Trading & Insider Detection — artificial volume, coordinated buying, insider accumulation patterns",
|
||||
},
|
||||
"wash_trading": {
|
||||
"price_usd": 0.08,
|
||||
"price_atoms": "80000",
|
||||
"category": "security",
|
||||
"trial_free": 1,
|
||||
"description": "wash_trading",
|
||||
},
|
||||
"webhook_list": {
|
||||
"price_usd": 0.0,
|
||||
"price_atoms": "0",
|
||||
"category": "monitoring",
|
||||
"trial_free": 999,
|
||||
"description": "List registered webhooks for an address",
|
||||
},
|
||||
"webhook_register": {
|
||||
"price_usd": 0.02,
|
||||
"price_atoms": "20000",
|
||||
"category": "monitoring",
|
||||
"trial_free": 2,
|
||||
"description": "Register webhook URL for real-time monitoring alerts — rug pulls, whale moves, price crashes",
|
||||
},
|
||||
"whale": {
|
||||
"price_usd": 0.15,
|
||||
"price_atoms": "150000",
|
||||
"category": "intelligence",
|
||||
"trial_free": 1,
|
||||
"description": "whale",
|
||||
},
|
||||
"whale_accumulation": {
|
||||
"price_usd": 0.08,
|
||||
"price_atoms": "80000",
|
||||
"category": "intelligence",
|
||||
"trial_free": 1,
|
||||
"description": "whale_accumulation",
|
||||
},
|
||||
"whale_profile": {
|
||||
"price_usd": 0.05,
|
||||
"price_atoms": "50000",
|
||||
"category": "intelligence",
|
||||
"trial_free": 2,
|
||||
"description": "whale_profile",
|
||||
},
|
||||
"whale_scan": {
|
||||
"price_usd": 0.03,
|
||||
"price_atoms": "30000",
|
||||
"category": "intelligence",
|
||||
"trial_free": 3,
|
||||
"description": "whale_scan",
|
||||
},
|
||||
"rug_imminence": {
|
||||
"price_usd": 0.20,
|
||||
"price_atoms": "200000",
|
||||
"category": "security",
|
||||
"trial_free": 1,
|
||||
"description": "Rug Pull Imminence Predictor — AI-powered early warning system fusing 7 signals to predict imminent rug pulls before they happen",
|
||||
},
|
||||
"liquidation_cascade": {
|
||||
"price_usd": 0.15,
|
||||
"price_atoms": "150000",
|
||||
"category": "defi",
|
||||
"trial_free": 1,
|
||||
"description": "Liquidation Cascade Risk Analyzer — cross-chain DeFi position monitoring, health factor computation, and cascade scenario simulation for Aave/Compound positions",
|
||||
},
|
||||
"bridge_health": {
|
||||
"price_usd": 0.10,
|
||||
"price_atoms": "100000",
|
||||
"category": "security",
|
||||
"trial_free": 2,
|
||||
"description": "Cross-Chain Bridge Health & Exploit Monitor — real-time TVL tracking, anomaly detection, trust model scoring, and exploit signal detection across 12 major bridges (LayerZero, Stargate, Wormhole, Across, Hop, Synapse, Axelar, Celer, DeBridge, CCIP, Connext, Orbiter)",
|
||||
},
|
||||
}
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
"""Catalog domain — the unified read/write API for RMI.
|
||||
|
||||
T27 of the v4.0 guide. Every store read/write goes through CatalogService.
|
||||
Domain facades call the catalog; they never touch stores directly.
|
||||
|
||||
Architecture (per v4.0 §T27):
|
||||
app/catalog/models.py Pydantic v2 entity schemas
|
||||
app/catalog/service.py CatalogService — fan-out reads, fan-in writes
|
||||
app/catalog/reputation.py Deployer reputation scoring (T31)
|
||||
app/catalog/rag_bridge.py Bridge to existing app/rag/ engine
|
||||
app/catalog/llm_router.py LiteLLM proxy for AI analysis
|
||||
|
||||
Cross-store ref shape (per v4.0):
|
||||
"chain:address" Wallet, Token, Contract IDs
|
||||
UUID Entity, Alert, NewsItem, RAGFinding, Report IDs
|
||||
Qdrant point_id RAGFinding.vector_id, rag_embedding_id on Token
|
||||
|
||||
Public API (re-exported):
|
||||
CatalogService, get_catalog
|
||||
Entity, Wallet, Deployer, Token, Alert, NewsItem, RAGFinding, ScanReport
|
||||
DeployerReputation, RECIPES
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.core import health as health_mod
|
||||
from app.core.health import DomainHealth
|
||||
|
||||
|
||||
async def _health_check() -> DomainHealth:
|
||||
"""Catalog health: which stores are reachable + how many entities."""
|
||||
try:
|
||||
from app.catalog.service import get_catalog
|
||||
|
||||
cat = get_catalog()
|
||||
reach = await cat.probe_stores()
|
||||
healthy = any(reach.values())
|
||||
return DomainHealth(
|
||||
name="catalog",
|
||||
healthy=healthy,
|
||||
details={
|
||||
"stores_reachable": {k: v for k, v in reach.items()},
|
||||
"primary": "redis+rag" if healthy else "none",
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
return DomainHealth(name="catalog", healthy=False, error=str(e))
|
||||
|
||||
|
||||
health_mod.register_health_check("catalog", _health_check)
|
||||
|
||||
|
||||
# Public API
|
||||
from app.catalog.models import ( # noqa: E402
|
||||
COLLECTIONS,
|
||||
CHAIN_REGISTRY,
|
||||
Chain,
|
||||
Entity,
|
||||
EntityLabel,
|
||||
RiskTier,
|
||||
Token,
|
||||
Wallet,
|
||||
Deployer,
|
||||
Alert,
|
||||
NewsItem,
|
||||
RAGFinding,
|
||||
ScanReport,
|
||||
)
|
||||
from app.catalog.service import CatalogService, get_catalog # noqa: E402
|
||||
|
||||
__all__ = [
|
||||
"CatalogService",
|
||||
"get_catalog",
|
||||
"Entity",
|
||||
"Wallet",
|
||||
"Deployer",
|
||||
"Token",
|
||||
"Alert",
|
||||
"NewsItem",
|
||||
"RAGFinding",
|
||||
"ScanReport",
|
||||
"EntityLabel",
|
||||
"Chain",
|
||||
"RiskTier",
|
||||
"COLLECTIONS",
|
||||
"CHAIN_REGISTRY",
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,172 +0,0 @@
|
|||
"""Catalog database schema — initial migration.
|
||||
|
||||
Creates the tables referenced by the v4.0 catalog models.
|
||||
Idempotent: safe to run multiple times.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
import asyncpg
|
||||
|
||||
|
||||
SCHEMA = """
|
||||
-- Tokens (T27)
|
||||
CREATE TABLE IF NOT EXISTS tokens (
|
||||
token_id TEXT PRIMARY KEY,
|
||||
chain TEXT NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
symbol TEXT,
|
||||
name TEXT,
|
||||
decimals INT,
|
||||
deployer_wallet_id TEXT,
|
||||
deployed_at TIMESTAMPTZ NOT NULL,
|
||||
initial_supply BIGINT,
|
||||
current_supply BIGINT,
|
||||
is_honeypot BOOLEAN,
|
||||
is_mintable BOOLEAN,
|
||||
is_proxy BOOLEAN,
|
||||
tax_buy_bps INT,
|
||||
tax_sell_bps INT,
|
||||
risk_tier TEXT,
|
||||
risk_score INT,
|
||||
risk_factors TEXT[],
|
||||
rag_embedding_id TEXT,
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tokens_chain ON tokens(chain);
|
||||
CREATE INDEX IF NOT EXISTS idx_tokens_deployer ON tokens(deployer_wallet_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tokens_risk ON tokens(risk_score DESC) WHERE risk_score IS NOT NULL;
|
||||
|
||||
-- Alerts
|
||||
CREATE TABLE IF NOT EXISTS alerts (
|
||||
alert_id TEXT PRIMARY KEY,
|
||||
token_id TEXT,
|
||||
wallet_id TEXT,
|
||||
chain TEXT,
|
||||
alert_type TEXT NOT NULL,
|
||||
severity TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
evidence JSONB DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
resolved_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_alerts_token ON alerts(token_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_alerts_wallet ON alerts(wallet_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_alerts_created ON alerts(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_alerts_severity ON alerts(severity);
|
||||
|
||||
-- News items (T28)
|
||||
CREATE TABLE IF NOT EXISTS news_items (
|
||||
news_id TEXT PRIMARY KEY,
|
||||
url TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
summary TEXT,
|
||||
body_markdown TEXT,
|
||||
source TEXT NOT NULL,
|
||||
published_at TIMESTAMPTZ NOT NULL,
|
||||
ingested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
chains_mentioned TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
tokens_mentioned TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
wallets_mentioned TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
sentiment_score REAL,
|
||||
ai_analysis TEXT,
|
||||
rag_embedding_id TEXT,
|
||||
body_tsv tsvector
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_news_published ON news_items(published_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_news_source ON news_items(source);
|
||||
CREATE INDEX IF NOT EXISTS idx_news_sentiment ON news_items(sentiment_score);
|
||||
CREATE INDEX IF NOT EXISTS idx_news_body_tsv ON news_items USING gin(body_tsv);
|
||||
|
||||
-- Auto-update the body_tsv column on insert/update
|
||||
CREATE OR REPLACE FUNCTION news_items_tsv_update() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.body_tsv := to_tsvector('english', COALESCE(NEW.title, '') || ' ' ||
|
||||
COALESCE(NEW.summary, '') || ' ' ||
|
||||
COALESCE(NEW.body_markdown, ''));
|
||||
RETURN NEW;
|
||||
END
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS news_items_tsv_trigger ON news_items;
|
||||
CREATE TRIGGER news_items_tsv_trigger
|
||||
BEFORE INSERT OR UPDATE ON news_items
|
||||
FOR EACH ROW EXECUTE FUNCTION news_items_tsv_update();
|
||||
|
||||
-- Scan reports (T29)
|
||||
CREATE TABLE IF NOT EXISTS scan_reports (
|
||||
report_id TEXT PRIMARY KEY,
|
||||
subject_type TEXT NOT NULL,
|
||||
subject_id TEXT NOT NULL,
|
||||
generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
generated_by_model TEXT NOT NULL,
|
||||
risk_score INT NOT NULL,
|
||||
risk_tier TEXT NOT NULL,
|
||||
sections JSONB DEFAULT '{}'::jsonb,
|
||||
markdown_url TEXT,
|
||||
paid_via_x402 TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_reports_subject ON scan_reports(subject_type, subject_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_reports_generated ON scan_reports(generated_at DESC);
|
||||
|
||||
-- RAG findings metadata (Qdrant has the vectors; this is metadata for query)
|
||||
CREATE TABLE IF NOT EXISTS rag_findings (
|
||||
finding_id TEXT PRIMARY KEY,
|
||||
source_type TEXT NOT NULL,
|
||||
source_url TEXT,
|
||||
source_token_id TEXT,
|
||||
source_wallet_id TEXT,
|
||||
claim TEXT NOT NULL,
|
||||
confidence REAL NOT NULL,
|
||||
extracted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
qdrant_point_id TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_findings_token ON rag_findings(source_token_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_findings_wallet ON rag_findings(source_wallet_id);
|
||||
|
||||
-- x402 receipts (T34)
|
||||
CREATE TABLE IF NOT EXISTS x402_receipts (
|
||||
tx_hash TEXT PRIMARY KEY,
|
||||
agent_id TEXT,
|
||||
tool TEXT NOT NULL,
|
||||
amount_usd REAL NOT NULL,
|
||||
chain TEXT,
|
||||
paid_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
tier TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_x402_paid_at ON x402_receipts(paid_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_x402_tool ON x402_receipts(tool);
|
||||
CREATE INDEX IF NOT EXISTS idx_x402_agent ON x402_receipts(agent_id);
|
||||
"""
|
||||
|
||||
|
||||
async def main():
|
||||
"""Run the schema migration."""
|
||||
cfg = {
|
||||
"host": os.getenv("POSTGRES_HOST", "rmi-postgres"),
|
||||
"port": int(os.getenv("POSTGRES_PORT", "5432")),
|
||||
"user": os.getenv("POSTGRES_USER", "rmi"),
|
||||
"password": os.getenv("POSTGRES_PASSWORD", "RMI_PROD_POSTGRES_2026"),
|
||||
"database": os.getenv("POSTGRES_DB", "rmi"),
|
||||
}
|
||||
print(f"Connecting to postgres at {cfg['host']}:{cfg['port']} db={cfg['database']}")
|
||||
conn = await asyncpg.connect(**cfg)
|
||||
try:
|
||||
await conn.execute(SCHEMA)
|
||||
print("✓ Schema applied")
|
||||
# Verify
|
||||
tables = await conn.fetch(
|
||||
"SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename"
|
||||
)
|
||||
print(f"Tables ({len(tables)}):")
|
||||
for t in tables:
|
||||
print(f" {t['tablename']}")
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
"""T27 LLM Router — sovereign-first LiteLLM proxy for catalog AI.
|
||||
|
||||
Per v4.0 §T28 (News analysis), §T29 (Report generation).
|
||||
|
||||
Self-hosted LiteLLM proxy at litellm.rugmunch.io routes to:
|
||||
- DeepSeek-V3 (cost-effective analysis)
|
||||
- Qwen (summaries)
|
||||
- Local Llama-3 (free-tier fallback)
|
||||
|
||||
We never call OpenAI directly. If LiteLLM is unreachable, the catalog
|
||||
operations that need LLM analysis return None/empty with a logged warning
|
||||
rather than failing the whole request.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Config ─────────────────────────────────────────────────────────
|
||||
LITELLM_URL = os.getenv("LITELLM_URL", "http://litellm.rugmunch.io")
|
||||
LITELLM_API_KEY = os.getenv("LITELLM_API_KEY", "")
|
||||
DEFAULT_MODEL = os.getenv("LITELLM_DEFAULT_MODEL", "deepseek-v3")
|
||||
|
||||
NEWS_ANALYSIS_PROMPT = """You are an analyst at RugMunch Intelligence, a crypto
|
||||
scam-detection platform. Analyze the following news item and produce a
|
||||
structured Markdown summary.
|
||||
|
||||
NEWS ITEM:
|
||||
- Title: {title}
|
||||
- Source: {source}
|
||||
- Published: {published_at}
|
||||
- Body: {body_truncated_to_2000_chars}
|
||||
|
||||
Produce a summary with these sections (use Markdown headers):
|
||||
|
||||
## Summary
|
||||
2-3 sentence plain-English summary.
|
||||
|
||||
## Affected Tokens
|
||||
List any tokens mentioned, with their chain and address if known.
|
||||
|
||||
## Affected Wallets
|
||||
List any wallets mentioned.
|
||||
|
||||
## Sentiment
|
||||
One of: bullish | bearish | neutral | risk-elevating | risk-reducing
|
||||
1-sentence justification.
|
||||
|
||||
## RugMunch Action
|
||||
What should our platform do in response? Options:
|
||||
- (none)
|
||||
- (flag mentioned tokens for re-scan)
|
||||
- (alert subscribers)
|
||||
- (update deployer reputation)
|
||||
- (cross-chain entity resolution trigger)
|
||||
|
||||
Be concise. Do not speculate beyond what the article says.
|
||||
"""
|
||||
|
||||
|
||||
class LLMRouter:
|
||||
"""Async client for the self-hosted LiteLLM proxy.
|
||||
|
||||
Falls back to None if the proxy is unreachable — catalog operations
|
||||
that need LLM output will skip the AI analysis but still complete
|
||||
the rest of the workflow.
|
||||
"""
|
||||
|
||||
def __init__(self, url: str | None = None, api_key: str | None = None) -> None:
|
||||
self.url = (url or LITELLM_URL).rstrip("/")
|
||||
self.api_key = api_key or LITELLM_API_KEY
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
|
||||
async def _get_client(self) -> httpx.AsyncClient:
|
||||
if self._client is None:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if self.api_key:
|
||||
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=self.url, headers=headers, timeout=30.0
|
||||
)
|
||||
return self._client
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
prompt: str,
|
||||
model: str | None = None,
|
||||
max_tokens: int = 800,
|
||||
temperature: float = 0.3,
|
||||
) -> str | None:
|
||||
"""Send a chat completion. Returns text or None on failure."""
|
||||
try:
|
||||
client = await self._get_client()
|
||||
r = await client.post(
|
||||
"/chat/completions",
|
||||
json={
|
||||
"model": model or DEFAULT_MODEL,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
},
|
||||
)
|
||||
if r.status_code != 200:
|
||||
log.warning("llm_http_%d: %s", r.status_code, r.text[:200])
|
||||
return None
|
||||
data = r.json()
|
||||
return data["choices"][0]["message"]["content"]
|
||||
except Exception as e:
|
||||
log.warning("llm_chat_fail: %s", e)
|
||||
return None
|
||||
|
||||
async def analyze_news(
|
||||
self, news_item: "NewsItem" # type: ignore # noqa: F821
|
||||
) -> str | None:
|
||||
"""Generate AI analysis for a NewsItem. Per v4.0 §T28."""
|
||||
prompt = NEWS_ANALYSIS_PROMPT.format(
|
||||
title=news_item.title,
|
||||
source=news_item.source,
|
||||
published_at=news_item.published_at.isoformat(),
|
||||
body_truncated_to_2000_chars=(news_item.body_markdown or news_item.summary)[:2000],
|
||||
)
|
||||
return await self.chat(prompt, max_tokens=800, temperature=0.3)
|
||||
|
|
@ -1,323 +0,0 @@
|
|||
"""T27A — Canonical entity models for the RMI data catalog.
|
||||
|
||||
Pydantic v2 (per ADR-0002). Every entity is persisted in exactly one primary
|
||||
store, with cross-store references (string IDs) to related entities in other
|
||||
stores. CatalogService resolves these references transparently.
|
||||
|
||||
Cross-store ID conventions:
|
||||
Token, Wallet, Contract: f"{chain.value}:{address}"
|
||||
Entity, Alert, NewsItem, RAGFinding, ScanReport: UUID4 hex
|
||||
Qdrant point_id: 16-byte UUID hex (matches RAG engine)
|
||||
|
||||
Persistence (per v4.0 §T27 "Why each store exists"):
|
||||
Redis: hot token data, rate limits, alert state, cron locks
|
||||
Postgres: users, api_keys, subscriptions, x402 receipts, audit
|
||||
alerts, news_items, scan_reports
|
||||
Neo4j: entities, wallets, deployers, contracts, entity labels
|
||||
(graph traversal, Cypher)
|
||||
Qdrant: RAG embeddings, token similarity, news embeddings
|
||||
MinIO: news raw HTML, report markdown, RAG source docs
|
||||
Filesystem: ingest tmp, log rotation (transient)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, UTC
|
||||
from enum import Enum
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, HttpUrl, field_validator
|
||||
|
||||
|
||||
# ── Enums ───────────────────────────────────────────────────────────
|
||||
class Chain(str, Enum):
|
||||
"""All chains the platform indexes. Per v4.0 §T27."""
|
||||
|
||||
SOLANA = "solana"
|
||||
ETHEREUM = "ethereum"
|
||||
BASE = "base"
|
||||
ARBITRUM = "arbitrum"
|
||||
OPTIMISM = "optimism"
|
||||
POLYGON = "polygon"
|
||||
BSC = "bsc"
|
||||
TRON = "tron"
|
||||
BITCOIN = "bitcoin"
|
||||
AVALANCHE = "avalanche"
|
||||
FANTOM = "fantom"
|
||||
GNOSIS = "gnosis"
|
||||
# EVM subnets (sample — full 96 in CHAIN_REGISTRY)
|
||||
SEPOLIA = "sepolia"
|
||||
LINEA = "linea"
|
||||
SCROLL = "scroll"
|
||||
ZKSYNC = "zksync"
|
||||
BLAST = "blast"
|
||||
MANTLE = "mantle"
|
||||
|
||||
|
||||
# Full chain registry — v4.0 says 96 chains. We track the canonical 20 here
|
||||
# plus extend via CHAIN_REGISTRY for the remaining 76. Adding a chain is a
|
||||
# one-line edit.
|
||||
CHAIN_REGISTRY: dict[str, dict[str, str]] = {
|
||||
"solana": {"name": "Solana", "type": "svm", "native": "SOL", "explorer": "https://solscan.io"},
|
||||
"ethereum": {"name": "Ethereum", "type": "evm", "native": "ETH", "explorer": "https://etherscan.io"},
|
||||
"base": {"name": "Base", "type": "evm", "native": "ETH", "explorer": "https://basescan.org"},
|
||||
"arbitrum": {"name": "Arbitrum One", "type": "evm", "native": "ETH", "explorer": "https://arbiscan.io"},
|
||||
"optimism": {"name": "Optimism", "type": "evm", "native": "ETH", "explorer": "https://optimistic.etherscan.io"},
|
||||
"polygon": {"name": "Polygon", "type": "evm", "native": "MATIC", "explorer": "https://polygonscan.com"},
|
||||
"bsc": {"name": "BNB Smart Chain", "type": "evm", "native": "BNB", "explorer": "https://bscscan.com"},
|
||||
"tron": {"name": "TRON", "type": "tvm", "native": "TRX", "explorer": "https://tronscan.org"},
|
||||
"bitcoin": {"name": "Bitcoin", "type": "utxo", "native": "BTC", "explorer": "https://mempool.space"},
|
||||
"avalanche": {"name": "Avalanche C-Chain", "type": "evm", "native": "AVAX", "explorer": "https://snowtrace.io"},
|
||||
"fantom": {"name": "Fantom Opera", "type": "evm", "native": "FTM", "explorer": "https://ftmscan.com"},
|
||||
"gnosis": {"name": "Gnosis Chain", "type": "evm", "native": "xDAI", "explorer": "https://gnosisscan.io"},
|
||||
}
|
||||
|
||||
|
||||
class RiskTier(str, Enum):
|
||||
LOW = "low"
|
||||
MEDIUM = "medium"
|
||||
HIGH = "high"
|
||||
CRITICAL = "critical"
|
||||
|
||||
|
||||
# ── Entities (Neo4j primary) ───────────────────────────────────────
|
||||
class Entity(BaseModel):
|
||||
"""A logical entity resolved across chains. Neo4j primary."""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
entity_id: str = Field(..., description="UUID, Neo4j primary key")
|
||||
label: Optional[str] = None
|
||||
aliases: list[str] = Field(default_factory=list)
|
||||
first_seen: datetime
|
||||
last_seen: datetime
|
||||
risk_score: Optional[int] = Field(None, ge=0, le=100)
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
notes: Optional[str] = None
|
||||
store: Literal["neo4j"] = "neo4j"
|
||||
|
||||
|
||||
class EntityLabel(BaseModel):
|
||||
"""A label attached to an entity. Neo4j primary."""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
entity_id: str
|
||||
label: str
|
||||
source: Literal["manual", "heuristic", "third_party"]
|
||||
confidence: float = Field(ge=0.0, le=1.0)
|
||||
added_at: datetime
|
||||
added_by: str
|
||||
store: Literal["neo4j"] = "neo4j"
|
||||
|
||||
|
||||
# ── Wallets + Deployers (Neo4j primary) ───────────────────────────
|
||||
class Wallet(BaseModel):
|
||||
"""An on-chain wallet. Neo4j primary; linked to Entity."""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
wallet_id: str = Field(..., description='"chain:address", e.g. "solana:7Np41..."')
|
||||
chain: Chain
|
||||
address: str
|
||||
entity_id: Optional[str] = None
|
||||
first_seen: datetime
|
||||
last_seen: datetime
|
||||
tx_count: int = 0
|
||||
total_volume_usd: float = 0.0
|
||||
is_deployer: bool = False
|
||||
is_known_exchange: bool = False
|
||||
is_suspicious: bool = False
|
||||
reputation_score: Optional[int] = Field(None, ge=0, le=100)
|
||||
store: Literal["neo4j"] = "neo4j"
|
||||
|
||||
@field_validator("wallet_id")
|
||||
@classmethod
|
||||
def _check_id_format(cls, v: str) -> str:
|
||||
if ":" not in v:
|
||||
raise ValueError("wallet_id must be 'chain:address'")
|
||||
chain, _ = v.split(":", 1)
|
||||
if chain not in {c.value for c in Chain}:
|
||||
# Allow unknown chains (forward compat) but flag them
|
||||
pass
|
||||
return v
|
||||
|
||||
|
||||
class Deployer(Wallet):
|
||||
"""A wallet that has deployed at least one token. Extends Wallet."""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
deployments: list[str] = Field(default_factory=list, description="token_ids")
|
||||
rug_count: int = 0
|
||||
legit_count: int = 0
|
||||
avg_token_lifetime_days: float = 0.0
|
||||
reputation_score: Optional[int] = Field(
|
||||
None,
|
||||
ge=0,
|
||||
le=100,
|
||||
description="Weighted: legit_count * 1.0 - rug_count * 3.0 + age_bonus - news_penalty. Cached in Redis TTL 1h.",
|
||||
)
|
||||
|
||||
|
||||
# ── Tokens (Postgres primary) ──────────────────────────────────────
|
||||
class Token(BaseModel):
|
||||
"""A token contract. Postgres primary; references Deployer in Neo4j."""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
token_id: str = Field(..., description='"chain:address"')
|
||||
chain: Chain
|
||||
address: str
|
||||
symbol: str
|
||||
name: str
|
||||
decimals: int
|
||||
deployer_wallet_id: Optional[str] = Field(
|
||||
None, description="Cross-store ref to Wallet (Neo4j)"
|
||||
)
|
||||
deployed_at: datetime
|
||||
initial_supply: int
|
||||
current_supply: Optional[int] = None
|
||||
is_honeypot: Optional[bool] = None
|
||||
is_mintable: Optional[bool] = None
|
||||
is_proxy: Optional[bool] = None
|
||||
tax_buy_bps: Optional[int] = None
|
||||
tax_sell_bps: Optional[int] = None
|
||||
risk_tier: Optional[RiskTier] = None
|
||||
risk_score: Optional[int] = Field(None, ge=0, le=100)
|
||||
risk_factors: list[str] = Field(default_factory=list)
|
||||
rag_embedding_id: Optional[str] = Field(
|
||||
None, description="Cross-store ref to Qdrant point (16-byte hex)"
|
||||
)
|
||||
store: Literal["postgres"] = "postgres"
|
||||
|
||||
|
||||
# ── Alerts (Postgres primary) ──────────────────────────────────────
|
||||
class Alert(BaseModel):
|
||||
"""A risk alert. Postgres primary."""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
alert_id: str = Field(..., description="UUID")
|
||||
token_id: Optional[str] = None
|
||||
wallet_id: Optional[str] = None
|
||||
chain: Optional[Chain] = None
|
||||
alert_type: Literal[
|
||||
"rug_detected",
|
||||
"deployer_history",
|
||||
"liquidity_drain",
|
||||
"honeypot_detected",
|
||||
"high_tax_change",
|
||||
"whale_dump",
|
||||
]
|
||||
severity: Literal["info", "warning", "critical"]
|
||||
title: str
|
||||
description: str
|
||||
evidence: dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: datetime
|
||||
resolved_at: Optional[datetime] = None
|
||||
store: Literal["postgres"] = "postgres"
|
||||
|
||||
|
||||
# ── News (Postgres primary + Qdrant embeddings) ────────────────────
|
||||
class NewsItem(BaseModel):
|
||||
"""A news article from RSS. Postgres primary; embeddings in Qdrant."""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
news_id: str = Field(..., description="UUID")
|
||||
url: HttpUrl
|
||||
title: str
|
||||
summary: str
|
||||
body_markdown: Optional[str] = None
|
||||
source: str
|
||||
published_at: datetime
|
||||
ingested_at: datetime
|
||||
chains_mentioned: list[Chain] = Field(default_factory=list)
|
||||
tokens_mentioned: list[str] = Field(default_factory=list)
|
||||
wallets_mentioned: list[str] = Field(default_factory=list)
|
||||
sentiment_score: Optional[float] = Field(None, ge=-1.0, le=1.0)
|
||||
ai_analysis: Optional[str] = None
|
||||
rag_embedding_id: Optional[str] = None
|
||||
store: Literal["postgres"] = "postgres"
|
||||
|
||||
|
||||
# ── RAG Findings (Qdrant primary + Postgres metadata) ───────────────
|
||||
class RAGFinding(BaseModel):
|
||||
"""A fact extracted by RAG. Qdrant primary (vector); metadata in Postgres."""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
finding_id: str = Field(..., description="UUID")
|
||||
source_type: Literal["news", "onchain", "audit", "social", "manual"]
|
||||
source_url: Optional[HttpUrl] = None
|
||||
source_token_id: Optional[str] = None
|
||||
source_wallet_id: Optional[str] = None
|
||||
claim: str
|
||||
confidence: float = Field(ge=0.0, le=1.0)
|
||||
extracted_at: datetime
|
||||
qdrant_point_id: str
|
||||
store: Literal["qdrant"] = "qdrant"
|
||||
|
||||
|
||||
# ── Reports (Postgres + MinIO) ──────────────────────────────────────
|
||||
class ScanReport(BaseModel):
|
||||
"""A research report. Postgres primary; markdown in MinIO."""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
report_id: str = Field(..., description="UUID")
|
||||
subject_type: Literal["token", "wallet", "deployer"]
|
||||
subject_id: str
|
||||
generated_at: datetime
|
||||
generated_by_model: str
|
||||
risk_score: int = Field(ge=0, le=100)
|
||||
risk_tier: RiskTier
|
||||
sections: dict[str, str] = Field(default_factory=dict)
|
||||
markdown_url: Optional[HttpUrl] = None
|
||||
paid_via_x402: Optional[str] = None
|
||||
store: Literal["postgres", "minio"] = "postgres"
|
||||
|
||||
def to_markdown(self) -> str:
|
||||
"""Render report sections to a single Markdown document."""
|
||||
parts = [
|
||||
f"# Research Report: {self.subject_type.title()} `{self.subject_id}`",
|
||||
"",
|
||||
f"**Generated:** {self.generated_at.isoformat()}",
|
||||
f"**Generated by:** {self.generated_by_model}",
|
||||
f"**Risk score:** {self.risk_score}/100 ({self.risk_tier.value.upper()})",
|
||||
"",
|
||||
]
|
||||
for section, body in self.sections.items():
|
||||
parts.append(f"## {section.replace('_', ' ').title()}")
|
||||
parts.append("")
|
||||
parts.append(body)
|
||||
parts.append("")
|
||||
parts.append("---")
|
||||
parts.append(f"*Report ID: {self.report_id}*")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
# ── Wire-format helpers ─────────────────────────────────────────────
|
||||
def utcnow() -> datetime:
|
||||
"""Timezone-aware UTC now. Pydantic serializes to ISO 8601."""
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
# ── RAG engine collections (kept here so catalog + RAG share the list) ─
|
||||
COLLECTIONS: list[str] = [
|
||||
# Per v4.0 catalog/RAG bridge — these are the canonical 13 RAG
|
||||
# collections that also have Token/Wallet/etc cross-refs.
|
||||
"scam_intel",
|
||||
"deployer_history",
|
||||
"wallet_labels",
|
||||
"contract_audit",
|
||||
"phishing_db",
|
||||
"defi_hacks",
|
||||
"rug_timeline",
|
||||
"vuln_patterns",
|
||||
"crime_reports",
|
||||
"transaction_patterns",
|
||||
"known_scams",
|
||||
"token_analysis",
|
||||
"market_intel",
|
||||
]
|
||||
|
|
@ -1,179 +0,0 @@
|
|||
"""T01 — Bayesian Deployer Reputation System.
|
||||
|
||||
Per MINIMAX_M3_TASKS.md T01. Beta-Binomial posterior replaces the
|
||||
weighted-sum that conflated probabilities with volumes.
|
||||
|
||||
The legacy 0-100 score is kept for backward compatibility (every
|
||||
existing consumer reads it). The new authoritative output is:
|
||||
probability — P(rug) = alpha / (alpha + beta)
|
||||
credible_interval_95 — 95% Bayesian CI from Beta distribution
|
||||
observations — {successes, failures, total}
|
||||
|
||||
We start with a uniform prior Beta(1,1). Each rug increments beta.
|
||||
Each legitimate deployment increments alpha. News sentiment < -0.3
|
||||
adds 2 to beta (pessimistic prior). News sentiment > 0.3 adds 2 to
|
||||
alpha (optimistic prior). Age and volume are logged but not folded
|
||||
into the prior (they are orthogonal signals, not evidence).
|
||||
|
||||
The legacy 0-100 score is derived deterministically from probability:
|
||||
score = round((1 - probability) * 100)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime, UTC
|
||||
|
||||
from app.catalog.models import Deployer, utcnow
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Prior adjustments (Bayesian update weights) ────────────────
|
||||
PRIOR_WEIGHTS: dict[str, int] = {
|
||||
"prior_alpha": 1, # Beta(1,1) = uniform prior
|
||||
"prior_beta": 1,
|
||||
"news_pessimistic_shift": 2, # +2 to beta if avg sentiment < -0.3
|
||||
"news_optimistic_shift": 2, # +2 to alpha if avg sentiment > 0.3
|
||||
"news_window_hours": 720, # 30 days
|
||||
"news_negative_threshold": -0.3,
|
||||
"news_positive_threshold": 0.3,
|
||||
}
|
||||
|
||||
|
||||
def _beta_credible_interval_95(alpha: float, beta: float) -> tuple[float, float]:
|
||||
"""Approximate 95% credible interval for Beta(alpha, beta).
|
||||
|
||||
Uses the normal approximation to the Beta distribution, which is
|
||||
accurate for alpha+beta > 30 (our regime: typically dozens of
|
||||
observations per deployer). For low-observation regimes, falls back
|
||||
to a wider quantile-based interval.
|
||||
"""
|
||||
n = alpha + beta
|
||||
if n <= 0:
|
||||
return (0.0, 1.0)
|
||||
if n < 30:
|
||||
# Wider interval for low-data regime
|
||||
mean = alpha / n
|
||||
var = (alpha * beta) / (n * n * (n + 1))
|
||||
sd = math.sqrt(var)
|
||||
# Use 1.96 but clamp to [0,1]
|
||||
lo = max(0.0, mean - 1.96 * sd)
|
||||
hi = min(1.0, mean + 1.96 * sd)
|
||||
return (lo, hi)
|
||||
# High-data regime: tighter interval
|
||||
mean = alpha / n
|
||||
var = (alpha * beta) / (n * n * (n + 1))
|
||||
sd = math.sqrt(var)
|
||||
lo = max(0.0, mean - 1.96 * sd)
|
||||
hi = min(1.0, mean + 1.96 * sd)
|
||||
return (lo, hi)
|
||||
|
||||
|
||||
async def compute_deployer_posterior(
|
||||
deployer: Deployer,
|
||||
catalog: "CatalogService",
|
||||
) -> dict:
|
||||
"""Compute Bayesian reputation for a deployer.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"probability": float, # P(rug), 0..1
|
||||
"credible_interval_95": [lo, hi], # 95% Bayesian CI
|
||||
"observations": {
|
||||
"rugs": int, "legit": int, "total": int,
|
||||
"alpha": float, "beta": float,
|
||||
},
|
||||
"news_sentiment": float | None, # -1..+1 if available
|
||||
"score": int, # legacy 0-100 (backward compat)
|
||||
"computed_at": str, # ISO8601
|
||||
}
|
||||
"""
|
||||
cache_key = f"catalog:deployer_rep:v2:{deployer.wallet_id}"
|
||||
if catalog._health.redis:
|
||||
try:
|
||||
cached = await catalog._redis.get(cache_key)
|
||||
if cached:
|
||||
import json as _json
|
||||
return _json.loads(cached)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Update prior from observations ──
|
||||
alpha = float(PRIOR_WEIGHTS["prior_alpha"])
|
||||
beta = float(PRIOR_WEIGHTS["prior_beta"])
|
||||
|
||||
rugs = max(0, deployer.rug_count)
|
||||
legit = max(0, len(deployer.deployments) - rugs)
|
||||
alpha += legit
|
||||
beta += rugs
|
||||
|
||||
# ── News sentiment prior adjustment ──
|
||||
news_sentiment = None
|
||||
if catalog._health.postgres:
|
||||
try:
|
||||
async with catalog._pg_pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""SELECT sentiment_score FROM news_items
|
||||
WHERE $1 = ANY(wallets_mentioned)
|
||||
AND published_at > NOW() - make_interval(hours => $2)
|
||||
LIMIT 20""",
|
||||
deployer.wallet_id,
|
||||
PRIOR_WEIGHTS["news_window_hours"],
|
||||
)
|
||||
scores = [r["sentiment_score"] for r in rows if r["sentiment_score"] is not None]
|
||||
if scores:
|
||||
news_sentiment = sum(scores) / len(scores)
|
||||
if news_sentiment < PRIOR_WEIGHTS["news_negative_threshold"]:
|
||||
beta += PRIOR_WEIGHTS["news_pessimistic_shift"]
|
||||
elif news_sentiment > PRIOR_WEIGHTS["news_positive_threshold"]:
|
||||
alpha += PRIOR_WEIGHTS["news_optimistic_shift"]
|
||||
except Exception as e:
|
||||
log.debug("reputation_news_fail: %s", e)
|
||||
|
||||
# ── Posterior ──
|
||||
total = alpha + beta
|
||||
probability = alpha / total if total > 0 else 0.5
|
||||
lo, hi = _beta_credible_interval_95(alpha, beta)
|
||||
|
||||
result = {
|
||||
"probability": round(probability, 4),
|
||||
"credible_interval_95": [round(lo, 4), round(hi, 4)],
|
||||
"observations": {
|
||||
"rugs": int(rugs),
|
||||
"legit": int(legit),
|
||||
"total": int(deployer.total_volume_usd and len(deployer.deployments) or 0),
|
||||
"alpha": alpha,
|
||||
"beta": beta,
|
||||
},
|
||||
"news_sentiment": round(news_sentiment, 4) if news_sentiment is not None else None,
|
||||
# Legacy 0-100 score: probability of legitness scaled to 0..100
|
||||
# probability = P(rug), so legitness = 1 - probability
|
||||
"score": int(round((1.0 - probability) * 100)),
|
||||
"computed_at": utcnow().isoformat(),
|
||||
}
|
||||
|
||||
if catalog._health.redis:
|
||||
try:
|
||||
import json as _json
|
||||
await catalog._redis.setex(cache_key, 3600, _json.dumps(result))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ── Backward-compatible wrapper (returns just the int score) ──
|
||||
|
||||
async def compute_deployer_reputation(
|
||||
deployer: Deployer,
|
||||
catalog: "CatalogService",
|
||||
) -> int:
|
||||
"""Legacy 0-100 reputation score.
|
||||
|
||||
Returns the integer score derived from the Bayesian posterior.
|
||||
New code should call compute_deployer_posterior() directly for the
|
||||
full probability + CI.
|
||||
"""
|
||||
posterior = await compute_deployer_posterior(deployer, catalog)
|
||||
return posterior["score"]
|
||||
|
|
@ -1,609 +0,0 @@
|
|||
"""T27 CatalogService — unified read/write API for RMI.
|
||||
|
||||
Per v4.0 §T27. The CatalogService is the ONLY sanctioned way to read or
|
||||
write catalog data. Domain facades call this; they never touch stores directly.
|
||||
|
||||
Architecture:
|
||||
- Lazy store clients (init on first use, retry on failure)
|
||||
- Graceful degradation: unreachable stores return None/empty, not crash
|
||||
- Redis cache layer (always available since rmi-redis is up)
|
||||
- Cross-store ID conventions from app.catalog.models
|
||||
|
||||
Recipe coverage:
|
||||
Recipe 1: find_tokens_by_deployer_history (Postgres + Neo4j)
|
||||
Recipe 2: find_similar_tokens (Qdrant + Postgres)
|
||||
Recipe 3: get_token_risk (Redis + Postgres + Neo4j)
|
||||
Recipe 4: news_price_correlation (Postgres, basic v1)
|
||||
Recipe 5: resolve_entity (Neo4j Cypher)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from app.catalog.models import (
|
||||
Alert,
|
||||
COLLECTIONS,
|
||||
Chain,
|
||||
Deployer,
|
||||
Entity,
|
||||
NewsItem,
|
||||
RAGFinding,
|
||||
ScanReport,
|
||||
Token,
|
||||
Wallet,
|
||||
utcnow,
|
||||
)
|
||||
from app.rag.engine import three_pillar_search as rag_three_pillar_search
|
||||
from app.rag.engine import ingest_document as rag_ingest_document
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Connection config (from env, with sensible defaults to netcup IPs) ──
|
||||
# These defaults point to the actual IPs on netcup rmi_network.
|
||||
# Override via env vars in docker-compose.yml for portability.
|
||||
DEFAULT_CONFIG: dict[str, Any] = {
|
||||
"redis": {
|
||||
"host": os.getenv("REDIS_HOST", "rmi-redis"),
|
||||
"port": int(os.getenv("REDIS_PORT", "6379")),
|
||||
"password": os.getenv("REDIS_PASSWORD", "RMI_PROD_REDIS_2026"),
|
||||
"db": int(os.getenv("REDIS_DB", "0")),
|
||||
},
|
||||
"postgres": {
|
||||
"host": os.getenv("POSTGRES_HOST", "rmi-postgres"),
|
||||
"port": int(os.getenv("POSTGRES_PORT", "5432")),
|
||||
"user": os.getenv("POSTGRES_USER", "rmi"),
|
||||
"password": os.getenv("POSTGRES_PASSWORD", ""),
|
||||
"database": os.getenv("POSTGRES_DB", "rmi"),
|
||||
},
|
||||
"neo4j": {
|
||||
"uri": os.getenv("NEO4J_URI", "bolt://rmi-neo4j:7687"),
|
||||
"user": os.getenv("NEO4J_USER", "neo4j"),
|
||||
"password": os.getenv("NEO4J_PASSWORD", ""),
|
||||
},
|
||||
"qdrant": {
|
||||
"url": os.getenv("QDRANT_URL", "http://rmi-qdrant:6333"),
|
||||
"api_key": os.getenv("QDRANT_API_KEY", ""),
|
||||
},
|
||||
"minio": {
|
||||
"endpoint": os.getenv("MINIO_ENDPOINT", "rmi-minio:9000"),
|
||||
"access_key": os.getenv("MINIO_ACCESS_KEY", ""),
|
||||
"secret_key": os.getenv("MINIO_SECRET_KEY", ""),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class StoreHealth:
|
||||
"""Tracks which stores are reachable. Updated on each probe."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.redis: bool = False
|
||||
self.postgres: bool = False
|
||||
self.neo4j: bool = False
|
||||
self.qdrant: bool = False
|
||||
self.minio: bool = False
|
||||
self.last_checked: float = 0.0
|
||||
|
||||
|
||||
class CatalogService:
|
||||
"""Unified read/write API for RMI data.
|
||||
|
||||
Use `get_catalog()` to get the singleton instance. All methods are
|
||||
async and graceful-degrade — if a store is unreachable, the method
|
||||
returns None or an empty result with a logged warning.
|
||||
"""
|
||||
|
||||
def __init__(self, config: dict[str, Any] | None = None) -> None:
|
||||
self.config = config or DEFAULT_CONFIG
|
||||
self._health = StoreHealth()
|
||||
self._redis = None
|
||||
self._pg_pool: Any = None
|
||||
self._neo_driver: Any = None
|
||||
self._qdrant: Any = None # httpx.AsyncClient
|
||||
self._init_lock = asyncio.Lock()
|
||||
|
||||
# ── Store init (lazy) ────────────────────────────────────────────
|
||||
async def _init_stores(self) -> None:
|
||||
async with self._init_lock:
|
||||
if self._redis is not None:
|
||||
return # already init
|
||||
# Redis (always try first — used for everything)
|
||||
try:
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
cfg = self.config["redis"]
|
||||
self._redis = aioredis.Redis(
|
||||
host=cfg["host"],
|
||||
port=cfg["port"],
|
||||
password=cfg["password"],
|
||||
db=cfg["db"],
|
||||
decode_responses=True,
|
||||
)
|
||||
await self._redis.ping()
|
||||
self._health.redis = True
|
||||
log.info("catalog_redis_ok")
|
||||
except Exception as e:
|
||||
log.warning("catalog_redis_fail: %s", e)
|
||||
self._health.redis = False
|
||||
|
||||
# Postgres
|
||||
try:
|
||||
import asyncpg
|
||||
|
||||
cfg = self.config["postgres"]
|
||||
if cfg["password"]:
|
||||
self._pg_pool = await asyncpg.create_pool(
|
||||
host=cfg["host"],
|
||||
port=cfg["port"],
|
||||
user=cfg["user"],
|
||||
password=cfg["password"],
|
||||
database=cfg["database"],
|
||||
min_size=1,
|
||||
max_size=8,
|
||||
command_timeout=10,
|
||||
)
|
||||
self._health.postgres = True
|
||||
log.info("catalog_postgres_ok")
|
||||
except Exception as e:
|
||||
log.warning("catalog_postgres_fail: %s", e)
|
||||
self._health.postgres = False
|
||||
|
||||
# Neo4j (NEO4J_AUTH=none means no password)
|
||||
try:
|
||||
from neo4j import GraphDatabase
|
||||
|
||||
cfg = self.config["neo4j"]
|
||||
auth = (
|
||||
(cfg["user"], cfg["password"]) if cfg["password"] else None
|
||||
)
|
||||
self._neo_driver = GraphDatabase.driver(cfg["uri"], auth=auth)
|
||||
with self._neo_driver.session() as s:
|
||||
s.run("RETURN 1").consume()
|
||||
self._health.neo4j = True
|
||||
log.info("catalog_neo4j_ok")
|
||||
except Exception as e:
|
||||
log.warning("catalog_neo4j_fail: %s", e)
|
||||
self._health.neo4j = False
|
||||
|
||||
# MinIO (HTTP health probe)
|
||||
try:
|
||||
import socket
|
||||
|
||||
host, port = self.config["minio"]["endpoint"].rsplit(":", 1)
|
||||
s = socket.socket()
|
||||
s.settimeout(3)
|
||||
s.connect((host, int(port)))
|
||||
s.close()
|
||||
self._health.minio = True
|
||||
log.info("catalog_minio_ok")
|
||||
except Exception as e:
|
||||
log.warning("catalog_minio_fail: %s", e)
|
||||
self._health.minio = False
|
||||
|
||||
# Qdrant (HTTP)
|
||||
try:
|
||||
cfg = self.config["qdrant"]
|
||||
headers = {"api-key": cfg["api_key"]} if cfg["api_key"] else {}
|
||||
self._qdrant = httpx.AsyncClient(
|
||||
base_url=cfg["url"], headers=headers, timeout=5.0
|
||||
)
|
||||
r = await self._qdrant.get("/collections")
|
||||
if r.status_code == 200:
|
||||
self._health.qdrant = True
|
||||
log.info("catalog_qdrant_ok")
|
||||
else:
|
||||
log.warning("catalog_qdrant_http_%d", r.status_code)
|
||||
except Exception as e:
|
||||
log.warning("catalog_qdrant_fail: %s", e)
|
||||
self._health.qdrant = False
|
||||
|
||||
self._health.last_checked = time.time()
|
||||
|
||||
# ── Health probe ────────────────────────────────────────────────
|
||||
async def probe_stores(self) -> dict[str, bool]:
|
||||
await self._init_stores()
|
||||
return {
|
||||
"redis": self._health.redis,
|
||||
"postgres": self._health.postgres,
|
||||
"neo4j": self._health.neo4j,
|
||||
"qdrant": self._health.qdrant,
|
||||
"minio": self._health.minio,
|
||||
}
|
||||
|
||||
# ── Tokens (Postgres primary + Redis cache) ─────────────────────
|
||||
async def get_token(self, chain: Chain, address: str) -> Token | None:
|
||||
await self._init_stores()
|
||||
cache_key = f"catalog:token:{chain.value}:{address}"
|
||||
if self._health.redis:
|
||||
try:
|
||||
cached = await self._redis.get(cache_key)
|
||||
if cached:
|
||||
return Token.model_validate_json(cached)
|
||||
except Exception as e:
|
||||
log.debug("token_cache_get_fail: %s", e)
|
||||
if not self._health.postgres:
|
||||
return None
|
||||
try:
|
||||
async with self._pg_pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT * FROM tokens WHERE chain=$1 AND address=$2",
|
||||
chain.value, address,
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
token = Token(**dict(row))
|
||||
if self._health.redis:
|
||||
try:
|
||||
await self._redis.setex(cache_key, 3600, token.model_dump_json())
|
||||
except Exception as e:
|
||||
log.debug("token_cache_set_fail: %s", e)
|
||||
return token
|
||||
except Exception as e:
|
||||
log.warning("token_get_fail: %s", e)
|
||||
return None
|
||||
|
||||
async def save_token(self, token: Token) -> bool:
|
||||
await self._init_stores()
|
||||
if not self._health.postgres:
|
||||
log.warning("token_save_no_postgres")
|
||||
return False
|
||||
try:
|
||||
async with self._pg_pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO tokens (
|
||||
token_id, chain, address, symbol, name, decimals,
|
||||
deployer_wallet_id, deployed_at, initial_supply,
|
||||
current_supply, is_honeypot, is_mintable, is_proxy,
|
||||
tax_buy_bps, tax_sell_bps, risk_tier, risk_score,
|
||||
risk_factors, rag_embedding_id
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,
|
||||
$14,$15,$16,$17,$18,$19)
|
||||
ON CONFLICT (token_id) DO UPDATE SET
|
||||
symbol=EXCLUDED.symbol,
|
||||
name=EXCLUDED.name,
|
||||
current_supply=EXCLUDED.current_supply,
|
||||
is_honeypot=EXCLUDED.is_honeypot,
|
||||
is_mintable=EXCLUDED.is_mintable,
|
||||
risk_tier=EXCLUDED.risk_tier,
|
||||
risk_score=EXCLUDED.risk_score,
|
||||
risk_factors=EXCLUDED.risk_factors
|
||||
""",
|
||||
token.token_id, token.chain.value, token.address,
|
||||
token.symbol, token.name, token.decimals,
|
||||
token.deployer_wallet_id, token.deployed_at,
|
||||
token.initial_supply, token.current_supply,
|
||||
token.is_honeypot, token.is_mintable, token.is_proxy,
|
||||
token.tax_buy_bps, token.tax_sell_bps,
|
||||
token.risk_tier.value if token.risk_tier else None,
|
||||
token.risk_score, token.risk_factors, token.rag_embedding_id,
|
||||
)
|
||||
# Invalidate cache
|
||||
if self._health.redis:
|
||||
try:
|
||||
await self._redis.delete(
|
||||
f"catalog:token:{token.chain.value}:{token.address}"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
except Exception as e:
|
||||
log.warning("token_save_fail: %s", e)
|
||||
return False
|
||||
|
||||
# ── Wallets (Neo4j primary) ──────────────────────────────────────
|
||||
async def get_wallet(self, chain: Chain, address: str) -> Wallet | None:
|
||||
await self._init_stores()
|
||||
if not self._health.neo4j:
|
||||
return None
|
||||
wallet_id = f"{chain.value}:{address}"
|
||||
try:
|
||||
with self._neo_driver.session() as s:
|
||||
result = s.run(
|
||||
"MATCH (w:Wallet {wallet_id: $id}) RETURN w",
|
||||
id=wallet_id,
|
||||
).single()
|
||||
if not result:
|
||||
return None
|
||||
node = dict(result["w"])
|
||||
# Normalize datetime strings
|
||||
for k in ("first_seen", "last_seen"):
|
||||
if isinstance(node.get(k), str):
|
||||
from datetime import datetime
|
||||
try:
|
||||
node[k] = datetime.fromisoformat(node[k].replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
pass
|
||||
return Wallet(**node)
|
||||
except Exception as e:
|
||||
log.warning("wallet_get_fail: %s", e)
|
||||
return None
|
||||
|
||||
async def save_wallet(self, wallet: Wallet) -> bool:
|
||||
await self._init_stores()
|
||||
if not self._health.neo4j:
|
||||
return False
|
||||
try:
|
||||
with self._neo_driver.session() as s:
|
||||
s.run(
|
||||
"""
|
||||
MERGE (w:Wallet {wallet_id: $wallet_id})
|
||||
SET w.chain=$chain, w.address=$address,
|
||||
w.first_seen=$first_seen, w.last_seen=$last_seen,
|
||||
w.tx_count=$tx_count, w.total_volume_usd=$total_volume_usd,
|
||||
w.is_deployer=$is_deployer,
|
||||
w.is_known_exchange=$is_known_exchange,
|
||||
w.is_suspicious=$is_suspicious,
|
||||
w.reputation_score=$reputation_score
|
||||
""",
|
||||
wallet_id=wallet.wallet_id,
|
||||
chain=wallet.chain.value,
|
||||
address=wallet.address,
|
||||
first_seen=wallet.first_seen.isoformat(),
|
||||
last_seen=wallet.last_seen.isoformat(),
|
||||
tx_count=wallet.tx_count,
|
||||
total_volume_usd=wallet.total_volume_usd,
|
||||
is_deployer=wallet.is_deployer,
|
||||
is_known_exchange=wallet.is_known_exchange,
|
||||
is_suspicious=wallet.is_suspicious,
|
||||
reputation_score=wallet.reputation_score,
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
log.warning("wallet_save_fail: %s", e)
|
||||
return False
|
||||
|
||||
# ── Recipe 1: find tokens by deployer history ───────────────────
|
||||
async def find_tokens_by_deployer_history(
|
||||
self, min_rug_count: int = 1, chain: Chain | None = None, limit: int = 50
|
||||
) -> list[Token]:
|
||||
"""Find tokens deployed by wallets that have rug-pulled before.
|
||||
|
||||
Cross-store: Neo4j for the wallet filter, Postgres for the tokens.
|
||||
"""
|
||||
await self._init_stores()
|
||||
if not (self._health.neo4j and self._health.postgres):
|
||||
log.warning("recipe1_stores_unavailable")
|
||||
return []
|
||||
try:
|
||||
# Step 1: Neo4j — find wallets with rug_count >= min_rug_count
|
||||
with self._neo_driver.session() as s:
|
||||
wallets = [
|
||||
r["w.wallet_id"]
|
||||
for r in s.run(
|
||||
"MATCH (w:Deployer) WHERE w.rug_count >= $min "
|
||||
"RETURN w.wallet_id LIMIT 200",
|
||||
min=min_rug_count,
|
||||
)
|
||||
]
|
||||
if not wallets:
|
||||
return []
|
||||
# Step 2: Postgres — find tokens deployed by those wallets
|
||||
async with self._pg_pool.acquire() as conn:
|
||||
query = (
|
||||
"SELECT * FROM tokens WHERE deployer_wallet_id = ANY($1::text[]) "
|
||||
)
|
||||
params: list[Any] = [wallets]
|
||||
if chain:
|
||||
query += "AND chain=$2 "
|
||||
params.append(chain.value)
|
||||
query += "ORDER BY deployed_at DESC LIMIT $3"
|
||||
params.append(limit)
|
||||
rows = await conn.fetch(query, *params)
|
||||
return [Token(**dict(r)) for r in rows]
|
||||
except Exception as e:
|
||||
log.warning("recipe1_fail: %s", e)
|
||||
return []
|
||||
|
||||
# ── Recipe 3: get_token_risk (3-store composition) ──────────────
|
||||
async def get_token_risk(
|
||||
self, chain: Chain, address: str
|
||||
) -> dict[str, Any]:
|
||||
"""Real-time risk score — composes Redis cache + Postgres + Neo4j.
|
||||
|
||||
Returns dict (not Token) because it's a cross-store projection.
|
||||
Cache TTL 60s.
|
||||
"""
|
||||
await self._init_stores()
|
||||
cache_key = f"catalog:risk:{chain.value}:{address}"
|
||||
if self._health.redis:
|
||||
try:
|
||||
cached = await self._redis.get(cache_key)
|
||||
if cached:
|
||||
return json.loads(cached)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
token = await self.get_token(chain, address)
|
||||
deployer_reputation: int | None = None
|
||||
if token and token.deployer_wallet_id and self._health.neo4j:
|
||||
try:
|
||||
with self._neo_driver.session() as s:
|
||||
r = s.run(
|
||||
"MATCH (d:Deployer {wallet_id: $id}) "
|
||||
"RETURN d.reputation_score AS rep, d.rug_count AS rugs",
|
||||
id=token.deployer_wallet_id,
|
||||
).single()
|
||||
if r:
|
||||
deployer_reputation = r["rep"]
|
||||
except Exception as e:
|
||||
log.debug("risk_neo4j_fail: %s", e)
|
||||
|
||||
token_score = token.risk_score if token and token.risk_score is not None else 50
|
||||
if deployer_reputation is not None:
|
||||
score = int(0.6 * token_score + 0.4 * (100 - deployer_reputation))
|
||||
else:
|
||||
score = token_score
|
||||
score = max(0, min(100, score))
|
||||
if score < 25:
|
||||
tier = "low"
|
||||
elif score < 50:
|
||||
tier = "medium"
|
||||
elif score < 75:
|
||||
tier = "high"
|
||||
else:
|
||||
tier = "critical"
|
||||
result: dict[str, Any] = {
|
||||
"score": score,
|
||||
"tier": tier,
|
||||
"factors": token.risk_factors if token else [],
|
||||
"token": token.model_dump() if token else None,
|
||||
"deployer_reputation": deployer_reputation,
|
||||
"fetched_at": utcnow().isoformat(),
|
||||
}
|
||||
if self._health.redis:
|
||||
try:
|
||||
await self._redis.setex(cache_key, 60, json.dumps(result, default=str))
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
|
||||
# ── Recipe 5: resolve_entity (Neo4j Cypher) ─────────────────────
|
||||
async def resolve_entity(
|
||||
self, wallet_id: str, max_chains: int = 5
|
||||
) -> dict[str, Any]:
|
||||
"""Cross-chain entity resolution via Neo4j.
|
||||
|
||||
Uses the SAME_AS / FUNDED_BY_SAME / CLONE_OF / BEHAVIORAL_MATCH
|
||||
edges defined in v4.0 §T32 with path-weighted confidence.
|
||||
"""
|
||||
await self._init_stores()
|
||||
if not self._health.neo4j:
|
||||
return {"entity_id": None, "wallets": [], "note": "neo4j unavailable"}
|
||||
try:
|
||||
with self._neo_driver.session() as s:
|
||||
result = s.run(
|
||||
"""
|
||||
MATCH (start:Wallet {wallet_id: $wallet_id})
|
||||
OPTIONAL MATCH path = (start)-[:SAME_AS|FUNDED_BY_SAME|CLONE_OF|BEHAVIORAL_MATCH*1..3]-(other:Wallet)
|
||||
WHERE start <> other
|
||||
WITH start, other, path,
|
||||
reduce(conf = 1.0, r IN relationships(path) | conf * r.confidence) AS path_conf
|
||||
ORDER BY path_conf DESC
|
||||
LIMIT $limit
|
||||
RETURN start.entity_id AS entity_id,
|
||||
collect({
|
||||
wallet_id: other.wallet_id,
|
||||
chain: other.chain,
|
||||
address: other.address,
|
||||
confidence: path_conf
|
||||
}) AS cross_chain_wallets
|
||||
""",
|
||||
wallet_id=wallet_id, limit=max_chains,
|
||||
).single()
|
||||
if not result:
|
||||
return {"entity_id": None, "wallets": [], "note": "no edges"}
|
||||
wallets = [w for w in (result["cross_chain_wallets"] or []) if w.get("wallet_id")]
|
||||
return {
|
||||
"entity_id": result["entity_id"],
|
||||
"wallets": wallets,
|
||||
"note": f"{len(wallets)} cross-chain matches",
|
||||
}
|
||||
except Exception as e:
|
||||
log.warning("resolve_entity_fail: %s", e)
|
||||
return {"entity_id": None, "wallets": [], "error": str(e)}
|
||||
|
||||
# ── RAG bridge: wire app/rag/engine.py into the catalog ─────────
|
||||
async def rag_search(
|
||||
self, query: str, collection: str = "scam_intel", top_k: int = 5
|
||||
) -> list[dict]:
|
||||
"""Search the RAG system. Returns raw hits (catalog-agnostic).
|
||||
|
||||
Use this when you want RAG results as raw search hits.
|
||||
Use get_token_risk / etc. when you want catalog-typed results.
|
||||
"""
|
||||
try:
|
||||
return await rag_three_pillar_search(
|
||||
query=query, collection=collection, top_k=top_k
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning("rag_search_fail: %s", e)
|
||||
return []
|
||||
|
||||
async def rag_ingest(
|
||||
self,
|
||||
content: str,
|
||||
collection: str = "scam_intel",
|
||||
doc_id: str | None = None,
|
||||
metadata: dict | None = None,
|
||||
) -> dict:
|
||||
"""Ingest a fact into RAG and (if it's about a token) link to Token.rag_embedding_id.
|
||||
|
||||
Returns the RAG ingest result plus the Qdrant point_id for cross-store ref.
|
||||
"""
|
||||
from uuid import uuid4
|
||||
|
||||
doc_id = doc_id or f"finding:{uuid4().hex[:12]}"
|
||||
try:
|
||||
r = await rag_ingest_document(
|
||||
collection=collection,
|
||||
doc_id=doc_id,
|
||||
content=content,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
r["qdrant_point_id"] = doc_id # Engine returns the same
|
||||
return r
|
||||
except Exception as e:
|
||||
log.warning("rag_ingest_fail: %s", e)
|
||||
return {"status": "failed", "error": str(e)}
|
||||
|
||||
async def attach_rag_to_token(
|
||||
self, chain: Chain, address: str, qdrant_point_id: str
|
||||
) -> bool:
|
||||
"""Link an existing Qdrant point to a Token row as rag_embedding_id."""
|
||||
token = await self.get_token(chain, address)
|
||||
if not token:
|
||||
return False
|
||||
token.rag_embedding_id = qdrant_point_id
|
||||
return await self.save_token(token)
|
||||
|
||||
# ── Stats / introspection ────────────────────────────────────────
|
||||
async def stats(self) -> dict[str, Any]:
|
||||
await self._init_stores()
|
||||
out: dict[str, Any] = {
|
||||
"stores": await self.probe_stores(),
|
||||
"collections": COLLECTIONS,
|
||||
}
|
||||
if self._health.postgres:
|
||||
try:
|
||||
async with self._pg_pool.acquire() as conn:
|
||||
out["tokens"] = await conn.fetchval("SELECT COUNT(*) FROM tokens") or 0
|
||||
out["alerts"] = await conn.fetchval("SELECT COUNT(*) FROM alerts") or 0
|
||||
except Exception as e:
|
||||
out["postgres_error"] = str(e)
|
||||
if self._health.neo4j:
|
||||
try:
|
||||
with self._neo_driver.session() as s:
|
||||
out["wallets"] = s.run("MATCH (w:Wallet) RETURN COUNT(w) AS c").single()["c"] or 0
|
||||
out["deployers"] = s.run("MATCH (d:Deployer) RETURN COUNT(d) AS c").single()["c"] or 0
|
||||
except Exception as e:
|
||||
out["neo4j_error"] = str(e)
|
||||
if self._health.qdrant:
|
||||
try:
|
||||
cols = await self._qdrant.get("/collections")
|
||||
if cols.status_code == 200:
|
||||
out["qdrant_collections"] = [
|
||||
c["name"] for c in cols.json().get("result", {}).get("collections", [])
|
||||
]
|
||||
except Exception as e:
|
||||
out["qdrant_error"] = str(e)
|
||||
return out
|
||||
|
||||
|
||||
# ── Singleton accessor ──────────────────────────────────────────────
|
||||
_catalog: CatalogService | None = None
|
||||
|
||||
|
||||
def get_catalog() -> CatalogService:
|
||||
"""Get the global CatalogService. Lazy-init on first call."""
|
||||
global _catalog
|
||||
if _catalog is None:
|
||||
_catalog = CatalogService()
|
||||
return _catalog
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
"""Application configuration.
|
||||
|
||||
Pydantic-settings reads from environment variables (and .env in dev).
|
||||
Import: `from app.config import settings`
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""All runtime configuration. Add new env vars here as needed."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# ── Runtime ──────────────────────────────────────────────────────
|
||||
environment: Literal["dev", "staging", "prod"] = "prod"
|
||||
log_level: str = "INFO"
|
||||
port: int = 8000
|
||||
|
||||
# ── Database / Cache ────────────────────────────────────────────
|
||||
database_url: str = "postgresql+asyncpg://rmi:rmi@localhost/rmi"
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
|
||||
# ── Auth ────────────────────────────────────────────────────────
|
||||
jwt_secret: str = Field(default="dev-secret-CHANGE-ME")
|
||||
jwt_algorithm: str = "HS256"
|
||||
jwt_expire_minutes: int = 60 * 24
|
||||
|
||||
# ── CORS ────────────────────────────────────────────────────────
|
||||
cors_origins: list[str] = ["*"]
|
||||
|
||||
# ── Rate limiting ───────────────────────────────────────────────
|
||||
rate_limit_per_minute: int = 100
|
||||
|
||||
# ── AI providers ────────────────────────────────────────────────
|
||||
ollama_url: str = "http://localhost:11434"
|
||||
openrouter_api_key: str = ""
|
||||
huggingface_token: str = ""
|
||||
|
||||
# ── Langfuse v4 (observability) ─────────────────────────────────
|
||||
langfuse_public_key: str = ""
|
||||
langfuse_secret_key: str = ""
|
||||
langfuse_host: str = "http://localhost:3002"
|
||||
|
||||
# ── External APIs ───────────────────────────────────────────────
|
||||
coingecko_api_key: str = ""
|
||||
etherscan_api_key: str = ""
|
||||
birdeye_api_key: str = ""
|
||||
goplus_api_key: str = ""
|
||||
|
||||
# ── RMI-specific ────────────────────────────────────────────────
|
||||
rag_collections: list[str] = [
|
||||
"scam_intel",
|
||||
"deployer_history",
|
||||
"wallet_labels",
|
||||
"contract_audit",
|
||||
"phishing_db",
|
||||
]
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
"""Cached settings instance."""
|
||||
return Settings()
|
||||
|
||||
|
||||
# Module-level singleton for convenience.
|
||||
settings = get_settings()
|
||||
|
|
@ -1,355 +0,0 @@
|
|||
"""
|
||||
Deep Contract Audit / Contract Scanner
|
||||
======================================
|
||||
Performs static analysis, honeypot detection, and vulnerability scanning on
|
||||
smart contracts across EVM and Solana chains. The free, comprehensive alternative
|
||||
to paid audit services.
|
||||
|
||||
What it does:
|
||||
1. Static Analysis — Bytecode pattern matching for known malicious opcodes
|
||||
2. Honeypot Detection — Sell restrictions, blacklist/whitelist patterns
|
||||
3. Ownership & Admin Checks — Owner privileges, proxy patterns, upgrade capabilities
|
||||
4. Fee Analysis — Hidden taxes, dynamic fees, cooldown mechanisms
|
||||
5. Liquidity Verification — LP lock status, minting controls, supply manipulation
|
||||
6. Vulnerability Scanning — Reentrancy, overflow, unchecked external calls
|
||||
7. Risk Scoring — Composite score with detailed findings
|
||||
|
||||
Signals detected:
|
||||
- honeypot patterns (sell disabled, whitelist-only, blacklist on sell)
|
||||
- ownership renouncement status
|
||||
- proxy/upgradeable contract patterns
|
||||
- mint/burn privileges
|
||||
- transfer restrictions (cooldown, tax on sell)
|
||||
- anti-whale mechanisms
|
||||
- known scam function signatures
|
||||
- flashloan attack vectors
|
||||
|
||||
Tier : Premium ($0.08)
|
||||
Price : 80000 atoms
|
||||
Endpoint: POST /api/v1/x402-tools/contract_scan
|
||||
|
||||
Usage:
|
||||
from app.contract_scan import ContractScanner
|
||||
|
||||
scanner = ContractScanner()
|
||||
result = await scanner.scan("0x...")
|
||||
print(result.score, result.findings)
|
||||
|
||||
CLI:
|
||||
python3 contract_scan.py 0x1234... --chain ethereum
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
SOLANA_ADDR_RE = re.compile(r"^[1-9A-HJ-NP-Za-km-z]{32,44}$")
|
||||
EVM_ADDR_RE = re.compile(r"^0x[a-fA-F0-9]{40}$")
|
||||
|
||||
EVM_CHAINS = frozenset({
|
||||
"ethereum", "bsc", "polygon", "arbitrum", "optimism",
|
||||
"avalanche", "base", "fantom", "linea", "zksync", "scroll",
|
||||
})
|
||||
|
||||
SUPPORTED_CHAINS = [*EVM_CHAINS, "solana"]
|
||||
|
||||
# Known vulnerable opcode patterns (Solidity)
|
||||
VULNERABLE_PATTERNS = {
|
||||
"reentrancy": [r"call\.value", r"\.call\(", r"call\.gas\("],
|
||||
"unchecked_send": [r"\.send\(", r"\.transfer\("],
|
||||
"integer_overflow": [r"unchecked\s*\{", r"\+\+.*\+\+"],
|
||||
"delegatecall": [r"delegatecall", r"DELEGATECALL"],
|
||||
"selfdestruct": [r"selfdestruct", r"suicide\("],
|
||||
"tx_origin": [r"tx\.origin"],
|
||||
}
|
||||
|
||||
# Known honeypot function signatures
|
||||
HONEYPOT_SIGS = [
|
||||
"0x181f3e6", # transfer
|
||||
"0xa9059cbb", # transfer
|
||||
"0x23b872dd", # transferFrom
|
||||
"0x095ea7b3", # approve
|
||||
"0x70a08231", # balanceOf
|
||||
]
|
||||
|
||||
# DEX APIs
|
||||
DEXSCREENER_API = "https://api.dexscreener.com/latest/dex/tokens"
|
||||
BIRDEYE_API = "https://public-api.birdeye.so"
|
||||
|
||||
# Risk thresholds
|
||||
SCORE_HIGH_RISK = 70
|
||||
SCORE_MEDIUM_RISK = 30
|
||||
SCORE_LOW_RISK = 10
|
||||
|
||||
|
||||
# ── Enums & Types ────────────────────────────────────────────────────────
|
||||
|
||||
class RiskLevel(Enum):
|
||||
SAFE = "safe"
|
||||
LOW = "low"
|
||||
MEDIUM = "medium"
|
||||
HIGH = "high"
|
||||
CRITICAL = "critical"
|
||||
|
||||
|
||||
class FindingType(Enum):
|
||||
HONEYPOT = "honeypot"
|
||||
OWNED = "owned"
|
||||
UPGRADEABLE = "upgradeable"
|
||||
MINT_PRIVILEGE = "mint_privilege"
|
||||
BLACKLIST = "blacklist"
|
||||
WHITELIST = "whitelist"
|
||||
HIGH_TAX = "high_tax"
|
||||
COOLDOWN = "cooldown"
|
||||
ANTI_WHALE = "anti_whale"
|
||||
REENTRANCY = "reentrancy"
|
||||
DELEGATECALL = "delegatecall"
|
||||
SELFDESTRUCT = "selfdestruct"
|
||||
|
||||
|
||||
# ── Data Models ───────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class Finding:
|
||||
"""A single security finding."""
|
||||
finding_type: FindingType
|
||||
severity: RiskLevel
|
||||
description: str
|
||||
evidence: str = ""
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"type": self.finding_type.value,
|
||||
"severity": self.severity.value,
|
||||
"description": self.description,
|
||||
"evidence": self.evidence,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContractScanResult:
|
||||
"""Result of contract scan."""
|
||||
contract_address: str
|
||||
chain: str
|
||||
score: float # 0-100, higher = riskier
|
||||
risk_level: RiskLevel
|
||||
findings: list[Finding] = field(default_factory=list)
|
||||
bytecode_length: int = 0
|
||||
is_proxy: bool = False
|
||||
is_verified: bool = False
|
||||
constructor_args: str = ""
|
||||
proxy_implementation: str = ""
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"contract_address": self.contract_address,
|
||||
"chain": self.chain,
|
||||
"score": self.score,
|
||||
"risk_level": self.risk_level.value,
|
||||
"findings": [f.to_dict() for f in self.findings],
|
||||
"bytecode_length": self.bytecode_length,
|
||||
"is_proxy": self.is_proxy,
|
||||
"is_verified": self.is_verified,
|
||||
"proxy_implementation": self.proxy_implementation,
|
||||
}
|
||||
|
||||
|
||||
# ── Main Scanner Class ───────────────────────────────────────────────────
|
||||
|
||||
class ContractScanner:
|
||||
"""Deep contract scanner for security analysis."""
|
||||
|
||||
def __init__(self, alchemy_key: str | None = None):
|
||||
self.alchemy_key = alchemy_key or None
|
||||
self.client = httpx.AsyncClient(timeout=30.0)
|
||||
|
||||
async def scan(
|
||||
self,
|
||||
contract_address: str,
|
||||
chain: str = "ethereum",
|
||||
include_source: bool = False,
|
||||
) -> ContractScanResult:
|
||||
"""Scan a contract for security issues."""
|
||||
chain = chain.lower()
|
||||
if chain not in SUPPORTED_CHAINS:
|
||||
raise ValueError(f"Unsupported chain: {chain}")
|
||||
|
||||
result = ContractScanResult(
|
||||
contract_address=contract_address,
|
||||
chain=chain,
|
||||
score=0.0,
|
||||
risk_level=RiskLevel.SAFE,
|
||||
)
|
||||
|
||||
# Fetch contract bytecode
|
||||
bytecode = await self._get_bytecode(contract_address, chain)
|
||||
if bytecode:
|
||||
result.bytecode_length = len(bytecode)
|
||||
await self._analyze_bytecode(bytecode, result)
|
||||
|
||||
# Check verification and proxy status
|
||||
result.is_verified, result.is_proxy, result.proxy_implementation = await self._get_contract_metadata(contract_address, chain)
|
||||
if result.is_proxy:
|
||||
result.findings.append(Finding(
|
||||
finding_type=FindingType.UPGRADEABLE,
|
||||
severity=RiskLevel.HIGH,
|
||||
description="Contract is upgradeable via proxy pattern",
|
||||
evidence=f"Implementation: {result.proxy_implementation[:20]}..."
|
||||
))
|
||||
|
||||
# Check liquidity
|
||||
await self._check_liquidity(contract_address, chain, result)
|
||||
|
||||
# Calculate final score
|
||||
result.score = self._calculate_score(result.findings)
|
||||
result.risk_level = self._get_risk_level(result.score)
|
||||
|
||||
return result
|
||||
|
||||
async def _get_bytecode(self, address: str, chain: str) -> str | None:
|
||||
"""Fetch contract bytecode from chain RPC."""
|
||||
if chain in EVM_CHAINS:
|
||||
try:
|
||||
# Use public RPC
|
||||
rpc_url = f"https://{chain}.llamarpc.com"
|
||||
payload = {
|
||||
"jsonrpc": "2.0",
|
||||
"method": "eth_getCode",
|
||||
"params": [address, "latest"],
|
||||
"id": 1,
|
||||
}
|
||||
resp = await self.client.post(rpc_url, json=payload)
|
||||
data = resp.json()
|
||||
return data.get("result", "")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch bytecode: {e}")
|
||||
return None
|
||||
|
||||
async def _get_contract_metadata(self, address: str, chain: str) -> tuple[bool, bool, str]:
|
||||
"""Get contract verification and proxy status."""
|
||||
try:
|
||||
# Etherscan/DexScreener
|
||||
url = f"https://api.dexscreener.com/latest/dex/tokens/{address}"
|
||||
resp = await self.client.get(url)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
# Check for proxy implementation
|
||||
proxy_impl = data.get("proxy", {}).get("implementation", "")
|
||||
return True, bool(proxy_impl), proxy_impl
|
||||
except Exception as e:
|
||||
logger.debug(f"Metadata fetch failed: {e}")
|
||||
return False, False, ""
|
||||
|
||||
async def _analyze_bytecode(self, bytecode: str, result: ContractScanResult) -> None:
|
||||
"""Analyze bytecode for security patterns."""
|
||||
# Convert to lowercase for matching
|
||||
bc_lower = bytecode.lower()
|
||||
|
||||
# Check honeypot signatures
|
||||
for sig in HONEYPOT_SIGS:
|
||||
if sig not in bc_lower:
|
||||
continue
|
||||
# If transfer sig exists but sell patterns missing, could be honeypot
|
||||
if sig == "0xa9059cbb": # transfer
|
||||
# Check for sell restrictions
|
||||
if "0x70a08231" not in bc_lower: # balanceOf missing
|
||||
result.findings.append(Finding(
|
||||
finding_type=FindingType.HONEYPOT,
|
||||
severity=RiskLevel.CRITICAL,
|
||||
description="Potential honeypot: transfer function without balance query"
|
||||
))
|
||||
|
||||
# Check vulnerable patterns
|
||||
for vuln_type, patterns in VULNERABLE_PATTERNS.items():
|
||||
for pattern in patterns:
|
||||
if re.search(pattern, bytecode, re.IGNORECASE):
|
||||
ft = FindingType.HONEYPOT if vuln_type == "honeypot" else (
|
||||
FindingType.DELEGATECALL if vuln_type == "delegatecall" else
|
||||
FindingType.REENTRANCY if vuln_type == "reentrancy" else
|
||||
FindingType.SELFDESTRUCT if vuln_type == "selfdestruct" else None
|
||||
)
|
||||
if ft:
|
||||
result.findings.append(Finding(
|
||||
finding_type=ft,
|
||||
severity=RiskLevel.HIGH,
|
||||
description=f"Vulnerable pattern detected: {vuln_type}",
|
||||
evidence=f"Pattern: {pattern}"
|
||||
))
|
||||
|
||||
async def _check_liquidity(self, address: str, chain: str, result: ContractScanResult) -> None:
|
||||
"""Check liquidity status on DEXs."""
|
||||
try:
|
||||
url = f"{DEXSCREENER_API}/{address}"
|
||||
resp = await self.client.get(url)
|
||||
if resp.status_code != 200:
|
||||
return
|
||||
|
||||
data = resp.json()
|
||||
pairs = data.get("pairs", [])
|
||||
if not pairs:
|
||||
result.findings.append(Finding(
|
||||
finding_type=FindingType.HIGH_TAX,
|
||||
severity=RiskLevel.MEDIUM,
|
||||
description="No liquidity pairs found on DEXs"
|
||||
))
|
||||
except Exception as e:
|
||||
logger.debug(f"Liquidity check failed: {e}")
|
||||
|
||||
def _calculate_score(self, findings: list[Finding]) -> float:
|
||||
"""Calculate risk score from findings."""
|
||||
score = 0.0
|
||||
weights = {
|
||||
RiskLevel.CRITICAL: 25,
|
||||
RiskLevel.HIGH: 15,
|
||||
RiskLevel.MEDIUM: 8,
|
||||
RiskLevel.LOW: 3,
|
||||
}
|
||||
for finding in findings:
|
||||
score += weights.get(finding.severity, 0)
|
||||
return min(100, score)
|
||||
|
||||
def _get_risk_level(self, score: float) -> RiskLevel:
|
||||
"""Convert score to risk level."""
|
||||
if score >= SCORE_HIGH_RISK:
|
||||
return RiskLevel.CRITICAL
|
||||
if score >= SCORE_MEDIUM_RISK:
|
||||
return RiskLevel.HIGH
|
||||
if score >= SCORE_LOW_RISK:
|
||||
return RiskLevel.MEDIUM
|
||||
return RiskLevel.SAFE
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close HTTP client."""
|
||||
await self.client.aclose()
|
||||
|
||||
|
||||
# ── CLI Interface ────────────────────────────────────────────────────────
|
||||
|
||||
async def main():
|
||||
"""CLI entry point."""
|
||||
import argparse
|
||||
p = argparse.ArgumentParser(description="Deep Contract Scanner")
|
||||
p.add_argument("address", help="Contract address")
|
||||
p.add_argument("--chain", default="ethereum", help="Chain name")
|
||||
args = p.parse_args()
|
||||
|
||||
scanner = ContractScanner()
|
||||
try:
|
||||
result = await scanner.scan(args.address, args.chain)
|
||||
print(json.dumps(result.to_dict(), indent=2))
|
||||
finally:
|
||||
await scanner.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
"""Cross-cutting concerns. NO business logic."""
|
||||
|
||||
from app.core.agent_memory import get_conversation, get_user_context, store_conversation
|
||||
from app.core.ai_stream import StreamRequest, ai_route, list_providers, stream_ai
|
||||
from app.core.auth import AuthMiddleware, is_public_path
|
||||
from app.core.cerebras_provider import cerebras_chat
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.core.cost_tracker import cheapest_for_task, get_cheapest_model, get_rates, get_usage, log_usage
|
||||
from app.core.databus_extras import provider_dashboard, task_queue_stats
|
||||
from app.core.db import get_supabase, get_supabase_sync
|
||||
from app.core.db_pool import get_postgres, pool_stats, return_postgres
|
||||
from app.core.errors import AppError, AuthError, NotFoundError, RateLimitError, register_error_handlers
|
||||
from app.core.http import close_http_client
|
||||
from app.core.lifespan import lifespan
|
||||
from app.core.llm_cache import get_cache_stats, get_cached, set_cached
|
||||
from app.core.logging import get_logger, setup_logging
|
||||
from app.core.metrics import setup_metrics
|
||||
from app.core.middleware import cache_middleware, emergency_lockdown_middleware, hsts_middleware, payload_size_limit_middleware, request_id_middleware, secure_cookie_middleware
|
||||
from app.core.mistral_provider import mistral_chat, mistral_embed, mistral_moderate
|
||||
from app.core.model_eval import compare_models, evaluate_model
|
||||
from app.core.model_router import RoutingDecision, TaskType, route_task, smart_route
|
||||
from app.core.prompt_registry import get_prompt, get_prompt_info, list_prompts, load_all_prompts, reload_prompts, render_prompt
|
||||
from app.core.rate_limiter import Tier, UpgradeRequest, check_rate_limit, get_tiers, get_user_tier, my_tier, payment_links, upgrade_tier
|
||||
from app.core.redis import get_redis, get_redis_async, invalidate_redis
|
||||
from app.core.signal_generator import fetch_trending, main, publish_signal, scan_and_signal
|
||||
from app.core.task_queue import enqueue, get_queue_stats, process_tasks, register_task
|
||||
from app.core.tracing import end_span, setup_tracing, start_span
|
||||
from app.core.tron_provider import tron_balance, tron_transactions
|
||||
from app.core.websocket import active_connections, broadcast_alert, broadcast_scan, register_connection, unregister_connection
|
||||
|
||||
__all__ = ['AppError', 'AuthError', 'AuthMiddleware', 'NotFoundError', 'RateLimitError', 'RoutingDecision', 'Settings', 'StreamRequest', 'TaskType', 'Tier', 'UpgradeRequest', 'active_connections', 'ai_route', 'broadcast_alert', 'broadcast_scan', 'cache_middleware', 'cerebras_chat', 'cheapest_for_task', 'check_rate_limit', 'close_http_client', 'compare_models', 'emergency_lockdown_middleware', 'end_span', 'enqueue', 'evaluate_model', 'fetch_trending', 'get_cache_stats', 'get_cached', 'get_cheapest_model', 'get_conversation', 'get_logger', 'get_postgres', 'get_prompt', 'get_prompt_info', 'get_queue_stats', 'get_rates', 'get_redis', 'get_redis', 'get_redis_async', 'get_settings', 'get_supabase', 'get_supabase_sync', 'get_tiers', 'get_usage', 'get_user_context', 'get_user_tier', 'hsts_middleware', 'invalidate_redis', 'is_public_path', 'lifespan', 'list_prompts', 'list_providers', 'load_all_prompts', 'log_usage', 'main', 'mistral_chat', 'mistral_embed', 'mistral_moderate', 'my_tier', 'payload_size_limit_middleware', 'payment_links', 'pool_stats', 'process_tasks', 'provider_dashboard', 'publish_signal', 'register_connection', 'register_error_handlers', 'register_task', 'reload_prompts', 'render_prompt', 'request_id_middleware', 'return_postgres', 'route_task', 'scan_and_signal', 'secure_cookie_middleware', 'set_cached', 'setup_logging', 'setup_metrics', 'setup_tracing', 'smart_route', 'start_span', 'store_conversation', 'stream_ai', 'task_queue_stats', 'tron_balance', 'tron_transactions', 'unregister_connection', 'upgrade_tier']
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
"""#9 — Agent Memory Layer. Stores conversation history in Memgraph for long-term agent memory.
|
||||
Enables agents to remember past interactions across sessions."""
|
||||
|
||||
import os
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
MEMGRAPH_URI = os.getenv("MEMGRAPH_URI", "bolt://localhost:7687")
|
||||
MEMGRAPH_USER = os.getenv("MEMGRAPH_USER", "")
|
||||
MEMGRAPH_PASS = os.getenv("MEMGRAPH_PASSWORD", "")
|
||||
|
||||
router = APIRouter(prefix="/api/v1/memory", tags=["agent-memory"])
|
||||
|
||||
|
||||
async def _run_query(query: str, params: dict = None) -> list:
|
||||
"""Run a Cypher query against Memgraph."""
|
||||
import requests
|
||||
|
||||
try:
|
||||
r = requests.post(
|
||||
"http://localhost:7444/db/memgraph/query",
|
||||
json={"query": query, "parameters": params or {}},
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=10,
|
||||
)
|
||||
if r.status_code == 200:
|
||||
return r.json().get("data", [])
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
@router.post("/conversation")
|
||||
async def store_conversation(user_id: str, agent_id: str, message: str, role: str = "user"):
|
||||
"""Store a conversation message in agent memory graph."""
|
||||
query = """
|
||||
MERGE (u:User {id: $user_id})
|
||||
MERGE (a:Agent {id: $agent_id})
|
||||
MERGE (c:Conversation {id: $conv_id})
|
||||
ON CREATE SET c.created_at = $timestamp
|
||||
CREATE (m:Message {
|
||||
role: $role,
|
||||
content: $message,
|
||||
timestamp: $timestamp
|
||||
})
|
||||
MERGE (u)-[:PARTICIPATES_IN]->(c)
|
||||
MERGE (a)-[:PARTICIPATES_IN]->(c)
|
||||
MERGE (c)-[:HAS_MESSAGE]->(m)
|
||||
MERGE (m)-[:SENT_BY]->(CASE WHEN $role = 'user' THEN u ELSE a END)
|
||||
"""
|
||||
conv_id = f"{user_id}:{agent_id}"
|
||||
await _run_query(
|
||||
query,
|
||||
{
|
||||
"user_id": user_id,
|
||||
"agent_id": agent_id,
|
||||
"conv_id": conv_id,
|
||||
"role": role,
|
||||
"message": message,
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
},
|
||||
)
|
||||
return {"stored": True, "conversation_id": conv_id}
|
||||
|
||||
|
||||
@router.get("/conversation/{user_id}/{agent_id}")
|
||||
async def get_conversation(user_id: str, agent_id: str, limit: int = 20):
|
||||
"""Retrieve conversation history for an agent."""
|
||||
query = """
|
||||
MATCH (u:User {id: $user_id})-[:PARTICIPATES_IN]->(c:Conversation)<-[:PARTICIPATES_IN]-(a:Agent {id: $agent_id})
|
||||
MATCH (c)-[:HAS_MESSAGE]->(m:Message)
|
||||
RETURN m.role as role, m.content as content, m.timestamp as timestamp
|
||||
ORDER BY m.timestamp DESC LIMIT $limit
|
||||
"""
|
||||
rows = await _run_query(query, {"user_id": user_id, "agent_id": agent_id, "limit": limit})
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"agent_id": agent_id,
|
||||
"messages": [{"role": r[0], "content": r[1], "timestamp": r[2]} for r in reversed(rows)] if rows else [],
|
||||
"count": len(rows) if rows else 0,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/context/{user_id}")
|
||||
async def get_user_context(user_id: str):
|
||||
"""Get all agent conversations + preferences for a user."""
|
||||
query = """
|
||||
MATCH (u:User {id: $user_id})-[:PARTICIPATES_IN]->(c:Conversation)
|
||||
MATCH (a:Agent)-[:PARTICIPATES_IN]->(c)
|
||||
RETURN a.id as agent, count(*) as messages
|
||||
ORDER BY messages DESC
|
||||
"""
|
||||
rows = await _run_query(query, {"user_id": user_id})
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"agents": [{"agent": r[0], "messages": r[1]} for r in rows] if rows else [],
|
||||
}
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
"""SSE Response Streaming — real-time token streaming for AI endpoints."""
|
||||
import asyncio
|
||||
import json
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from app.core.model_router import route_task, TaskType
|
||||
|
||||
router = APIRouter(prefix="/api/v1/ai", tags=["ai-streaming"])
|
||||
|
||||
class StreamRequest(BaseModel):
|
||||
prompt: str
|
||||
task: str = "fast" # fast|cheap|complex|bulk|code
|
||||
prefer: str = "fast" # fast|cheap
|
||||
|
||||
async def _stream_ollama(prompt: str, model: str):
|
||||
"""Stream from Ollama."""
|
||||
import httpx
|
||||
async with httpx.AsyncClient(timeout=120) as c:
|
||||
async with c.stream("POST", "http://localhost:11434/api/generate", json={
|
||||
"model": model, "prompt": prompt
|
||||
}) as r:
|
||||
async for line in r.aiter_lines():
|
||||
if line:
|
||||
try:
|
||||
chunk = json.loads(line)
|
||||
if chunk.get("done"):
|
||||
yield f"data: {json.dumps({'done': True, 'model': model})}\n\n"
|
||||
break
|
||||
yield f"data: {json.dumps({'token': chunk.get('response', '')})}\n\n"
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
@router.post("/stream")
|
||||
async def stream_ai(req: StreamRequest):
|
||||
"""Stream AI response in real-time. Tokens appear as generated."""
|
||||
decision = route_task(TaskType(req.task), req.prefer)
|
||||
|
||||
if decision.provider == "ollama":
|
||||
return StreamingResponse(
|
||||
_stream_ollama(req.prompt, decision.model),
|
||||
media_type="text/event-stream",
|
||||
headers={"X-Model": decision.model, "X-Provider": decision.provider, "X-Latency-Ms": str(decision.estimated_latency_ms)}
|
||||
)
|
||||
|
||||
# For non-Ollama providers, fall back to blocking + stream result
|
||||
async def _blocking_stream():
|
||||
from app.core.model_router import smart_route
|
||||
result = await smart_route(req.prompt, req.task, req.prefer)
|
||||
if result and "response" in result:
|
||||
words = result["response"].split()
|
||||
for word in words:
|
||||
yield f"data: {json.dumps({'token': word + ' '})}\n\n"
|
||||
await asyncio.sleep(0.05)
|
||||
yield f"data: {json.dumps({'done': True, 'model': decision.model})}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
_blocking_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={"X-Model": decision.model, "X-Provider": decision.provider}
|
||||
)
|
||||
|
||||
@router.post("/route")
|
||||
async def ai_route(req: StreamRequest):
|
||||
"""Non-streaming: auto-route to best model, return complete response."""
|
||||
from app.core.model_router import smart_route
|
||||
result = await smart_route(req.prompt, req.task, req.prefer)
|
||||
return result if result else {"error": "All providers failed"}
|
||||
|
||||
@router.get("/providers")
|
||||
async def list_providers():
|
||||
"""List all available AI providers with capabilities."""
|
||||
from app.core.model_router import ROUTING_TABLE
|
||||
return {
|
||||
"providers": {
|
||||
task.value: [{"model": m[0], "provider": m[1], "latency_ms": m[2]*1000, "cost_per_1M_input": m[3]}
|
||||
for m in models]
|
||||
for task, models in ROUTING_TABLE.items()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
"""RMI Backend — Auth middleware, API key verification, and JWT user identity.
|
||||
|
||||
This module is the single source of truth for auth. Routes import
|
||||
`get_current_user` / `get_optional_user` from here (re-exported via
|
||||
app.api.deps for convenience).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
RMI_AUTH_TOKEN = os.getenv("RMI_AUTH_TOKEN", "")
|
||||
|
||||
PUBLIC_WRITE_PREFIXES = [
|
||||
"/api/v1/auth/",
|
||||
"/api/v1/x402/",
|
||||
"/api/v1/x402-tools/",
|
||||
"/api/v1/x402-databus/",
|
||||
"/api/v1/databus/",
|
||||
"/api/v1/alerts/",
|
||||
"/api/v1/admin/",
|
||||
"/api/v1/content/",
|
||||
"/api/v1/bulletin/",
|
||||
"/api/v1/rag/permanence/",
|
||||
"/api/v1/token/",
|
||||
"/api/v1/ai/",
|
||||
"/api/v1/premium/",
|
||||
"/api/v1/rag/",
|
||||
"/api/v1/protect/",
|
||||
"/api/v1/wallet-manager/",
|
||||
]
|
||||
|
||||
PUBLIC_GET_PREFIXES = [
|
||||
"/api/v1/token/",
|
||||
"/api/v1/databus/",
|
||||
"/api/v1/alerts/",
|
||||
"/api/v1/x402-databus/",
|
||||
"/api/v1/x402-tools/",
|
||||
]
|
||||
|
||||
|
||||
def is_public_path(path: str, method: str) -> bool:
|
||||
"""Check if a path is publicly accessible without auth."""
|
||||
if path in ("/health", "/ready", "/docs", "/openapi.json", "/redoc", "/", "/favicon.ico"):
|
||||
return True
|
||||
if path.startswith("/ws/") or not path.startswith("/api/"):
|
||||
return True
|
||||
if method in ("POST", "PUT", "DELETE", "PATCH"):
|
||||
return any(path.startswith(p) for p in PUBLIC_WRITE_PREFIXES)
|
||||
return True # GET/HEAD always public
|
||||
|
||||
|
||||
class AuthMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
path = request.url.path
|
||||
method = request.method
|
||||
|
||||
if is_public_path(path, method):
|
||||
return await call_next(request)
|
||||
|
||||
api_key = request.headers.get("X-API-Key", "")
|
||||
if RMI_AUTH_TOKEN and api_key != RMI_AUTH_TOKEN:
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"detail": "Unauthorized - valid X-API-Key header required for write operations"},
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
# ── JWT user identity (FastAPI dependencies) ─────────────────────────────
|
||||
# Delegates to the legacy app.auth JWT logic during strangelfig migration.
|
||||
# Once legacy auth.py is migrated to the new pattern, these become the
|
||||
# canonical implementation. Until then they reuse the working logic so
|
||||
# new routes (like app/api/v1/auth/alerts.py) can use modern Depends().
|
||||
|
||||
async def get_optional_user(request: Request) -> dict[str, Any] | None:
|
||||
"""Return the authenticated user dict, or None if not authenticated.
|
||||
|
||||
Reads the Authorization: Bearer <jwt> header. Returns the user dict
|
||||
(id, email, tier, role) on success, None if no/invalid token.
|
||||
|
||||
Use this for endpoints that work with OR without auth.
|
||||
"""
|
||||
from app.auth import get_current_user as _legacy_get_user # local import to avoid cycles
|
||||
return await _legacy_get_user(request)
|
||||
|
||||
|
||||
async def get_current_user(request: Request) -> dict[str, Any]:
|
||||
"""Require an authenticated user. Raises 401 if missing.
|
||||
|
||||
Use this for endpoints that REQUIRE auth.
|
||||
"""
|
||||
from app.auth import require_auth as _legacy_require # local import to avoid cycles
|
||||
return await _legacy_require(request)
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
"""Cerebras provider — GPT-OSS-120B, fastest inference on Earth (9ms).
|
||||
Free tier: 14,400 req/day, 1M tokens/day."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
CEREBRAS_KEY = os.getenv("CEREBRAS_API_KEY", "")
|
||||
BASE = "https://api.cerebras.ai/v1"
|
||||
|
||||
|
||||
async def cerebras_chat(
|
||||
prompt: str, system: str = None, temperature: float = 0.7, max_tokens: int = 1024
|
||||
) -> dict | None:
|
||||
"""GPT-OSS-120B via Cerebras — 9ms latency. Use for real-time, latency-sensitive tasks."""
|
||||
if not CEREBRAS_KEY:
|
||||
return None
|
||||
messages = []
|
||||
if system:
|
||||
messages.append({"role": "system", "content": system})
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as c:
|
||||
r = await c.post(
|
||||
f"{BASE}/chat/completions",
|
||||
json={
|
||||
"model": "gpt-oss-120b",
|
||||
"messages": messages,
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {CEREBRAS_KEY}"},
|
||||
)
|
||||
if r.status_code == 200:
|
||||
d = r.json()
|
||||
choice = d["choices"][0]["message"]
|
||||
content = choice.get("content") or choice.get("reasoning", "")
|
||||
return {
|
||||
"response": content.strip(),
|
||||
"model": "gpt-oss-120b",
|
||||
"tokens": d.get("usage", {}).get("total_tokens", 0),
|
||||
"latency_ms": round(d.get("time_info", {}).get("total_time", 0) * 1000),
|
||||
"provider": "cerebras",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Cerebras failed: {e}")
|
||||
return None
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
"""
|
||||
Central configuration — single source of truth for all settings.
|
||||
Loaded from .env via pydantic-settings.
|
||||
|
||||
Usage:
|
||||
from app.core.config import settings
|
||||
print(settings.REDIS_HOST)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from functools import lru_cache
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Settings:
|
||||
"""Application settings loaded from environment variables."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# Redis
|
||||
self.REDIS_HOST: str = os.getenv("REDIS_HOST", "rmi-redis")
|
||||
self.REDIS_PORT: int = int(os.getenv("REDIS_PORT", "6379"))
|
||||
self.REDIS_PASSWORD: str = os.getenv("REDIS_PASSWORD", "")
|
||||
self.REDIS_DB: int = int(os.getenv("REDIS_DB", "0"))
|
||||
|
||||
# Supabase
|
||||
self.SUPABASE_URL: str = os.getenv("SUPABASE_URL", "")
|
||||
self.SUPABASE_SERVICE_KEY: str = os.getenv("SUPABASE_SERVICE_KEY", "") or os.getenv("SUPABASE_KEY", "")
|
||||
|
||||
# Admin
|
||||
self.ADMIN_API_KEY: str = os.getenv("ADMIN_API_KEY", "dev-key-change-me")
|
||||
|
||||
# API Keys
|
||||
self.OPENROUTER_API_KEY: str = os.getenv("OPENROUTER_API_KEY", "")
|
||||
self.ETHERSCAN_API_KEY: str = os.getenv("ETHERSCAN_API_KEY", "")
|
||||
self.HELIUS_API_KEY: str = os.getenv("HELIUS_API_KEY", "")
|
||||
self.DEEPSEEK_API_KEY: str = os.getenv("DEEPSEEK_API_KEY", "")
|
||||
self.GEMINI_API_KEY: str = os.getenv("GEMINI_API_KEY", "")
|
||||
self.GEMINI_API_KEY_2: str = os.getenv("GEMINI_API_KEY_2", "")
|
||||
|
||||
# Langfuse
|
||||
self.LANGFUSE_PUBLIC_KEY: str = os.getenv("LANGFUSE_PUBLIC_KEY", "")
|
||||
self.LANGFUSE_SECRET_KEY: str = os.getenv("LANGFUSE_SECRET_KEY", "")
|
||||
self.LANGFUSE_HOST: str = os.getenv("LANGFUSE_HOST", "http://langfuse-langfuse-web-1:3000")
|
||||
|
||||
# Ollama
|
||||
self.OLLAMA_HOST: str = os.getenv("OLLAMA_HOST", "http://ollama:11434")
|
||||
|
||||
# Wallet
|
||||
self.WALLET_VAULT_PASSWORD: str = os.getenv("WALLET_VAULT_PASSWORD", "")
|
||||
|
||||
# R2
|
||||
self.R2_API_TOKEN: str = os.getenv("R2_API_TOKEN", "")
|
||||
self.R2_ACCOUNT_ID: str = os.getenv("R2_ACCOUNT_ID", "")
|
||||
self.R2_BUCKET: str = os.getenv("R2_BUCKET", "rag-backup")
|
||||
|
||||
def validate(self) -> list[str]:
|
||||
"""Return list of missing critical env vars. Empty list = all good."""
|
||||
critical = [
|
||||
"REDIS_HOST",
|
||||
"SUPABASE_URL",
|
||||
"SUPABASE_SERVICE_KEY",
|
||||
"ADMIN_API_KEY",
|
||||
"WALLET_VAULT_PASSWORD",
|
||||
]
|
||||
missing = []
|
||||
for var in critical:
|
||||
if not getattr(self, var, None):
|
||||
missing.append(var)
|
||||
return missing
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
"""Cached singleton — call this, don't instantiate Settings directly."""
|
||||
return Settings()
|
||||
|
||||
|
||||
# Module-level singleton — import this everywhere
|
||||
settings = get_settings()
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
"""#10 — Cost-Per-Model Tracking. Tracks $/1K tokens per model/provider.
|
||||
Auto-routes to cheapest model that meets quality threshold."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
router = APIRouter(prefix="/api/v1/costs", tags=["cost-tracking"])
|
||||
|
||||
# Cost per 1M tokens (USD) — updated June 2026
|
||||
MODEL_COSTS = {
|
||||
"deepseek-v4-flash": {"input": 0.14, "output": 0.28, "provider": "deepseek"},
|
||||
"deepseek-v4-pro": {"input": 0.55, "output": 2.19, "provider": "deepseek"},
|
||||
# Gemini pricing (paid tier, per 1M tokens)
|
||||
"gemini-2.5-flash": {"input": 0.15, "output": 0.60, "provider": "gemini"},
|
||||
"gemini-2.5-pro": {"input": 1.25, "output": 10.00, "provider": "gemini"},
|
||||
"gemini-3.5-flash": {"input": 1.50, "output": 9.00, "provider": "gemini"},
|
||||
"mistral-small-latest": {"input": 0.0, "output": 0.0, "provider": "mistral", "note": "Free tier — 1B tokens/mo"},
|
||||
"mistral-medium-latest": {"input": 0.0, "output": 0.0, "provider": "mistral", "note": "Free tier — use sparingly"},
|
||||
"mistral-embed": {
|
||||
"input": 0.0,
|
||||
"output": 0.0,
|
||||
"provider": "mistral",
|
||||
"note": "Free tier — state of art embeddings",
|
||||
},
|
||||
"mistral-large": {"input": 2.00, "output": 6.00, "provider": "mistral"},
|
||||
"mistral-small": {"input": 0.20, "output": 0.60, "provider": "mistral"},
|
||||
"qwen2.5-coder:7b": {"input": 0.0, "output": 0.0, "provider": "ollama"},
|
||||
"gpt-oss-120b": {
|
||||
"input": 0.0,
|
||||
"output": 0.0,
|
||||
"provider": "cerebras",
|
||||
"note": "Free tier — 14.4K req/day, 9ms latency",
|
||||
},
|
||||
"mistral:7b": {"input": 0.0, "output": 0.0, "provider": "ollama"},
|
||||
"bge-m3": {"input": 0.0, "output": 0.0, "provider": "ollama"},
|
||||
}
|
||||
|
||||
_usage_log: list[dict] = []
|
||||
|
||||
|
||||
def log_usage(model: str, input_tokens: int, output_tokens: int, latency_ms: float):
|
||||
"""Log model usage for cost tracking."""
|
||||
costs = MODEL_COSTS.get(model, {"input": 0, "output": 0, "provider": "unknown"})
|
||||
input_cost = (input_tokens / 1_000_000) * costs["input"]
|
||||
output_cost = (output_tokens / 1_000_000) * costs["output"]
|
||||
total_cost = input_cost + output_cost
|
||||
|
||||
_usage_log.append(
|
||||
{
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"model": model,
|
||||
"provider": costs["provider"],
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
"cost_usd": round(total_cost, 6),
|
||||
"latency_ms": latency_ms,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def get_cheapest_model(models: list[str]) -> str:
|
||||
"""Pick the cheapest model from a list that meets quality."""
|
||||
best = None
|
||||
best_cost = float("inf")
|
||||
for m in models:
|
||||
c = MODEL_COSTS.get(m, {})
|
||||
cost = c.get("output", 1.0)
|
||||
if cost < best_cost:
|
||||
best_cost = cost
|
||||
best = m
|
||||
return best or models[0]
|
||||
|
||||
|
||||
@router.get("/rates")
|
||||
async def get_rates():
|
||||
"""Get current model pricing."""
|
||||
return {"models": MODEL_COSTS, "updated": "2026-06-15"}
|
||||
|
||||
|
||||
@router.get("/usage")
|
||||
async def get_usage(limit: int = Query(50, le=200)):
|
||||
"""Get recent usage log."""
|
||||
recent = _usage_log[-limit:]
|
||||
total_cost = sum(e["cost_usd"] for e in recent)
|
||||
total_tokens = sum(e["input_tokens"] + e["output_tokens"] for e in recent)
|
||||
return {
|
||||
"total_cost_usd": round(total_cost, 4),
|
||||
"total_tokens": total_tokens,
|
||||
"entries": len(recent),
|
||||
"log": recent,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/cheapest")
|
||||
async def cheapest_for_task(quality: str = Query("medium", description="minimum quality tier")):
|
||||
"""Get cheapest model for a given quality tier."""
|
||||
if quality == "high":
|
||||
candidates = ["deepseek-v4-pro", "gemini-2.5-pro", "mistral-large"]
|
||||
elif quality == "medium":
|
||||
candidates = ["deepseek-v4-flash", "gemini-2.5-flash", "mistral-small", "qwen2.5-coder:7b"]
|
||||
else:
|
||||
candidates = ["qwen2.5-coder:7b", "mistral:7b", "deepseek-v4-flash"]
|
||||
|
||||
cheapest = get_cheapest_model(candidates)
|
||||
return {
|
||||
"quality_tier": quality,
|
||||
"candidates": candidates,
|
||||
"cheapest": cheapest,
|
||||
"cost_per_1M_output": MODEL_COSTS.get(cheapest, {}).get("output", "?"),
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
"""#2 SSE Streaming + #3 Provider Dashboard endpoints."""
|
||||
from fastapi import APIRouter
|
||||
import httpx
|
||||
import os
|
||||
|
||||
router = APIRouter(prefix="/api/v1/databus", tags=["databus-extras"])
|
||||
|
||||
BACKEND = os.getenv("BACKEND_URL", "http://localhost:8000")
|
||||
|
||||
@router.get("/providers/dashboard")
|
||||
async def provider_dashboard():
|
||||
"""Real-time provider health dashboard data — feed into Grafana."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as c:
|
||||
r = await c.get(f"{BACKEND}/api/v1/databus/providers/health", headers={"X-RMI-Key": os.getenv("RMI_INTERNAL_KEY", "rmi-internal-2026")})
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
providers = data.get("providers", data)
|
||||
|
||||
# Format for Grafana
|
||||
panels = []
|
||||
for name, health in (providers.items() if isinstance(providers, dict) else []):
|
||||
panels.append({
|
||||
"provider": name,
|
||||
"status": "healthy" if health.get("healthy", True) else "degraded",
|
||||
"latency_ms": health.get("avg_latency_ms", 0),
|
||||
"error_rate": health.get("error_rate", 0),
|
||||
"circuit": health.get("circuit_state", "closed"),
|
||||
})
|
||||
|
||||
return {
|
||||
"providers": panels,
|
||||
"summary": {
|
||||
"total": len(panels),
|
||||
"healthy": sum(1 for p in panels if p["status"] == "healthy"),
|
||||
"degraded": sum(1 for p in panels if p["status"] != "healthy"),
|
||||
}
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"providers": [], "note": "Provider health API unavailable — check backend"}
|
||||
|
||||
@router.get("/queue/stats")
|
||||
async def task_queue_stats():
|
||||
"""Background task queue statistics."""
|
||||
from app.core.task_queue import get_queue_stats
|
||||
return await get_queue_stats()
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
"""
|
||||
Supabase client singleton — single source of truth.
|
||||
Replaces scattered supabase client creation across routers.
|
||||
|
||||
Usage:
|
||||
from app.core.db import get_supabase
|
||||
client = await get_supabase()
|
||||
result = await client.table("alerts").select("*").execute()
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_client: Any = None
|
||||
|
||||
|
||||
async def get_supabase() -> Any:
|
||||
"""Get or create the async Supabase client."""
|
||||
global _client
|
||||
if _client is not None:
|
||||
return _client
|
||||
|
||||
url = os.getenv("SUPABASE_URL", "")
|
||||
key = os.getenv("SUPABASE_SERVICE_KEY", "") or os.getenv("SUPABASE_KEY", "")
|
||||
|
||||
if not url or not key:
|
||||
logger.warning("supabase_not_configured")
|
||||
return None
|
||||
|
||||
try:
|
||||
from supabase import create_client as _create_sync
|
||||
_client = _create_sync(url, key)
|
||||
logger.info("supabase_connected", url=url[:30])
|
||||
return _client
|
||||
except ImportError:
|
||||
logger.error("supabase_package_missing — pip install supabase")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error("supabase_init_failed", error=str(e))
|
||||
return None
|
||||
|
||||
|
||||
def get_supabase_sync() -> Any:
|
||||
"""Synchronous Supabase client for scripts and non-async contexts."""
|
||||
url = os.getenv("SUPABASE_URL", "")
|
||||
key = os.getenv("SUPABASE_SERVICE_KEY", "") or os.getenv("SUPABASE_KEY", "")
|
||||
|
||||
if not url or not key:
|
||||
return None
|
||||
|
||||
try:
|
||||
from supabase import create_client
|
||||
return create_client(url, key)
|
||||
except (ImportError, Exception):
|
||||
return None
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
"""Database connection pooling — Redis + Postgres with auto-reconnect."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
||||
PG_URL = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/postgres")
|
||||
|
||||
_redis_pool = None
|
||||
_pg_pool = None
|
||||
|
||||
|
||||
def get_redis():
|
||||
"""Get or create Redis connection pool. Auto-reconnects."""
|
||||
global _redis_pool
|
||||
if _redis_pool is None:
|
||||
import redis
|
||||
|
||||
try:
|
||||
_redis_pool = redis.ConnectionPool.from_url(
|
||||
REDIS_URL, max_connections=20, retry_on_timeout=True, health_check_interval=30
|
||||
)
|
||||
logger.info("Redis pool created (max 20)")
|
||||
except Exception as e:
|
||||
logger.error(f"Redis pool failed: {e}")
|
||||
return None
|
||||
import redis
|
||||
|
||||
return redis.Redis(connection_pool=_redis_pool)
|
||||
|
||||
|
||||
def get_postgres():
|
||||
"""Get or create Postgres connection pool."""
|
||||
global _pg_pool
|
||||
if _pg_pool is None:
|
||||
try:
|
||||
from psycopg2 import pool
|
||||
|
||||
_pg_pool = pool.ThreadedConnectionPool(5, 20, PG_URL)
|
||||
logger.info("Postgres pool created (5-20)")
|
||||
except Exception as e:
|
||||
logger.error(f"Postgres pool failed: {e}")
|
||||
return None
|
||||
return _pg_pool.getconn()
|
||||
|
||||
|
||||
def return_postgres(conn):
|
||||
"""Return connection to pool."""
|
||||
if _pg_pool and conn:
|
||||
_pg_pool.putconn(conn)
|
||||
|
||||
|
||||
def pool_stats() -> dict:
|
||||
"""Get connection pool statistics."""
|
||||
return {
|
||||
"redis": {"pool_size": 20, "active": "unknown"} if _redis_pool else {"error": "no pool"},
|
||||
"postgres": {"min": 5, "max": 20} if _pg_pool else {"error": "no pool"},
|
||||
}
|
||||
|
|
@ -1,301 +0,0 @@
|
|||
"""Domain-specific error hierarchy.
|
||||
|
||||
Each domain defines its own error subclasses with HTTP status + code.
|
||||
The FastAPI exception handler (in core.errors) returns a consistent
|
||||
ErrorResponse envelope.
|
||||
|
||||
Why domain errors over a generic AppError:
|
||||
- OpenAPI schema documents each error code (clients can switch on code).
|
||||
- Self-documenting: a wallet domain error says "WalletNotFound" not
|
||||
"AppError with code 404".
|
||||
- Each domain owns its error vocabulary. New errors don't require
|
||||
touching the global error catalog.
|
||||
- The legacy code has scattered HTTPException raises. Domain errors
|
||||
replace these with typed exceptions that the handler converts.
|
||||
|
||||
Migration: existing code that raises AppError/NotFoundError/AuthError
|
||||
keeps working (those are base classes). New code should use
|
||||
domain-specific subclasses.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
class AppError(Exception):
|
||||
"""Base for all RMI errors. Each subclass declares its HTTP status."""
|
||||
|
||||
status_code: int = 500
|
||||
code: str = "internal_error"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str = "",
|
||||
*,
|
||||
details: Optional[dict[str, Any]] = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message or self.code
|
||||
self.details = details or {}
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"code": self.code,
|
||||
"message": self.message,
|
||||
"details": self.details,
|
||||
}
|
||||
|
||||
|
||||
# ── Generic categories (subdomain-agnostic) ───────────────────────────
|
||||
|
||||
|
||||
class NotFoundError(AppError):
|
||||
status_code = 404
|
||||
code = "not_found"
|
||||
|
||||
|
||||
class AuthError(AppError):
|
||||
status_code = 401
|
||||
code = "unauthorized"
|
||||
|
||||
|
||||
class ForbiddenError(AppError):
|
||||
status_code = 403
|
||||
code = "forbidden"
|
||||
|
||||
|
||||
class RateLimitError(AppError):
|
||||
status_code = 429
|
||||
code = "rate_limited"
|
||||
|
||||
|
||||
class ValidationError(AppError):
|
||||
status_code = 400
|
||||
code = "validation_error"
|
||||
|
||||
|
||||
class ConflictError(AppError):
|
||||
status_code = 409
|
||||
code = "conflict"
|
||||
|
||||
|
||||
class UpstreamError(AppError):
|
||||
"""External API failed (chain RPC, databus provider, etc)."""
|
||||
|
||||
status_code = 502
|
||||
code = "upstream_error"
|
||||
|
||||
|
||||
# ── Domain-specific errors ────────────────────────────────────────────
|
||||
|
||||
|
||||
class WalletError(AppError):
|
||||
"""Base for all wallet-domain errors."""
|
||||
|
||||
code = "wallet_error"
|
||||
|
||||
|
||||
class WalletNotFoundError(WalletError, NotFoundError):
|
||||
code = "wallet_not_found"
|
||||
|
||||
def __init__(self, address: str, chain: str = "unknown") -> None:
|
||||
super().__init__(
|
||||
f"Wallet {address[:12]}... not found",
|
||||
details={"address": address, "chain": chain},
|
||||
)
|
||||
|
||||
|
||||
class InsufficientFundsError(WalletError):
|
||||
status_code = 402
|
||||
code = "insufficient_funds"
|
||||
|
||||
def __init__(self, required: float, available: float, asset: str = "native") -> None:
|
||||
super().__init__(
|
||||
f"Insufficient {asset}: need {required}, have {available}",
|
||||
details={"required": required, "available": available, "asset": asset},
|
||||
)
|
||||
|
||||
|
||||
class TokenError(AppError):
|
||||
"""Base for all token-domain errors."""
|
||||
|
||||
code = "token_error"
|
||||
|
||||
|
||||
class TokenNotScannedError(TokenError, NotFoundError):
|
||||
code = "token_not_scanned"
|
||||
|
||||
def __init__(self, address: str, chain: str = "unknown") -> None:
|
||||
super().__init__(
|
||||
f"Token {address[:12]}... has not been scanned",
|
||||
details={"address": address, "chain": chain},
|
||||
)
|
||||
|
||||
|
||||
class HoneypotDetectedError(TokenError):
|
||||
status_code = 422
|
||||
code = "honeypot_detected"
|
||||
|
||||
def __init__(self, address: str, chain: str = "unknown") -> None:
|
||||
super().__init__(
|
||||
f"Token {address[:12]}... is a honeypot — cannot trade",
|
||||
details={"address": address, "chain": chain},
|
||||
)
|
||||
|
||||
|
||||
class ScanError(TokenError, UpstreamError):
|
||||
code = "scan_failed"
|
||||
|
||||
def __init__(self, address: str, reason: str) -> None:
|
||||
super().__init__(
|
||||
f"Scan failed for {address[:12]}...: {reason}",
|
||||
details={"address": address, "reason": reason},
|
||||
)
|
||||
|
||||
|
||||
class AlertError(AppError):
|
||||
"""Base for alert-domain errors."""
|
||||
|
||||
code = "alert_error"
|
||||
|
||||
|
||||
class AlertNotFoundError(AlertError, NotFoundError):
|
||||
code = "alert_not_found"
|
||||
|
||||
def __init__(self, alert_id: str) -> None:
|
||||
super().__init__(
|
||||
f"Alert {alert_id} not found",
|
||||
details={"alert_id": alert_id},
|
||||
)
|
||||
|
||||
|
||||
class AlertQuotaExceededError(AlertError, RateLimitError):
|
||||
code = "alert_quota_exceeded"
|
||||
|
||||
def __init__(self, limit: int, used: int) -> None:
|
||||
super().__init__(
|
||||
f"Alert quota exceeded: {used}/{limit}",
|
||||
details={"limit": limit, "used": used},
|
||||
)
|
||||
|
||||
|
||||
class PaymentError(AppError):
|
||||
"""Base for x402 payment errors."""
|
||||
|
||||
code = "payment_error"
|
||||
|
||||
|
||||
class PaymentRequiredError(PaymentError):
|
||||
status_code = 402
|
||||
code = "payment_required"
|
||||
|
||||
def __init__(self, tool: str, price_usd: float, chain: str = "solana") -> None:
|
||||
super().__init__(
|
||||
f"Payment required for {tool}: ${price_usd}",
|
||||
details={"tool": tool, "price_usd": price_usd, "chain": chain},
|
||||
)
|
||||
|
||||
|
||||
class PaymentFailedError(PaymentError, UpstreamError):
|
||||
code = "payment_failed"
|
||||
|
||||
def __init__(self, tx_hash: str | None, reason: str) -> None:
|
||||
super().__init__(
|
||||
f"Payment failed: {reason}",
|
||||
details={"tx_hash": tx_hash, "reason": reason},
|
||||
)
|
||||
|
||||
|
||||
class RAGError(AppError):
|
||||
"""Base for RAG errors."""
|
||||
|
||||
code = "rag_error"
|
||||
|
||||
|
||||
class RAGSearchError(RAGError, UpstreamError):
|
||||
code = "rag_search_failed"
|
||||
|
||||
def __init__(self, query: str, reason: str) -> None:
|
||||
super().__init__(
|
||||
f"RAG search failed: {reason}",
|
||||
details={"query": query[:100], "reason": reason},
|
||||
)
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def domain_error_response(error: AppError) -> dict[str, Any]:
|
||||
"""Convert an AppError to the standard error envelope."""
|
||||
return {
|
||||
"code": error.code,
|
||||
"message": error.message,
|
||||
"details": error.details,
|
||||
"status": error.status_code,
|
||||
}
|
||||
|
||||
|
||||
# ── FastAPI exception handlers ──────────────────────────────────────
|
||||
|
||||
|
||||
def register_error_handlers(app: Any, debug: bool = False) -> None:
|
||||
"""Register exception handlers on the FastAPI app.
|
||||
|
||||
Handlers:
|
||||
- AppError subclasses → domain_error_response, status from class
|
||||
- StarletteHTTPException → standard {error, code, request_id} envelope
|
||||
- ValueError → 400 validation error
|
||||
- Exception → 500 with optional traceback (dev only)
|
||||
"""
|
||||
import traceback
|
||||
import uuid
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
@app.exception_handler(AppError)
|
||||
async def app_error_handler(request: Request, exc: AppError):
|
||||
request_id = getattr(request.state, "request_id", str(uuid.uuid4()))
|
||||
body = domain_error_response(exc)
|
||||
body["request_id"] = request_id
|
||||
return JSONResponse(status_code=exc.status_code, content=body)
|
||||
|
||||
@app.exception_handler(StarletteHTTPException)
|
||||
async def http_exception_handler(request: Request, exc: StarletteHTTPException):
|
||||
request_id = getattr(request.state, "request_id", str(uuid.uuid4()))
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={
|
||||
"code": exc.status_code,
|
||||
"message": str(exc.detail),
|
||||
"details": {},
|
||||
"request_id": request_id,
|
||||
},
|
||||
)
|
||||
|
||||
@app.exception_handler(ValueError)
|
||||
async def value_error_handler(request: Request, exc: ValueError):
|
||||
request_id = getattr(request.state, "request_id", str(uuid.uuid4()))
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"code": "validation_error",
|
||||
"message": str(exc),
|
||||
"details": {},
|
||||
"request_id": request_id,
|
||||
},
|
||||
)
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def unhandled_exception_handler(request: Request, exc: Exception):
|
||||
request_id = getattr(request.state, "request_id", str(uuid.uuid4()))
|
||||
body = {
|
||||
"code": "internal_error",
|
||||
"message": "Internal server error" if not debug else str(exc),
|
||||
"details": {},
|
||||
"request_id": request_id,
|
||||
}
|
||||
if debug:
|
||||
body["details"]["traceback"] = traceback.format_exc().split("\n")
|
||||
return JSONResponse(status_code=500, content=body)
|
||||
|
|
@ -1,169 +0,0 @@
|
|||
"""Health check hierarchy: /live, /ready, /health.
|
||||
|
||||
Three tiers, kubernetes-grade:
|
||||
|
||||
/live — process up. Always 200 if the Python process is alive.
|
||||
Use this for liveness probes (restart-on-fail).
|
||||
|
||||
/ready — critical deps reachable: redis, databus, vector store.
|
||||
200 if all healthy, 503 if any critical dep down.
|
||||
Use this for readiness probes (route traffic only when ready).
|
||||
|
||||
/health — deep per-domain checks. Each domain registers a health_check()
|
||||
function. The endpoint runs them all and returns per-domain
|
||||
status. 200 if all healthy, 503 if any critical domain down.
|
||||
Use this for monitoring dashboards and incident response.
|
||||
|
||||
Design: each domain provides a health_check() function via
|
||||
register_health_check(). The health module doesn't import the domains
|
||||
directly — they register themselves. This keeps core/ free of domain
|
||||
coupling.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Awaitable, Callable, Optional
|
||||
|
||||
from app.core.logging import get_logger
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
HealthCheck = Callable[[], Awaitable["DomainHealth"]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class DomainHealth:
|
||||
"""Result of a single domain's health check."""
|
||||
|
||||
name: str
|
||||
healthy: bool
|
||||
latency_ms: int = 0
|
||||
details: dict = field(default_factory=dict)
|
||||
error: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"name": self.name,
|
||||
"healthy": self.healthy,
|
||||
"latency_ms": self.latency_ms,
|
||||
"details": self.details,
|
||||
"error": self.error,
|
||||
}
|
||||
|
||||
|
||||
# Global registry of domain health checks
|
||||
_HEALTH_CHECKS: dict[str, HealthCheck] = {}
|
||||
|
||||
|
||||
def register_health_check(name: str, check: HealthCheck) -> None:
|
||||
"""Register a domain health check. Idempotent (overwrites)."""
|
||||
_HEALTH_CHECKS[name] = check
|
||||
|
||||
|
||||
def unregister_health_check(name: str) -> None:
|
||||
"""Remove a registered health check (mostly for tests)."""
|
||||
_HEALTH_CHECKS.pop(name, None)
|
||||
|
||||
|
||||
def list_registered_checks() -> list[str]:
|
||||
"""Names of all registered domain health checks."""
|
||||
return sorted(_HEALTH_CHECKS.keys())
|
||||
|
||||
|
||||
async def liveness() -> dict:
|
||||
"""Liveness probe — process is alive. Always 200 unless the process is dead."""
|
||||
return {"status": "alive"}
|
||||
|
||||
|
||||
async def readiness() -> tuple[bool, dict]:
|
||||
"""Readiness probe — critical deps reachable. Returns (healthy, details)."""
|
||||
details: dict = {}
|
||||
healthy = True
|
||||
|
||||
# Redis check
|
||||
try:
|
||||
from app.core.redis import get_redis
|
||||
r = get_redis()
|
||||
if r.ping():
|
||||
details["redis"] = {"healthy": True}
|
||||
else:
|
||||
details["redis"] = {"healthy": False, "error": "ping returned falsy"}
|
||||
healthy = False
|
||||
except Exception as e:
|
||||
details["redis"] = {"healthy": False, "error": str(e)}
|
||||
healthy = False
|
||||
|
||||
# Databus check (best-effort, only if client available)
|
||||
try:
|
||||
from app.databus.client import get_databus_client
|
||||
client = get_databus_client()
|
||||
if hasattr(client, "ping"):
|
||||
await asyncio.wait_for(client.ping(), timeout=2.0)
|
||||
details["databus"] = {"healthy": True}
|
||||
else:
|
||||
details["databus"] = {"healthy": True, "note": "no ping() method — assumed up"}
|
||||
except Exception as e:
|
||||
details["databus"] = {"healthy": False, "error": str(e)}
|
||||
# Databus is best-effort — don't fail readiness just because it's slow
|
||||
details["databus"]["best_effort"] = True
|
||||
|
||||
return healthy, details
|
||||
|
||||
|
||||
async def deep_health() -> tuple[bool, dict]:
|
||||
"""Deep health — runs every registered domain health check in parallel."""
|
||||
if not _HEALTH_CHECKS:
|
||||
return True, {"domains": {}, "registered": 0}
|
||||
|
||||
start = time.monotonic()
|
||||
tasks = []
|
||||
for name, check in _HEALTH_CHECKS.items():
|
||||
tasks.append((name, _safe_check(name, check)))
|
||||
results = await asyncio.gather(*(t[1] for t in tasks), return_exceptions=True)
|
||||
|
||||
domains: dict[str, dict] = {}
|
||||
healthy = True
|
||||
for (name, _), result in zip(tasks, results):
|
||||
if isinstance(result, Exception):
|
||||
domains[name] = DomainHealth(
|
||||
name=name, healthy=False, error=str(result),
|
||||
).to_dict()
|
||||
healthy = False
|
||||
elif isinstance(result, DomainHealth):
|
||||
domains[name] = result.to_dict()
|
||||
if not result.healthy:
|
||||
healthy = False
|
||||
else:
|
||||
domains[name] = {"name": name, "healthy": False, "error": f"unexpected: {result!r}"}
|
||||
healthy = False
|
||||
|
||||
total_ms = int((time.monotonic() - start) * 1000)
|
||||
return healthy, {
|
||||
"domains": domains,
|
||||
"registered": len(_HEALTH_CHECKS),
|
||||
"total_latency_ms": total_ms,
|
||||
}
|
||||
|
||||
|
||||
async def _safe_check(name: str, check: HealthCheck) -> DomainHealth:
|
||||
"""Run a single check with a 5s timeout. Returns DomainHealth, never raises."""
|
||||
start = time.monotonic()
|
||||
try:
|
||||
result = await asyncio.wait_for(check(), timeout=5.0)
|
||||
if isinstance(result, DomainHealth):
|
||||
return result
|
||||
# Some checks return a bool — wrap it
|
||||
return DomainHealth(
|
||||
name=name,
|
||||
healthy=bool(result),
|
||||
latency_ms=int((time.monotonic() - start) * 1000),
|
||||
)
|
||||
except Exception as e:
|
||||
return DomainHealth(
|
||||
name=name,
|
||||
healthy=False,
|
||||
latency_ms=int((time.monotonic() - start) * 1000),
|
||||
error=str(e),
|
||||
)
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
"""Health route — exposes the health check hierarchy at HTTP.
|
||||
|
||||
This is the 2026 framework push #6: kubernetes-grade liveness, readiness,
|
||||
and deep health, with per-domain registration.
|
||||
|
||||
Mounted by main.py after the legacy /live, /ready, /health routes.
|
||||
The legacy ones still serve during strangelfig.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.core import health as health_mod
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
@router.get("/live")
|
||||
async def live() -> dict:
|
||||
"""Liveness probe. Process up. Always 200 unless the process is dead."""
|
||||
return await health_mod.liveness()
|
||||
|
||||
|
||||
@router.get("/ready")
|
||||
async def ready() -> JSONResponse:
|
||||
"""Readiness probe. 200 if critical deps reachable, 503 otherwise."""
|
||||
healthy, details = await health_mod.readiness()
|
||||
status_code = 200 if healthy else 503
|
||||
return JSONResponse(
|
||||
status_code=status_code,
|
||||
content={"status": "ready" if healthy else "degraded", "dependencies": details},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health() -> JSONResponse:
|
||||
"""Deep health — runs every registered domain health check.
|
||||
|
||||
Each domain registers its own check via core.health.register_health_check().
|
||||
Returns 200 if all domains healthy, 503 if any critical domain down.
|
||||
"""
|
||||
healthy, details = await health_mod.deep_health()
|
||||
status_code = 200 if healthy else 503
|
||||
return JSONResponse(
|
||||
status_code=status_code,
|
||||
content={"status": "healthy" if healthy else "degraded", **details},
|
||||
)
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
"""
|
||||
Shared HTTP client with connection pooling.
|
||||
Use this instead of creating ad-hoc httpx clients.
|
||||
|
||||
Usage:
|
||||
from app.core.http import http_client
|
||||
resp = await http_client.get("https://api.example.com/data")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
# Single pooled client — reuse across all requests
|
||||
http_client = httpx.AsyncClient(
|
||||
limits=httpx.Limits(
|
||||
max_connections=100,
|
||||
max_keepalive_connections=20,
|
||||
),
|
||||
timeout=httpx.Timeout(30.0),
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
|
||||
async def close_http_client() -> None:
|
||||
"""Call on app shutdown."""
|
||||
await http_client.aclose()
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
"""RMI Backend - Application lifespan (startup/shutdown events)."""
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Background task imports (lazy, at startup time)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan: startup checks, background tasks, graceful shutdown."""
|
||||
vault_pw = os.getenv("WALLET_VAULT_PASSWORD", "").strip()
|
||||
if not vault_pw:
|
||||
raise RuntimeError(
|
||||
"CRITICAL: WALLET_VAULT_PASSWORD environment variable is missing or empty. "
|
||||
"The backend will not start without it to prevent silent wallet key loss."
|
||||
)
|
||||
|
||||
app.state.http_client = httpx.AsyncClient(
|
||||
limits=httpx.Limits(max_connections=100, max_keepalive_connections=20), timeout=10.0
|
||||
)
|
||||
|
||||
await _verify_indexes(app)
|
||||
await _start_background_tasks(app)
|
||||
|
||||
yield # App runs here
|
||||
|
||||
await app.state.http_client.aclose()
|
||||
logger.info("shutdown_complete")
|
||||
|
||||
|
||||
async def _start_background_tasks(app: FastAPI) -> None:
|
||||
"""Start all background monitoring / cleanup tasks."""
|
||||
tasks = [
|
||||
("facilitator_health", "app.routers.facilitator_health", "health_check_loop", "60s"),
|
||||
("status_page", "app.routers.status_page", "status_monitor_loop", "30s"),
|
||||
("webhook_dispatcher", "app.routers.webhook_dispatcher", "webhook_dispatcher_loop", "5s"),
|
||||
("x402_trial_cleanup", "app.routers.x402_enforcement", "trial_cleanup_loop", "hourly"),
|
||||
("auto_sweep", "app.wallet_manager_v2", "auto_sweep_loop", ""),
|
||||
]
|
||||
|
||||
for name, module_path, func_name, interval in tasks:
|
||||
try:
|
||||
mod = __import__(module_path, fromlist=[func_name])
|
||||
fn = getattr(mod, func_name)
|
||||
if name == "auto_sweep":
|
||||
asyncio.create_task(fn())
|
||||
else:
|
||||
asyncio.create_task(fn())
|
||||
logger.info("background_task_started", task=name, interval=interval)
|
||||
except Exception as e:
|
||||
logger.warning("background_task_failed", task=name, error=str(e))
|
||||
|
||||
# Cache warmer (passes app instance)
|
||||
try:
|
||||
from app.databus.core import cache_warm_loop
|
||||
|
||||
asyncio.create_task(cache_warm_loop(app))
|
||||
logger.info("background_task_started", task="databus_cache_warmer", interval="")
|
||||
except Exception as e:
|
||||
logger.warning("background_task_failed", task="databus_cache_warmer", error=str(e))
|
||||
|
||||
|
||||
async def _verify_indexes(app: FastAPI) -> None:
|
||||
"""Verify and create database indexes on startup."""
|
||||
try:
|
||||
supabase_url = os.getenv("SUPABASE_URL", "")
|
||||
supabase_key = os.getenv("SUPABASE_SERVICE_KEY", "") or os.getenv("SUPABASE_KEY", "")
|
||||
if not supabase_url or not supabase_key:
|
||||
return
|
||||
|
||||
indexes = [
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_scan_results_created_at ON scan_results(created_at DESC);",
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_scan_results_token_address ON scan_results(token_address);",
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_whale_alerts_created_at ON whale_alerts(created_at DESC);",
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_security_alerts_created_at ON security_alerts(created_at DESC);",
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_x402_payments_tx_hash ON x402_payments(tx_hash);",
|
||||
]
|
||||
|
||||
for sql in indexes:
|
||||
try:
|
||||
res = await app.state.http_client.post(
|
||||
f"{supabase_url}/rest/v1/rpc/exec_sql",
|
||||
json={"query": sql},
|
||||
headers={
|
||||
"apikey": supabase_key,
|
||||
"Authorization": f"Bearer {supabase_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
if res.status_code in [200, 204]:
|
||||
logger.info("index_verified", table=sql.split("ON ")[1].split("(")[0].strip())
|
||||
else:
|
||||
logger.warning("index_skipped", status=res.status_code, sql=sql[:50])
|
||||
except Exception as e:
|
||||
logger.warning("index_verify_failed", error=str(e))
|
||||
except Exception as e:
|
||||
logger.warning("index_verification_skipped", error=str(e))
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
"""Semantic LLM Cache for DataBus — caches identical + similar prompts. Redis-backed."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
|
||||
REDIS_URL = os.getenv("REDIS_CACHE_URL", "redis://localhost:6379/1")
|
||||
CACHE_TTL = int(os.getenv("LLM_CACHE_TTL", "3600"))
|
||||
|
||||
|
||||
def _cache_key(prompt: str, model: str) -> str:
|
||||
return f"llm_cache:{hashlib.sha256(f'{model}:{prompt}'.encode()).hexdigest()[:16]}"
|
||||
|
||||
|
||||
def get_cached(prompt: str, model: str) -> dict | None:
|
||||
"""Check if prompt+model result is cached."""
|
||||
import redis
|
||||
|
||||
try:
|
||||
r = redis.from_url(REDIS_URL, decode_responses=True)
|
||||
data = r.get(_cache_key(prompt, model))
|
||||
if data:
|
||||
return json.loads(data)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def set_cached(prompt: str, model: str, result: dict, ttl: int = CACHE_TTL):
|
||||
"""Cache a prompt result."""
|
||||
import redis
|
||||
|
||||
try:
|
||||
r = redis.from_url(REDIS_URL, decode_responses=True)
|
||||
r.setex(_cache_key(prompt, model), ttl, json.dumps(result))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def get_cache_stats() -> dict:
|
||||
"""Get cache statistics."""
|
||||
import redis
|
||||
|
||||
try:
|
||||
r = redis.from_url(REDIS_URL, decode_responses=True)
|
||||
keys = r.keys("llm_cache:*")
|
||||
return {"cached_prompts": len(keys)}
|
||||
except Exception:
|
||||
return {"cached_prompts": 0, "error": "redis unavailable"}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
"""RMI Backend - Structured logging with structlog + request ID tracking."""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
import structlog
|
||||
|
||||
|
||||
def setup_logging(level: str = "INFO") -> None:
|
||||
"""Configure structlog with JSON output for production, console for dev."""
|
||||
is_prod = level == "INFO" # JSON in prod, colored console in debug
|
||||
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.contextvars.merge_contextvars,
|
||||
structlog.stdlib.filter_by_level,
|
||||
structlog.stdlib.add_logger_name,
|
||||
structlog.stdlib.add_log_level,
|
||||
structlog.stdlib.PositionalArgumentsFormatter(),
|
||||
structlog.processors.TimeStamper(fmt="iso"),
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
structlog.processors.format_exc_info,
|
||||
structlog.processors.UnicodeDecoder(),
|
||||
structlog.dev.ConsoleRenderer() if not is_prod else structlog.processors.JSONRenderer(),
|
||||
],
|
||||
context_class=dict,
|
||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||
wrapper_class=structlog.stdlib.BoundLogger,
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
|
||||
# Set root logger level
|
||||
logging.basicConfig(format="%(message)s", stream=sys.stdout, level=getattr(logging, level))
|
||||
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
|
||||
|
||||
def get_logger(name: str) -> structlog.stdlib.BoundLogger:
|
||||
"""Get a structlog logger for a module."""
|
||||
return structlog.get_logger(name)
|
||||
|
|
@ -1,178 +0,0 @@
|
|||
"""Prometheus /metrics endpoint (P0 #4 in v3 unfuck plan).
|
||||
|
||||
Auto-instruments every request via FastAPI middleware. Exposes:
|
||||
- rmi_requests_total{method,path,status} — Counter
|
||||
- rmi_request_duration_seconds{method,path} — Histogram
|
||||
- rmi_active_requests — Gauge (in-flight)
|
||||
- rmi_errors_total{type,route} — Counter for typed errors
|
||||
|
||||
Mount in main.py:
|
||||
from app.core.metrics import router as metrics_router
|
||||
_legacy_main.app.include_router(metrics_router)
|
||||
|
||||
from app.core.metrics import PrometheusMiddleware
|
||||
_legacy_main.app.add_middleware(PrometheusMiddleware)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
from fastapi import APIRouter, Request, Response
|
||||
from prometheus_client import (
|
||||
CONTENT_TYPE_LATEST,
|
||||
CollectorRegistry,
|
||||
Counter,
|
||||
Gauge,
|
||||
Histogram,
|
||||
generate_latest,
|
||||
)
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
# Use a dedicated registry so we don't conflict with the default global one.
|
||||
REGISTRY = CollectorRegistry(auto_describe=True)
|
||||
|
||||
# ── Metrics ─────────────────────────────────────────────────────────────
|
||||
REQUEST_COUNT = Counter(
|
||||
"rmi_requests_total",
|
||||
"Total HTTP requests handled by the backend.",
|
||||
("method", "path", "status"),
|
||||
registry=REGISTRY,
|
||||
)
|
||||
|
||||
REQUEST_LATENCY = Histogram(
|
||||
"rmi_request_duration_seconds",
|
||||
"Request latency in seconds.",
|
||||
("method", "path"),
|
||||
buckets=(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0),
|
||||
registry=REGISTRY,
|
||||
)
|
||||
|
||||
ACTIVE_REQUESTS = Gauge(
|
||||
"rmi_active_requests",
|
||||
"Number of in-flight requests.",
|
||||
registry=REGISTRY,
|
||||
)
|
||||
|
||||
ERROR_COUNT = Counter(
|
||||
"rmi_errors_total",
|
||||
"Typed errors raised by the backend.",
|
||||
("type", "route"),
|
||||
registry=REGISTRY,
|
||||
)
|
||||
|
||||
LLM_COST_USD = Counter(
|
||||
"rmi_llm_cost_usd_total",
|
||||
"Cumulative LLM cost in USD (from cost_tracking middleware).",
|
||||
("tenant", "model"),
|
||||
registry=REGISTRY,
|
||||
)
|
||||
|
||||
|
||||
# ── /metrics endpoint ───────────────────────────────────────────────────
|
||||
router = APIRouter(tags=["metrics"])
|
||||
|
||||
|
||||
@router.get("/metrics", include_in_schema=False)
|
||||
async def metrics() -> Response:
|
||||
"""Prometheus scrape endpoint."""
|
||||
return Response(content=generate_latest(REGISTRY), media_type=CONTENT_TYPE_LATEST)
|
||||
|
||||
|
||||
# ── Middleware ──────────────────────────────────────────────────────────
|
||||
class PrometheusMiddleware(BaseHTTPMiddleware):
|
||||
"""Records request count + latency for every request.
|
||||
|
||||
Path labels are normalized to the route template (e.g. /api/v2/wallet/{address})
|
||||
rather than the actual path — this prevents cardinality explosion from
|
||||
addresses / IDs being used as labels.
|
||||
"""
|
||||
|
||||
async def dispatch(
|
||||
self,
|
||||
request: Request,
|
||||
call_next: Callable[[Request], Awaitable[Response]],
|
||||
) -> Response:
|
||||
# Skip metrics endpoint itself to avoid recursive metrics.
|
||||
if request.url.path == "/metrics":
|
||||
return await call_next(request)
|
||||
|
||||
method = request.method
|
||||
path_template = self._resolve_route_template(request)
|
||||
ACTIVE_REQUESTS.inc()
|
||||
|
||||
start = time.monotonic()
|
||||
try:
|
||||
response = await call_next(request)
|
||||
except Exception as exc: # noqa: BLE001 — typed errors raised below
|
||||
elapsed = time.monotonic() - start
|
||||
REQUEST_COUNT.labels(method, path_template, "500").inc()
|
||||
REQUEST_LATENCY.labels(method, path_template).observe(elapsed)
|
||||
ERROR_COUNT.labels(type=type(exc).__name__, route=path_template).inc()
|
||||
ACTIVE_REQUESTS.dec()
|
||||
raise
|
||||
else:
|
||||
elapsed = time.monotonic() - start
|
||||
REQUEST_COUNT.labels(method, path_template, str(response.status_code)).inc()
|
||||
REQUEST_LATENCY.labels(method, path_template).observe(elapsed)
|
||||
return response
|
||||
finally:
|
||||
ACTIVE_REQUESTS.dec()
|
||||
|
||||
@staticmethod
|
||||
def _resolve_route_template(request: Request) -> str:
|
||||
"""Return the FastAPI route path template (e.g. /api/v2/wallet/{address})
|
||||
instead of the literal URL — keeps label cardinality bounded.
|
||||
"""
|
||||
route = request.scope.get("route")
|
||||
if route is not None and getattr(route, "path", None):
|
||||
return str(route.path)
|
||||
# Fallback: bucket the literal path to avoid unbounded cardinality.
|
||||
return _bucket_path(request.url.path)
|
||||
|
||||
|
||||
_BUCKET_PREFIXES = (
|
||||
"/api/v1/scanner/",
|
||||
"/api/v1/wallet/",
|
||||
"/api/v1/token/",
|
||||
"/api/v1/alerts/",
|
||||
"/api/v1/rag/",
|
||||
"/api/v1/x402/",
|
||||
"/api/v2/scanner/",
|
||||
"/api/v2/wallet/",
|
||||
"/api/v2/token/",
|
||||
"/api/v2/alerts/",
|
||||
"/api/v2/rag/",
|
||||
"/api/v2/x402/",
|
||||
)
|
||||
|
||||
|
||||
def _bucket_path(path: str) -> str:
|
||||
"""Bucket unknown paths to avoid label cardinality explosion."""
|
||||
for prefix in _BUCKET_PREFIXES:
|
||||
if path.startswith(prefix):
|
||||
return prefix + "{id}"
|
||||
return path if len(path) <= 64 else "/{short_path}"
|
||||
|
||||
|
||||
|
||||
# ── v1 router compatibility shim ───────────────────────────────────────
|
||||
def setup_metrics() -> None:
|
||||
"""No-op setup for v1 router compatibility.
|
||||
|
||||
The v1 routers call this at import time to ensure metrics are wired.
|
||||
The actual middleware is registered in main.py's lifespan via
|
||||
PrometheusMiddleware. This function exists for import compatibility
|
||||
only — it does nothing.
|
||||
"""
|
||||
return None
|
||||
|
||||
|
||||
def record_llm_cost(tenant: str, model: str, cost_usd: float) -> None:
|
||||
"""Increment the LLM cost counter for a tenant + model.
|
||||
|
||||
Called by the cost tracking middleware or by LLM wrappers.
|
||||
"""
|
||||
LLM_COST_USD.labels(tenant=tenant or "anonymous", model=model or "unknown").inc(
|
||||
cost_usd
|
||||
)
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
"""RMI Backend — Core Middleware."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Rate-limit config
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
CACHEABLE_TOOLS = {"token_price", "token_metadata", "wallet_tokens", "entity_intel"}
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Payload size limit
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
MAX_PAYLOAD_SIZE = 1_048_576 # 1MB for standard JSON APIs
|
||||
|
||||
|
||||
async def cache_middleware(request: Request, call_next):
|
||||
"""Check Redis cache before executing tool. Store result after."""
|
||||
path = request.url.path
|
||||
if not path.startswith("/api/v1/x402-tools/"):
|
||||
return await call_next(request)
|
||||
if request.method != "POST":
|
||||
return await call_next(request)
|
||||
|
||||
tool = path.rstrip("/").split("/")[-1]
|
||||
if tool not in CACHEABLE_TOOLS:
|
||||
return await call_next(request)
|
||||
|
||||
try:
|
||||
body = await request.body()
|
||||
params = json.loads(body) if body else {}
|
||||
except Exception:
|
||||
return await call_next(request)
|
||||
|
||||
try:
|
||||
from app.routers.x402_advanced_tools import get_cached, set_cached
|
||||
|
||||
cached = get_cached(tool, params)
|
||||
if cached:
|
||||
return JSONResponse(content=cached, headers={"X-Cache": "HIT", "X-Cache-TTL": "60"})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
response = await call_next(request)
|
||||
if response.status_code == 200:
|
||||
try:
|
||||
resp_body = b""
|
||||
async for chunk in response.body_iterator:
|
||||
resp_body += chunk
|
||||
result = json.loads(resp_body)
|
||||
set_cached(tool, params, result)
|
||||
return JSONResponse(
|
||||
content=result,
|
||||
status_code=response.status_code,
|
||||
headers={**dict(response.headers), "X-Cache": "MISS"},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return response
|
||||
|
||||
|
||||
async def emergency_lockdown_middleware(request: Request, call_next):
|
||||
"""Check for emergency lockdown status. Block non-admin routes if active."""
|
||||
if request.url.path in ["/health", "/api/v1/admin/emergency-lockdown", "/api/v1/admin/emergency-status"]:
|
||||
return await call_next(request)
|
||||
|
||||
try:
|
||||
import redis.asyncio as redis_lib
|
||||
|
||||
r = redis_lib.Redis(
|
||||
host=os.getenv("REDIS_HOST", "localhost"),
|
||||
port=int(os.getenv("REDIS_PORT", "6379")),
|
||||
password=os.getenv("REDIS_PASSWORD", ""),
|
||||
decode_responses=True,
|
||||
)
|
||||
is_locked = await r.exists("rmi:emergency_lockdown")
|
||||
if is_locked:
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
session_token = request.headers.get("X-Admin-Session", "")
|
||||
if not auth_header and not session_token:
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={"error": "Service Unavailable", "detail": "System is in emergency lockdown."},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
async def hsts_middleware(request: Request, call_next):
|
||||
"""Force HTTPS and prevent protocol downgrade attacks."""
|
||||
response = await call_next(request)
|
||||
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains; preload"
|
||||
return response
|
||||
|
||||
|
||||
async def request_id_middleware(request: Request, call_next):
|
||||
"""Generate unique request ID for log correlation."""
|
||||
request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4())
|
||||
response = await call_next(request)
|
||||
response.headers["X-Request-ID"] = request_id
|
||||
return response
|
||||
|
||||
|
||||
async def payload_size_limit_middleware(request: Request, call_next):
|
||||
"""Enforce 1MB payload limit on non-upload routes."""
|
||||
content_length = request.headers.get("content-length")
|
||||
if content_length and int(content_length) > MAX_PAYLOAD_SIZE:
|
||||
if not request.url.path.startswith("/api/v1/admin/backend/upload/"):
|
||||
return JSONResponse(
|
||||
status_code=413,
|
||||
content={"error": "Payload Too Large", "detail": "Maximum payload size is 1MB"},
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
async def secure_cookie_middleware(request: Request, call_next):
|
||||
"""Enforce secure cookie flags for admin sessions."""
|
||||
response = await call_next(request)
|
||||
if "set-cookie" in response.headers:
|
||||
cookie_val = response.headers["set-cookie"]
|
||||
if "HttpOnly" not in cookie_val:
|
||||
cookie_val += "; HttpOnly"
|
||||
if "Secure" not in cookie_val:
|
||||
cookie_val += "; Secure"
|
||||
if "SameSite=Strict" not in cookie_val and "SameSite=Lax" not in cookie_val:
|
||||
cookie_val += "; SameSite=Strict"
|
||||
response.headers["set-cookie"] = cookie_val
|
||||
return response
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
"""Mistral AI provider for DataBus — Free tier: 1B tokens/month, 1 req/sec.
|
||||
Credit-conserving: uses Small 4 for bulk, Medium 3.5 only when needed."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MISTRAL_KEY = os.getenv("MISTRAL_API_KEY", "")
|
||||
MISTRAL_BASE = "https://api.mistral.ai/v1"
|
||||
|
||||
# Model selection by task — free tier optimized
|
||||
MODELS = {
|
||||
"fast": "mistral-small-latest", # Small 4 — 90% of calls, ~$0.1/1M tokens
|
||||
"smart": "mistral-medium-latest", # Medium 3.5 — complex analysis only
|
||||
"embed": "mistral-embed", # Embeddings — state of art
|
||||
"code": "mistral-small-latest", # Small 4 handles code well
|
||||
"moderate": "mistral-moderation-latest", # Content moderation
|
||||
}
|
||||
|
||||
# ⚠️ Deprecated — do NOT use
|
||||
# mistral-small-2506 → deprecated, retiring July 2026
|
||||
# mistral-medium-2508 → deprecated, retiring Aug 2026
|
||||
|
||||
|
||||
async def mistral_chat(
|
||||
prompt: str, model: str = None, system: str = None, max_tokens: int = 512, temperature: float = 0.7
|
||||
) -> dict | None:
|
||||
"""Chat completion via Mistral. Conserves free credits by using fast models."""
|
||||
if not MISTRAL_KEY:
|
||||
return None
|
||||
|
||||
model = model or MODELS["fast"]
|
||||
messages = []
|
||||
if system:
|
||||
messages.append({"role": "system", "content": system})
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as c:
|
||||
r = await c.post(
|
||||
f"{MISTRAL_BASE}/chat/completions",
|
||||
json={
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {MISTRAL_KEY}"},
|
||||
)
|
||||
if r.status_code == 200:
|
||||
d = r.json()
|
||||
return {
|
||||
"response": d["choices"][0]["message"]["content"],
|
||||
"model": model,
|
||||
"tokens": d.get("usage", {}).get("total_tokens", 0),
|
||||
"provider": "mistral",
|
||||
}
|
||||
elif r.status_code == 429:
|
||||
logger.warning("Mistral rate limit hit — waiting...")
|
||||
except Exception as e:
|
||||
logger.warning(f"Mistral chat failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def mistral_embed(text: str) -> list | None:
|
||||
"""Generate embeddings via Mistral Embed — state of art."""
|
||||
if not MISTRAL_KEY:
|
||||
return None
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as c:
|
||||
r = await c.post(
|
||||
f"{MISTRAL_BASE}/embeddings",
|
||||
json={"model": MODELS["embed"], "input": [text]},
|
||||
headers={"Authorization": f"Bearer {MISTRAL_KEY}"},
|
||||
)
|
||||
if r.status_code == 200:
|
||||
return r.json()["data"][0]["embedding"]
|
||||
except Exception as e:
|
||||
logger.warning(f"Mistral embed failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def mistral_moderate(text: str) -> dict | None:
|
||||
"""Content moderation — jailbreak, toxicity, PII detection."""
|
||||
if not MISTRAL_KEY:
|
||||
return None
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as c:
|
||||
r = await c.post(
|
||||
f"{MISTRAL_BASE}/chat/completions",
|
||||
json={
|
||||
"model": MODELS["moderate"],
|
||||
"messages": [{"role": "user", "content": text}],
|
||||
"max_tokens": 10,
|
||||
"temperature": 0,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {MISTRAL_KEY}"},
|
||||
)
|
||||
if r.status_code == 200:
|
||||
return {"flagged": False, "provider": "mistral"}
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""#8 — Model Evaluation Harness. Benchmarks models on Real-CATS scam data.
|
||||
Runs lm-eval locally or via Ollama. Picks the best model per task."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
OLLAMA = os.getenv("OLLAMA_HOST", "http://localhost:11434")
|
||||
REAL_CATS_PATH = Path(os.getenv("REAL_CATS_PATH", str(Path.home() / "rmi/backend/data/real_cats.json")))
|
||||
|
||||
# Test prompts for scam classification
|
||||
BENCHMARK_TASKS = {
|
||||
"scam_detection": {
|
||||
"prompts": [
|
||||
{
|
||||
"input": "Token has mint authority enabled, liquidity is 0.5 SOL unlocked, deployer created 50 tokens before. Is this a scam?",
|
||||
"expected": "yes",
|
||||
},
|
||||
{
|
||||
"input": "Token has renounced mint, liquidity locked for 1 year, verified contract, audited by CertiK. Is this a scam?",
|
||||
"expected": "no",
|
||||
},
|
||||
{
|
||||
"input": "Token has honeypot detection enabled, 99% sell tax, unverified contract, anonymous team. Is this a scam?",
|
||||
"expected": "yes",
|
||||
},
|
||||
{
|
||||
"input": "Token listed on Binance, $50M market cap, 100K holders, 2 years old. Is this a scam?",
|
||||
"expected": "no",
|
||||
},
|
||||
],
|
||||
"metric": "accuracy",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def evaluate_model(model: str, task_name: str) -> dict[str, Any]:
|
||||
"""Evaluate a model on a benchmark task."""
|
||||
task = BENCHMARK_TASKS.get(task_name)
|
||||
if not task:
|
||||
return {"error": f"Unknown task: {task_name}"}
|
||||
|
||||
correct = 0
|
||||
total = 0
|
||||
total_time = 0.0
|
||||
results = []
|
||||
|
||||
async with httpx.AsyncClient(timeout=60) as c:
|
||||
for item in task["prompts"]:
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
r = await c.post(
|
||||
f"{OLLAMA}/api/generate",
|
||||
json={
|
||||
"model": model,
|
||||
"prompt": f"Answer only YES or NO. {item['input']}",
|
||||
"stream": False,
|
||||
"options": {"num_predict": 5, "temperature": 0.1},
|
||||
},
|
||||
)
|
||||
elapsed = time.perf_counter() - start
|
||||
total_time += elapsed
|
||||
|
||||
response = r.json().get("response", "").strip().upper()
|
||||
is_correct = item["expected"].upper() in response
|
||||
if is_correct:
|
||||
correct += 1
|
||||
total += 1
|
||||
results.append(
|
||||
{
|
||||
"input": item["input"][:80],
|
||||
"expected": item["expected"],
|
||||
"got": response[:20],
|
||||
"correct": is_correct,
|
||||
"time_ms": round(elapsed * 1000),
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
results.append({"input": item["input"][:80], "error": str(e)})
|
||||
total += 1
|
||||
|
||||
accuracy = (correct / total * 100) if total > 0 else 0
|
||||
return {
|
||||
"model": model,
|
||||
"task": task_name,
|
||||
"accuracy": round(accuracy, 1),
|
||||
"correct": correct,
|
||||
"total": total,
|
||||
"avg_time_ms": round((total_time / total) * 1000) if total > 0 else 0,
|
||||
"results": results,
|
||||
}
|
||||
|
||||
|
||||
async def compare_models(models: list[str], task: str = "scam_detection"):
|
||||
"""Compare multiple models on a benchmark task."""
|
||||
scores = []
|
||||
for model in models:
|
||||
result = await evaluate_model(model, task)
|
||||
scores.append(result)
|
||||
|
||||
scores.sort(key=lambda s: s["accuracy"], reverse=True)
|
||||
return {
|
||||
"task": task,
|
||||
"models_compared": len(scores),
|
||||
"leaderboard": [
|
||||
{"model": s["model"], "accuracy": s["accuracy"], "avg_time_ms": s["avg_time_ms"]} for s in scores
|
||||
],
|
||||
"best_model": scores[0]["model"] if scores else None,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
async def main():
|
||||
print("Model Evaluation Harness")
|
||||
print("=" * 40)
|
||||
models = ["qwen2.5-coder:7b", "mistral:7b"]
|
||||
results = await compare_models(models)
|
||||
print(json.dumps(results["leaderboard"], indent=2))
|
||||
print(f"\nBest model for scam detection: {results['best_model']}")
|
||||
|
||||
asyncio.run(main())
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
"""Intelligent Model Router — auto-routes to best provider by task type, cost, latency.
|
||||
Priority: real-time → Cerebras (9ms), cheap → Ollama ($0), complex → DeepSeek, bulk → Mistral."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class TaskType(Enum):
|
||||
FAST = "fast" # < 100ms needed — Cerebras, Groq
|
||||
CHEAP = "cheap" # cost-sensitive — Ollama, Mistral free tier
|
||||
COMPLEX = "complex" # reasoning needed — DeepSeek V4 Pro
|
||||
BULK = "bulk" # high volume — Mistral Small 4
|
||||
VISION = "vision" # image understanding — Gemini
|
||||
EMBED = "embed" # embeddings — Mistral Embed
|
||||
CODE = "code" # code generation — DeepSeek, qwen2.5-coder
|
||||
|
||||
|
||||
ROUTING_TABLE = {
|
||||
TaskType.FAST: [
|
||||
("gpt-oss-120b", "cerebras", 0.009, 0.0), # 9ms, free
|
||||
("llama-3.3-70b", "groq", 0.1, 0.0), # 100ms, free
|
||||
],
|
||||
TaskType.CHEAP: [
|
||||
("qwen2.5-coder:7b", "ollama", 2.0, 0.0), # 2s, free
|
||||
("mistral-small-latest", "mistral", 0.2, 0.0), # 200ms, free
|
||||
],
|
||||
TaskType.COMPLEX: [
|
||||
("deepseek-v4-pro", "deepseek", 0.5, 0.55), # 500ms, $0.55/1M input
|
||||
("mistral-medium-latest", "mistral", 0.3, 0.0), # 300ms, free
|
||||
],
|
||||
TaskType.BULK: [
|
||||
("mistral-small-latest", "mistral", 0.2, 0.0), # 200ms, free, 2M TPM
|
||||
("deepseek-v4-flash", "deepseek", 0.3, 0.14), # 300ms, cheap
|
||||
],
|
||||
TaskType.VISION: [
|
||||
("gemini-2.5-flash", "gemini", 0.3, 0.0), # 300ms, free tier
|
||||
],
|
||||
TaskType.EMBED: [
|
||||
("mistral-embed", "mistral", 0.1, 0.0), # 100ms, 20M TPM free
|
||||
("bge-m3", "ollama", 2.0, 0.0), # 2s, local
|
||||
],
|
||||
TaskType.CODE: [
|
||||
("deepseek-v4-flash", "deepseek", 0.3, 0.14), # 300ms, cheap
|
||||
("qwen2.5-coder:7b", "ollama", 2.0, 0.0), # 2s, free
|
||||
("mistral-small-latest", "mistral", 0.2, 0.0), # 200ms, free
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class RoutingDecision:
|
||||
model: str
|
||||
provider: str
|
||||
estimated_latency_ms: float
|
||||
cost_per_1m_input: float
|
||||
fallback_model: str | None = None
|
||||
fallback_provider: str | None = None
|
||||
|
||||
|
||||
def route_task(task_type: TaskType, prefer: str = "fast") -> RoutingDecision:
|
||||
"""Route a task to the best model. Falls back to next on failure."""
|
||||
candidates = ROUTING_TABLE.get(task_type, ROUTING_TABLE[TaskType.FAST])
|
||||
|
||||
if prefer == "cheap":
|
||||
candidates = sorted(candidates, key=lambda c: c[3]) # sort by cost
|
||||
elif prefer == "fast":
|
||||
candidates = sorted(candidates, key=lambda c: c[2]) # sort by latency
|
||||
|
||||
primary = candidates[0]
|
||||
fallback = candidates[1] if len(candidates) > 1 else None
|
||||
|
||||
return RoutingDecision(
|
||||
model=primary[0],
|
||||
provider=primary[1],
|
||||
estimated_latency_ms=primary[2] * 1000,
|
||||
cost_per_1m_input=primary[3],
|
||||
fallback_model=fallback[0] if fallback else None,
|
||||
fallback_provider=fallback[1] if fallback else None,
|
||||
)
|
||||
|
||||
|
||||
async def smart_route(prompt: str, task_type: str = "fast", prefer: str = "fast", **kwargs):
|
||||
"""Auto-route a prompt to the best model. Returns response or falls back."""
|
||||
decision = route_task(TaskType(task_type), prefer)
|
||||
|
||||
# Try primary
|
||||
result = await _call_provider(decision.provider, decision.model, prompt, **kwargs)
|
||||
if result:
|
||||
return {**result, "routing": vars(decision), "fallback_used": False}
|
||||
|
||||
# Try fallback
|
||||
if decision.fallback_model:
|
||||
result = await _call_provider(decision.fallback_provider, decision.fallback_model, prompt, **kwargs)
|
||||
if result:
|
||||
return {**result, "routing": vars(decision), "fallback_used": True}
|
||||
|
||||
return {"error": "All providers failed", "routing": vars(decision)}
|
||||
|
||||
|
||||
async def _call_provider(provider: str, model: str, prompt: str, **kwargs):
|
||||
"""Call a specific provider. Returns dict or None."""
|
||||
try:
|
||||
if provider == "cerebras":
|
||||
from app.core.cerebras_provider import cerebras_chat
|
||||
|
||||
return await cerebras_chat(prompt, **kwargs)
|
||||
elif provider == "mistral":
|
||||
from app.core.mistral_provider import mistral_chat
|
||||
|
||||
return await mistral_chat(prompt, model=model, **kwargs)
|
||||
elif provider == "ollama":
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient(timeout=60) as c:
|
||||
r = await c.post(
|
||||
"http://localhost:11434/api/generate", json={"model": model, "prompt": prompt, "stream": False}
|
||||
)
|
||||
if r.status_code == 200:
|
||||
return {"response": r.json()["response"], "model": model, "provider": "ollama"}
|
||||
# DeepSeek, Groq, Gemini handled via existing DataBus providers
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
"""T07 GlitchTip — Sentry SDK integration.
|
||||
|
||||
Per v4.0 §T07. Self-hosted Sentry-compatible error tracking at
|
||||
glitchtip.rugmunch.io (Sentry SDK pointed at our own instance).
|
||||
|
||||
Key principle: NEVER leak secrets. The before_send hook strips
|
||||
authorization headers, X-API-Key, passwords, tokens, etc.
|
||||
|
||||
Per v3 unfuck rule #7: SDK init must be at module level, not in lifespan.
|
||||
But the SDK itself uses lazy init — setup_sentry() is called once at startup.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Config — defaults to local GlitchTip; override via env
|
||||
DEFAULT_DSN = "http://rmi-glitchtip-web:8000/1"
|
||||
DEFAULT_ENV = "production"
|
||||
DEFAULT_SAMPLE_RATE = 0.1 # 10% of transactions traced
|
||||
|
||||
# Sensitive field names that must never be sent to error tracking
|
||||
SENSITIVE_KEYS = {
|
||||
"authorization", "x-api-key", "x-payment", "x-agent-id",
|
||||
"password", "secret", "token", "cookie", "set-cookie",
|
||||
"api_key", "apikey", "access_token", "refresh_token",
|
||||
"private_key", "credit_card", "ssn",
|
||||
}
|
||||
|
||||
|
||||
def _scrub_secrets(data: Any) -> Any:
|
||||
"""Recursively replace sensitive values with '[REDACTED]'.
|
||||
|
||||
Walks dicts and lists, checking each key against SENSITIVE_KEYS.
|
||||
"""
|
||||
if isinstance(data, dict):
|
||||
return {
|
||||
k: "[REDACTED]" if k.lower() in SENSITIVE_KEYS else _scrub_secrets(v)
|
||||
for k, v in data.items()
|
||||
}
|
||||
if isinstance(data, list):
|
||||
return [_scrub_secrets(x) for x in data]
|
||||
return data
|
||||
|
||||
|
||||
def setup_sentry() -> bool:
|
||||
"""Initialize the Sentry SDK pointed at our self-hosted GlitchTip.
|
||||
|
||||
Returns True if initialized, False if DSN not configured or
|
||||
sentry_sdk is not installed (graceful — backend still works).
|
||||
"""
|
||||
dsn = os.getenv("GLITCHTIP_DSN") or os.getenv("SENTRY_DSN")
|
||||
if not dsn:
|
||||
log.info("sentry_disabled no_dsn")
|
||||
return False
|
||||
|
||||
try:
|
||||
import sentry_sdk
|
||||
from sentry_sdk.integrations.fastapi import FastApiIntegration
|
||||
from sentry_sdk.integrations.httpx import HttpxIntegration
|
||||
from sentry_sdk.integrations.redis import RedisIntegration
|
||||
except ImportError as exc:
|
||||
log.info(f"sentry_sdk_not_installed err={exc}")
|
||||
return False
|
||||
|
||||
env = os.getenv("ENVIRONMENT", DEFAULT_ENV)
|
||||
traces_sample_rate = float(os.getenv("SENTRY_TRACES_SAMPLE_RATE", DEFAULT_SAMPLE_RATE))
|
||||
|
||||
sentry_sdk.init(
|
||||
dsn=dsn,
|
||||
environment=env,
|
||||
traces_sample_rate=traces_sample_rate,
|
||||
send_default_pii=False, # PII stays in our infra
|
||||
integrations=[
|
||||
FastApiIntegration(transaction_style="endpoint"),
|
||||
HttpxIntegration(),
|
||||
RedisIntegration(),
|
||||
],
|
||||
before_send=_before_send,
|
||||
before_send_transaction=_before_send_transaction,
|
||||
)
|
||||
log.info(f"sentry_initialized dsn={dsn[:30]}... env={env} sample_rate={traces_sample_rate}")
|
||||
return True
|
||||
|
||||
|
||||
def _before_send(event: dict, hint: dict) -> dict | None:
|
||||
"""Strip secrets before sending to GlitchTip.
|
||||
|
||||
Per v4.0 §T07: secrets scrubbed before send. This is a hard
|
||||
requirement — never log full request bodies with credentials.
|
||||
"""
|
||||
try:
|
||||
if "request" in event:
|
||||
if "headers" in event["request"]:
|
||||
event["request"]["headers"] = _scrub_secrets(event["request"]["headers"])
|
||||
if "data" in event["request"]:
|
||||
event["request"]["data"] = _scrub_secrets(event["request"]["data"])
|
||||
if "extra" in event:
|
||||
event["extra"] = _scrub_secrets(event["extra"])
|
||||
if "user" in event:
|
||||
# Strip email/IP — keep only id
|
||||
event["user"] = {"id": event["user"].get("id", "anon")}
|
||||
return event
|
||||
except Exception as e:
|
||||
log.warning(f"sentry_scrub_fail err={e}")
|
||||
return event # Better to log it than to drop it
|
||||
|
||||
|
||||
def _before_send_transaction(event: dict, hint: dict) -> dict | None:
|
||||
"""Drop transactions that are health checks / metrics scrapes (high volume, low value)."""
|
||||
url = event.get("transaction") or event.get("request", {}).get("url", "")
|
||||
if any(s in url for s in ("/health", "/ready", "/live", "/metrics", "/openapi.json")):
|
||||
return None
|
||||
return event
|
||||
|
||||
|
||||
def capture_exception(error: BaseException, **extra: Any) -> None:
|
||||
"""Manually capture an exception. For use in catch blocks where
|
||||
we want to log to Sentry but continue execution."""
|
||||
try:
|
||||
import sentry_sdk
|
||||
with sentry_sdk.push_scope() as scope:
|
||||
for k, v in extra.items():
|
||||
scope.set_extra(k, v)
|
||||
sentry_sdk.capture_exception(error)
|
||||
except Exception as e:
|
||||
log.warning(f"sentry_capture_fail err={e}")
|
||||
|
||||
|
||||
def capture_message(message: str, level: str = "info", **extra: Any) -> None:
|
||||
"""Manually capture a message. For non-exception events."""
|
||||
try:
|
||||
import sentry_sdk
|
||||
with sentry_sdk.push_scope() as scope:
|
||||
for k, v in extra.items():
|
||||
scope.set_extra(k, v)
|
||||
sentry_sdk.capture_message(message, level=level)
|
||||
except Exception as e:
|
||||
log.warning(f"sentry_message_fail err={e}")
|
||||
|
||||
|
||||
def flush_sentry(timeout: float = 2.0) -> None:
|
||||
"""Flush pending events. Call before shutdown."""
|
||||
try:
|
||||
import sentry_sdk
|
||||
sentry_sdk.flush(timeout=timeout)
|
||||
except Exception:
|
||||
pass
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue