merge: chore/cleanup-remove-bloat-and-secrets into main
This commit is contained in:
commit
bde2f3a97d
1173 changed files with 437609 additions and 0 deletions
565
app/routers/persistent_state.py
Normal file
565
app/routers/persistent_state.py
Normal file
|
|
@ -0,0 +1,565 @@
|
|||
"""
|
||||
RMI Persistent State Layer
|
||||
===========================
|
||||
User watchlists, portfolios, saved scans, and alert history.
|
||||
Redis-backed for speed, with JSON serialization.
|
||||
|
||||
Endpoints:
|
||||
POST /api/v1/state/watchlist — Add address to watchlist
|
||||
DELETE /api/v1/state/watchlist/{id} — Remove from watchlist
|
||||
GET /api/v1/state/watchlist — List watched addresses
|
||||
POST /api/v1/state/portfolio — Add portfolio entry
|
||||
DELETE /api/v1/state/portfolio/{id} — Remove portfolio entry
|
||||
GET /api/v1/state/portfolio — Get portfolio summary
|
||||
GET /api/v1/state/portfolio/{address} — Get single holding details
|
||||
POST /api/v1/state/scan — Save a scan configuration
|
||||
GET /api/v1/state/scan/{id} — Get saved scan
|
||||
GET /api/v1/state/scan — List saved scans
|
||||
DELETE /api/v1/state/scan/{id} — Delete saved scan
|
||||
GET /api/v1/state/alerts — Get alert history
|
||||
GET /api/v1/state/alerts/unread — Get unread alerts
|
||||
POST /api/v1/state/alerts/{id}/read — Mark alert as read
|
||||
GET /api/v1/state/stats — User state statistics
|
||||
|
||||
Identity: Uses X-RMI-Dev-Key header for API key users, or
|
||||
X-Wallet-Identity header for verified wallet users.
|
||||
Falls back to fingerprint-based identity.
|
||||
|
||||
Author: RMI Development
|
||||
Date: 2026-06-05
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
logger = logging.getLogger("persistent_state")
|
||||
|
||||
router = APIRouter(prefix="/api/v1/state", tags=["persistent-state"])
|
||||
|
||||
|
||||
# ── Identity Helper ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_user_identity(request: Request) -> str:
|
||||
"""Get a stable user identity from the request."""
|
||||
# Priority 1: Developer API key
|
||||
dev_key = request.headers.get("X-RMI-Dev-Key", "") or request.headers.get("x-rmi-dev-key", "")
|
||||
if dev_key:
|
||||
import hashlib
|
||||
|
||||
return f"dev:{hashlib.sha256(dev_key.encode()).hexdigest()[:16]}"
|
||||
|
||||
# Priority 2: Wallet identity
|
||||
wallet_id = request.headers.get("X-Wallet-Identity", "") or request.headers.get("x-wallet-identity", "")
|
||||
if wallet_id:
|
||||
return f"wallet:{wallet_id}"
|
||||
|
||||
# Priority 3: Fingerprint
|
||||
fp = request.headers.get("x-fingerprint", "") or request.headers.get("X-Fingerprint", "")
|
||||
if fp:
|
||||
return f"fp:{fp[:16]}"
|
||||
|
||||
# Priority 4: IP address
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
return f"ip:{client_ip}"
|
||||
|
||||
|
||||
# ── Redis Helper ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def add_to_watchlist(entry: WatchlistEntry, request: Request):
|
||||
"""Add an address to the user's watchlist."""
|
||||
r = get_redis()
|
||||
if not r:
|
||||
return JSONResponse(status_code=500, content={"error": "redis_unavailable"})
|
||||
|
||||
user_id = get_user_identity(request)
|
||||
watch_id = f"watch:{uuid.uuid4().hex[:12]}"
|
||||
|
||||
watch_data = {
|
||||
"id": watch_id,
|
||||
"address": entry.address.lower(),
|
||||
"chain": entry.chain,
|
||||
"label": entry.label or entry.address[:8],
|
||||
"alert_types": entry.alert_types,
|
||||
"notes": entry.notes,
|
||||
"added_at": datetime.now(UTC).isoformat(),
|
||||
"status": "active",
|
||||
}
|
||||
|
||||
# Store in sorted set (by added time)
|
||||
r.zadd(
|
||||
f"rmi:state:{user_id}:watchlist",
|
||||
{json.dumps(watch_data): time.time()},
|
||||
)
|
||||
|
||||
# Also index by address for quick lookup
|
||||
r.sadd(
|
||||
f"rmi:state:{user_id}:watchlist:addresses",
|
||||
f"{entry.chain}:{entry.address.lower()}",
|
||||
)
|
||||
|
||||
r.incr("rmi:state:stats:watchlist_total")
|
||||
|
||||
return JSONResponse(
|
||||
status_code=201,
|
||||
content={
|
||||
"success": True,
|
||||
"watch_id": watch_id,
|
||||
"address": entry.address,
|
||||
"chain": entry.chain,
|
||||
"message": f"Added {entry.address} to watchlist",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/watchlist/{watch_id}")
|
||||
async def remove_from_watchlist(watch_id: str, request: Request):
|
||||
"""Remove an address from the watchlist."""
|
||||
r = get_redis()
|
||||
if not r:
|
||||
return JSONResponse(status_code=500, content={"error": "redis_unavailable"})
|
||||
|
||||
user_id = get_user_identity(request)
|
||||
|
||||
# Find and remove the entry
|
||||
entries = r.zrange(f"rmi:state:{user_id}:watchlist", 0, -1)
|
||||
for entry_json in entries:
|
||||
entry = json.loads(entry_json)
|
||||
if entry.get("id") == watch_id:
|
||||
r.zrem(f"rmi:state:{user_id}:watchlist", entry_json)
|
||||
addr_key = f"{entry.get('chain', '')}:{entry.get('address', '')}"
|
||||
r.srem(f"rmi:state:{user_id}:watchlist:addresses", addr_key)
|
||||
return JSONResponse(content={"success": True, "message": f"Removed {entry.get('address')}"})
|
||||
|
||||
return JSONResponse(status_code=404, content={"error": "Watchlist entry not found"})
|
||||
|
||||
|
||||
@router.get("/watchlist")
|
||||
async def get_watchlist(request: Request):
|
||||
"""Get the user's watchlist."""
|
||||
r = get_redis()
|
||||
if not r:
|
||||
return JSONResponse(content={"watchlist": [], "count": 0})
|
||||
|
||||
user_id = get_user_identity(request)
|
||||
entries = r.zrange(f"rmi:state:{user_id}:watchlist", 0, -1)
|
||||
|
||||
watchlist = []
|
||||
for entry_json in entries:
|
||||
entry = json.loads(entry_json)
|
||||
watchlist.append(entry)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"watchlist": watchlist,
|
||||
"count": len(watchlist),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ── Portfolio ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/portfolio")
|
||||
async def add_portfolio_entry(entry: PortfolioEntry, request: Request):
|
||||
"""Add a holding to the user's portfolio."""
|
||||
r = get_redis()
|
||||
if not r:
|
||||
return JSONResponse(status_code=500, content={"error": "redis_unavailable"})
|
||||
|
||||
user_id = get_user_identity(request)
|
||||
holding_id = f"holding:{uuid.uuid4().hex[:12]}"
|
||||
|
||||
holding_data = {
|
||||
"id": holding_id,
|
||||
"address": entry.address.lower(),
|
||||
"chain": entry.chain,
|
||||
"amount": entry.amount,
|
||||
"cost_basis": entry.cost_basis,
|
||||
"label": entry.label or entry.address[:8],
|
||||
"notes": entry.notes,
|
||||
"added_at": datetime.now(UTC).isoformat(),
|
||||
"status": "active",
|
||||
}
|
||||
|
||||
r.zadd(
|
||||
f"rmi:state:{user_id}:portfolio",
|
||||
{json.dumps(holding_data): time.time()},
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=201,
|
||||
content={
|
||||
"success": True,
|
||||
"holding_id": holding_id,
|
||||
"address": entry.address,
|
||||
"message": f"Added {entry.address} to portfolio",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/portfolio/{holding_id}")
|
||||
async def remove_portfolio_entry(holding_id: str, request: Request):
|
||||
"""Remove a holding from the portfolio."""
|
||||
r = get_redis()
|
||||
if not r:
|
||||
return JSONResponse(status_code=500, content={"error": "redis_unavailable"})
|
||||
|
||||
user_id = get_user_identity(request)
|
||||
|
||||
entries = r.zrange(f"rmi:state:{user_id}:portfolio", 0, -1)
|
||||
for entry_json in entries:
|
||||
entry = json.loads(entry_json)
|
||||
if entry.get("id") == holding_id:
|
||||
r.zrem(f"rmi:state:{user_id}:portfolio", entry_json)
|
||||
return JSONResponse(content={"success": True, "message": f"Removed {entry.get('address')}"})
|
||||
|
||||
return JSONResponse(status_code=404, content={"error": "Portfolio entry not found"})
|
||||
|
||||
|
||||
@router.get("/portfolio")
|
||||
async def get_portfolio(request: Request):
|
||||
"""Get the user's portfolio summary."""
|
||||
r = get_redis()
|
||||
if not r:
|
||||
return JSONResponse(content={"portfolio": [], "total_value": 0})
|
||||
|
||||
user_id = get_user_identity(request)
|
||||
entries = r.zrange(f"rmi:state:{user_id}:portfolio", 0, -1)
|
||||
|
||||
portfolio = []
|
||||
total_cost = 0
|
||||
for entry_json in entries:
|
||||
entry = json.loads(entry_json)
|
||||
portfolio.append(entry)
|
||||
total_cost += entry.get("cost_basis", 0) * entry.get("amount", 0)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"portfolio": portfolio,
|
||||
"count": len(portfolio),
|
||||
"total_cost_basis": round(total_cost, 2),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/portfolio/{address}")
|
||||
async def get_portfolio_holding(address: str, request: Request):
|
||||
"""Get details for a specific portfolio holding."""
|
||||
r = get_redis()
|
||||
if not r:
|
||||
return JSONResponse(status_code=500, content={"error": "redis_unavailable"})
|
||||
|
||||
user_id = get_user_identity(request)
|
||||
entries = r.zrange(f"rmi:state:{user_id}:portfolio", 0, -1)
|
||||
|
||||
for entry_json in entries:
|
||||
entry = json.loads(entry_json)
|
||||
if entry.get("address", "").lower() == address.lower():
|
||||
return JSONResponse(content={"holding": entry})
|
||||
|
||||
return JSONResponse(status_code=404, content={"error": "Holding not found"})
|
||||
|
||||
|
||||
# ── Saved Scans ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/scan")
|
||||
async def save_scan(scan: SavedScan, request: Request):
|
||||
"""Save a scan configuration for later use."""
|
||||
r = get_redis()
|
||||
if not r:
|
||||
return JSONResponse(status_code=500, content={"error": "redis_unavailable"})
|
||||
|
||||
user_id = get_user_identity(request)
|
||||
scan_id = f"scan:{uuid.uuid4().hex[:12]}"
|
||||
|
||||
scan_data = {
|
||||
"id": scan_id,
|
||||
"name": scan.name,
|
||||
"tool": scan.tool,
|
||||
"params": scan.params,
|
||||
"chain": scan.chain,
|
||||
"schedule": scan.schedule,
|
||||
"webhook_url": scan.webhook_url,
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
"last_run": None,
|
||||
"run_count": 0,
|
||||
"status": "active",
|
||||
}
|
||||
|
||||
r.set(
|
||||
f"rmi:state:{user_id}:scan:{scan_id}",
|
||||
json.dumps(scan_data),
|
||||
)
|
||||
|
||||
# Add to scan index
|
||||
r.zadd(
|
||||
f"rmi:state:{user_id}:scans",
|
||||
{scan_id: time.time()},
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=201,
|
||||
content={
|
||||
"success": True,
|
||||
"scan_id": scan_id,
|
||||
"name": scan.name,
|
||||
"message": f"Saved scan '{scan.name}'",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/scan/{scan_id}")
|
||||
async def get_saved_scan(scan_id: str, request: Request):
|
||||
"""Get a saved scan configuration."""
|
||||
r = get_redis()
|
||||
if not r:
|
||||
return JSONResponse(status_code=500, content={"error": "redis_unavailable"})
|
||||
|
||||
user_id = get_user_identity(request)
|
||||
scan_data = r.get(f"rmi:state:{user_id}:scan:{scan_id}")
|
||||
|
||||
if not scan_data:
|
||||
return JSONResponse(status_code=404, content={"error": "Scan not found"})
|
||||
|
||||
return JSONResponse(content={"scan": json.loads(scan_data)})
|
||||
|
||||
|
||||
@router.get("/scan")
|
||||
async def list_saved_scans(request: Request):
|
||||
"""List all saved scans for the user."""
|
||||
r = get_redis()
|
||||
if not r:
|
||||
return JSONResponse(content={"scans": [], "count": 0})
|
||||
|
||||
user_id = get_user_identity(request)
|
||||
scan_ids = r.zrange(f"rmi:state:{user_id}:scans", 0, -1)
|
||||
|
||||
scans = []
|
||||
for scan_id in scan_ids:
|
||||
scan_data = r.get(f"rmi:state:{user_id}:scan:{scan_id}")
|
||||
if scan_data:
|
||||
scans.append(json.loads(scan_data))
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"scans": scans,
|
||||
"count": len(scans),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/scan/{scan_id}")
|
||||
async def delete_saved_scan(scan_id: str, request: Request):
|
||||
"""Delete a saved scan."""
|
||||
r = get_redis()
|
||||
if not r:
|
||||
return JSONResponse(status_code=500, content={"error": "redis_unavailable"})
|
||||
|
||||
user_id = get_user_identity(request)
|
||||
|
||||
# Check ownership
|
||||
scan_data = r.get(f"rmi:state:{user_id}:scan:{scan_id}")
|
||||
if not scan_data:
|
||||
return JSONResponse(status_code=404, content={"error": "Scan not found"})
|
||||
|
||||
r.delete(f"rmi:state:{user_id}:scan:{scan_id}")
|
||||
r.zrem(f"rmi:state:{user_id}:scans", scan_id)
|
||||
|
||||
return JSONResponse(content={"success": True, "message": "Scan deleted"})
|
||||
|
||||
|
||||
# ── Alert History ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/alerts")
|
||||
async def get_alert_history(
|
||||
request: Request,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
status: str | None = None,
|
||||
):
|
||||
"""Get alert history for the user."""
|
||||
r = get_redis()
|
||||
if not r:
|
||||
return JSONResponse(content={"alerts": [], "count": 0})
|
||||
|
||||
user_id = get_user_identity(request)
|
||||
|
||||
# Alerts stored as a list (newest first)
|
||||
alerts = []
|
||||
start = offset
|
||||
end = offset + limit - 1
|
||||
|
||||
alert_keys = r.lrange(f"rmi:state:{user_id}:alerts", start, end)
|
||||
for alert_json in alert_keys:
|
||||
alert = json.loads(alert_json)
|
||||
if status is None or alert.get("status") == status:
|
||||
alerts.append(alert)
|
||||
|
||||
total = r.llen(f"rmi:state:{user_id}:alerts")
|
||||
unread = r.scard(f"rmi:state:{user_id}:alerts:unread")
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"alerts": alerts,
|
||||
"total": total,
|
||||
"unread": unread,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/alerts/unread")
|
||||
async def get_unread_alerts(request: Request):
|
||||
"""Get unread alerts."""
|
||||
r = get_redis()
|
||||
if not r:
|
||||
return JSONResponse(content={"alerts": [], "count": 0})
|
||||
|
||||
user_id = get_user_identity(request)
|
||||
unread_ids = r.smembers(f"rmi:state:{user_id}:alerts:unread")
|
||||
|
||||
alerts = []
|
||||
for alert_id in list(unread_ids)[:50]: # Limit to 50
|
||||
alert_json = r.get(f"rmi:state:{user_id}:alert:{alert_id}")
|
||||
if alert_json:
|
||||
alerts.append(json.loads(alert_json))
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"alerts": alerts,
|
||||
"count": len(alerts),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/alerts/{alert_id}/read")
|
||||
async def mark_alert_read(alert_id: str, request: Request):
|
||||
"""Mark an alert as read."""
|
||||
r = get_redis()
|
||||
if not r:
|
||||
return JSONResponse(status_code=500, content={"error": "redis_unavailable"})
|
||||
|
||||
user_id = get_user_identity(request)
|
||||
|
||||
# Remove from unread set
|
||||
r.srem(f"rmi:state:{user_id}:alerts:unread", alert_id)
|
||||
|
||||
# Update status in alert data
|
||||
alert_json = r.get(f"rmi:state:{user_id}:alert:{alert_id}")
|
||||
if alert_json:
|
||||
alert = json.loads(alert_json)
|
||||
alert["status"] = "read"
|
||||
alert["read_at"] = datetime.now(UTC).isoformat()
|
||||
r.set(f"rmi:state:{user_id}:alert:{alert_id}", json.dumps(alert))
|
||||
|
||||
return JSONResponse(content={"success": True})
|
||||
|
||||
|
||||
@router.post("/alerts/mark-all-read")
|
||||
async def mark_all_alerts_read(request: Request):
|
||||
"""Mark all alerts as read."""
|
||||
r = get_redis()
|
||||
if not r:
|
||||
return JSONResponse(status_code=500, content={"error": "redis_unavailable"})
|
||||
|
||||
user_id = get_user_identity(request)
|
||||
r.delete(f"rmi:state:{user_id}:alerts:unread")
|
||||
|
||||
return JSONResponse(content={"success": True, "message": "All alerts marked as read"})
|
||||
|
||||
|
||||
# Helper function to push alerts (called by webhook/cron systems)
|
||||
def push_alert(
|
||||
user_id: str,
|
||||
alert_type: str,
|
||||
address: str,
|
||||
message: str,
|
||||
severity: str = "medium",
|
||||
data: dict | None = None,
|
||||
source: str = "rmi_scanner",
|
||||
):
|
||||
"""Push an alert to a user's alert history.
|
||||
|
||||
Called by webhook handlers, cron jobs, and scanner systems.
|
||||
"""
|
||||
r = get_redis()
|
||||
if not r:
|
||||
return
|
||||
|
||||
alert_id = f"alert:{uuid.uuid4().hex[:12]}"
|
||||
|
||||
alert_data = {
|
||||
"id": alert_id,
|
||||
"type": alert_type,
|
||||
"address": address,
|
||||
"message": message,
|
||||
"severity": severity,
|
||||
"data": data or {},
|
||||
"source": source,
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
"status": "unread",
|
||||
}
|
||||
|
||||
# Add to alert history (newest first)
|
||||
r.lpush(
|
||||
f"rmi:state:{user_id}:alerts",
|
||||
json.dumps(alert_data),
|
||||
)
|
||||
|
||||
# Trim to last 1000 alerts
|
||||
r.ltrim(f"rmi:state:{user_id}:alerts", 0, 999)
|
||||
|
||||
# Add to unread set
|
||||
r.sadd(f"rmi:state:{user_id}:alerts:unread", alert_id)
|
||||
|
||||
# Store individual alert for retrieval
|
||||
r.set(
|
||||
f"rmi:state:{user_id}:alert:{alert_id}",
|
||||
json.dumps(alert_data),
|
||||
ex=86400 * 30, # 30 day TTL
|
||||
)
|
||||
|
||||
# Update stats
|
||||
r.incr("rmi:state:stats:alerts_total")
|
||||
r.incr(f"rmi:state:stats:alerts:{alert_type}")
|
||||
|
||||
|
||||
# ── User State Statistics ─────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_user_stats(request: Request):
|
||||
"""Get user state statistics."""
|
||||
r = get_redis()
|
||||
if not r:
|
||||
return JSONResponse(status_code=500, content={"error": "redis_unavailable"})
|
||||
|
||||
user_id = get_user_identity(request)
|
||||
|
||||
watchlist_count = r.zcard(f"rmi:state:{user_id}:watchlist")
|
||||
portfolio_count = r.zcard(f"rmi:state:{user_id}:portfolio")
|
||||
scan_count = r.zcard(f"rmi:state:{user_id}:scans")
|
||||
alerts_total = r.llen(f"rmi:state:{user_id}:alerts")
|
||||
alerts_unread = r.scard(f"rmi:state:{user_id}:alerts:unread")
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"user_id": user_id,
|
||||
"watchlist": watchlist_count,
|
||||
"portfolio": portfolio_count,
|
||||
"saved_scans": scan_count,
|
||||
"alerts_total": alerts_total,
|
||||
"alerts_unread": alerts_unread,
|
||||
}
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue