security(auth): wire AuthMiddleware, enforce RMI_AUTH_TOKEN, fix PUBLIC_WRITE_PREFIXES, fail-closed ADMIN_API_KEY defaults

feat(databus): add deployer_tokens + funding_source providers via public RPC
feat(databus): register 10 ghost chain passthroughs (104 chains, 127 providers)
fix(databus): structured error logging in ProviderChain, fix _coindesk_news import
fix(databus): _passthrough_alerts returns None on failure (not fake empty data)
fix(databus): enable Qdrant API key enforcement

refactor(bulletin): extract badges to badges.py, core.py -> 33-line facade
refactor(bulletin): rewrite bulletin.py — canonical Post schema, auth fixes, XSS sanitize
refactor(bulletin): consolidate DataBus to supabase_service, remove raw Supabase calls
refactor(bulletin): remove bulletin_board.py dead code (682 lines), fix mount.py

feat(admin): bulletin admin router (16 endpoints, RBAC auth) for darkroom
feat(admin): fix ADMIN_API_KEY defaults to empty in 6 routers
chore(admin): developer_routes, investigator_routes, premium_routes -> 501 stubs
feat(infra): bind SearXNG/FlareSolverr/Webmail/Vane to 127.0.0.1
chore(infra): postgres healthcheck, resource limits, memory tuning, DB healthchecks
fix(infra): Nginx HTTPS for rugmunch.io + api + admin darkroom

Prod audit: 15 critical/high issues resolved across auth, security, infrastructure, DataBus
This commit is contained in:
Crypto Rug Munch 2026-07-08 22:20:02 +02:00
parent c67f1d71ac
commit 30a6508893
28 changed files with 2732 additions and 1249 deletions

View file

@ -7,6 +7,15 @@ from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
RMI_AUTH_TOKEN = os.getenv("RMI_AUTH_TOKEN", "")
ENVIRONMENT = os.getenv("ENVIRONMENT", "dev")
if not RMI_AUTH_TOKEN and ENVIRONMENT not in ("dev", "test"):
import logging
logging.getLogger(__name__).critical(
"RMI_AUTH_TOKEN is not set! Authorization will PASS for all requests. "
"Set RMI_AUTH_TOKEN in environment."
)
PUBLIC_WRITE_PREFIXES = [
"/api/v1/auth/",
@ -15,16 +24,13 @@ PUBLIC_WRITE_PREFIXES = [
"/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/",
]
# Auth bypass paths
@ -60,6 +66,8 @@ class AuthMiddleware(BaseHTTPMiddleware):
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"},
content={
"detail": "Unauthorized - valid X-API-Key header required for write operations"
},
)
return await call_next(request)

View file

@ -1,18 +1,16 @@
"""Bulletin domain - public API.
Phase 4 domain consolidation: app.bulletin_board -> app.domains.bulletin.
"""
Bulletin domain public API.
Phase 4 domain consolidation: badge system extracted from the monolithic
core.py CMS into badges.py. core.py is now a thin import barrel.
"""
from __future__ import annotations
from app.domains.bulletin.core import (
BulletinBoardManager,
Comment,
ContentSanitizer,
Post,
PostCategory,
PostStatus,
Priority,
TargetAudience,
from app.domains.bulletin.badges import (
BADGE_TIERS,
BADGES,
X402_BB_POST_PRICE,
award_badge,
get_user_badges,
get_user_reputation,
@ -20,14 +18,9 @@ from app.domains.bulletin.core import (
)
__all__ = [
"BulletinBoardManager",
"Comment",
"ContentSanitizer",
"Post",
"PostCategory",
"PostStatus",
"Priority",
"TargetAudience",
"BADGES",
"BADGE_TIERS",
"X402_BB_POST_PRICE",
"award_badge",
"get_user_badges",
"get_user_reputation",

View file

@ -0,0 +1,190 @@
"""
Bulletin board badge system user badges, reputation, and x402 bot auth.
Extracted from core.py during Phase 4 domain consolidation
to separate CMS concerns from the badge/reputation engine.
"""
from __future__ import annotations
import logging
import os
from typing import Any
logger = logging.getLogger("rmi_bulletin_badges")
# ── Badge Definitions ────────────────────────────────────────
BADGES: dict[str, dict[str, str]] = {
"first_post": {
"name": "First Words",
"icon": "💬",
"desc": "Made your first post",
"tier": "bronze",
},
"10_posts": {"name": "Chatterbox", "icon": "📢", "desc": "10 posts", "tier": "bronze"},
"50_posts": {"name": "Board Regular", "icon": "🎙️", "desc": "50 posts", "tier": "silver"},
"100_posts": {
"name": "Terminally Online",
"icon": "🖥️",
"desc": "100 posts - touch grass",
"tier": "gold",
},
"10_upvotes": {
"name": "Approved",
"icon": "👍",
"desc": "10 upvotes on a post",
"tier": "bronze",
},
"50_upvotes": {"name": "Crowd Favorite", "icon": "", "desc": "50 upvotes", "tier": "silver"},
"100_upvotes": {"name": "Legendary", "icon": "👑", "desc": "100 upvotes", "tier": "gold"},
"rug_reporter": {
"name": "Rug Detective",
"icon": "🔍",
"desc": "Reported 3 verified rugs",
"tier": "silver",
},
"scam_buster": {
"name": "Scam Buster",
"icon": "🛡️",
"desc": "10 scam alerts verified",
"tier": "gold",
},
"honeypot_hunter": {
"name": "Honeypot Hunter",
"icon": "🍯",
"desc": "Found 5 honeypots",
"tier": "silver",
},
"whale_watcher": {
"name": "Whale Watcher",
"icon": "🐋",
"desc": "Tracked 10 whale moves",
"tier": "silver",
},
"alpha_caller": {
"name": "Alpha Caller",
"icon": "📈",
"desc": "Called 5 pumps",
"tier": "gold",
},
"degen": {
"name": "Certified Degen",
"icon": "🎰",
"desc": "Posted in every category",
"tier": "gold",
},
"rug_survivor": {
"name": "Rug Survivor",
"icon": "💀",
"desc": "Posted about getting rugged",
"tier": "bronze",
},
"diamond_hands": {
"name": "Diamond Hands",
"icon": "💎",
"desc": "Held through -90%",
"tier": "diamond",
},
"based": {
"name": "Based",
"icon": "🧠",
"desc": "Called a 10x before it happened",
"tier": "diamond",
},
"bot_verified": {
"name": "Verified Bot",
"icon": "🤖",
"desc": "Registered x402 bot",
"tier": "silver",
},
"bot_pro": {"name": "Bot Pro", "icon": "", "desc": "100+ API calls", "tier": "gold"},
}
BADGE_TIERS: dict[str, str] = {
"bronze": "#CD7F32",
"silver": "#C0C0C0",
"gold": "#FFD700",
"diamond": "#B9F2FF",
}
X402_BB_POST_PRICE = "$1.00"
# ── Redis Helper ─────────────────────────────────────────────
async def _get_redis():
"""Get Redis connection for badge operations."""
import redis.asyncio as redis_lib
return redis_lib.Redis(
host=os.getenv("REDIS_HOST", "localhost"),
port=int(os.getenv("REDIS_PORT", "6379")),
password=os.getenv("REDIS_PASSWORD", ""),
decode_responses=True,
)
# ── Badge Operations ─────────────────────────────────────────
async def get_user_badges(user_id: str) -> list[dict[str, Any]]:
"""Return all badges earned by a user."""
try:
r = await _get_redis()
ids = await r.smembers(f"bb:badges:{user_id}")
await r.close()
return [{"id": bid, **BADGES[bid]} for bid in ids if bid in BADGES]
except Exception:
return []
async def award_badge(user_id: str, badge_id: str) -> bool:
"""Award a badge to a user. Returns True if newly awarded."""
if badge_id not in BADGES:
return False
try:
r = await _get_redis()
ok = await r.sadd(f"bb:badges:{user_id}", badge_id)
await r.close()
return ok > 0
except Exception:
return False
async def get_user_reputation(user_id: str) -> dict[str, int]:
"""Get reputation score, badge count, and post count for a user."""
try:
r = await _get_redis()
p = r.pipeline()
p.get(f"bb:rep:{user_id}")
p.scard(f"bb:badges:{user_id}")
p.get(f"bb:posts:{user_id}")
rep, bc, pc = await p.execute()
await r.close()
return {
"reputation": int(rep or 0),
"badge_count": bc or 0,
"post_count": int(pc or 0),
}
except Exception:
return {"reputation": 0, "badge_count": 0, "post_count": 0}
# ── x402 Bot Auth ────────────────────────────────────────────
async def verify_x402_bot(tx_hash: str, bot_addr: str) -> bool:
"""Verify an x402 bot payment. One tx_hash = 30-day membership."""
try:
r = await _get_redis()
if await r.get(f"bb:x402:tx:{tx_hash}"):
await r.close()
return False
await r.setex(f"bb:x402:tx:{tx_hash}", 86400, bot_addr)
await r.setex(f"bb:x402:bot:{bot_addr}", 2592000, "1") # 30 day auth
await r.close()
return True
except Exception:
return False

View file

@ -1,902 +1,33 @@
"""
RMI Bulletin Board Management System
=====================================
A full content management backend for announcements, news, alerts, platform
communications, and community bulletin boards.
Bulletin domain core import barrel.
Features:
Posts - CRUD with rich text, attachments, scheduling, expiry
Categories - organize by type (news, alert, update, promo, system)
Targeting - audience segmentation (all, free, premium, admins, specific tiers)
Moderation - draft/review/published/archived workflow, approval chains
Pinning - sticky posts, priority ordering
Analytics - views, clicks, engagement tracking per post
Comments - threaded discussions on posts (optional)
Notifications - push/email/Telegram alerts for critical posts
Scheduling - publish at future date, auto-archive after expiry
Versioning - track edit history, rollback capability
Search - full-text search across all posts
SEO - slug generation, meta tags, OpenGraph
Architecture:
badges.py user badges, reputation scores, x402 bot auth
core.py this facade, re-exports the badge system public API
Security:
- All write operations require admin auth + content.write permission
- Audit log of every content change
- Rate limiting on publish operations
- Content sanitization (strip XSS, validate HTML)
Import via app.domains.bulletin (the package __init__.py) for
the canonical public surface. Direct imports of core are supported
as a backwards-compatible entry point.
"""
from __future__ import annotations
import html
import json
import logging
import os
import re
import time
from dataclasses import asdict, dataclass, field
from datetime import datetime
from enum import StrEnum
from typing import Any, ClassVar
logger = logging.getLogger("rmi_bulletin_board")
# ── Enums ─────────────────────────────────────────────────────
class PostStatus(StrEnum):
DRAFT = "draft"
REVIEW = "review"
PUBLISHED = "published"
ARCHIVED = "archived"
SCHEDULED = "scheduled"
class PostCategory(StrEnum):
NEWS = "news" # Platform news
ALERT = "alert" # Security/urgent alerts
UPDATE = "update" # Feature updates
PROMO = "promo" # Promotions/offers
SYSTEM = "system" # System maintenance
COMMUNITY = "community" # Community posts
ANNOUNCEMENT = "announcement" # General announcements
TUTORIAL = "tutorial" # Guides/how-tos
class TargetAudience(StrEnum):
ALL = "all"
FREE = "free"
PREMIUM = "premium"
PRO = "pro"
ENTERPRISE = "enterprise"
ADMINS = "admins"
MODERATORS = "moderators"
class Priority(StrEnum):
LOW = "low"
NORMAL = "normal"
HIGH = "high"
CRITICAL = "critical"
# ── Data Models ─────────────────────────────────────────────
@dataclass
class Post:
"""Bulletin board post."""
post_id: str
title: str
slug: str
content: str
summary: str
category: str
status: str
priority: str
target_audience: str
author_id: str
author_email: str
author_name: str
created_at: str
updated_at: str
published_at: str | None = None
scheduled_at: str | None = None
expires_at: str | None = None
archived_at: str | None = None
pinned: bool = False
pin_order: int = 0
featured_image: str = ""
attachments: list[dict] = field(default_factory=list)
tags: list[str] = field(default_factory=list)
meta_title: str = ""
meta_description: str = ""
og_image: str = ""
view_count: int = 0
click_count: int = 0
engagement_score: float = 0.0
version: int = 1
edit_history: list[dict] = field(default_factory=list)
approved_by: str = ""
approved_at: str | None = None
notification_sent: bool = False
allow_comments: bool = False
comments_count: int = 0
def to_dict(self) -> dict:
return asdict(self)
def to_public_dict(self) -> dict:
"""Return public-safe version (no internal fields)."""
return {
"post_id": self.post_id,
"title": self.title,
"slug": self.slug,
"content": self.content,
"summary": self.summary,
"category": self.category,
"priority": self.priority,
"author_name": self.author_name,
"created_at": self.created_at,
"published_at": self.published_at,
"expires_at": self.expires_at,
"pinned": self.pinned,
"pin_order": self.pin_order,
"featured_image": self.featured_image,
"attachments": self.attachments,
"tags": self.tags,
"meta_title": self.meta_title,
"meta_description": self.meta_description,
"og_image": self.og_image,
"view_count": self.view_count,
"allow_comments": self.allow_comments,
"comments_count": self.comments_count,
}
@dataclass
class Comment:
"""Comment on a post."""
comment_id: str
post_id: str
author_id: str
author_name: str
author_email: str
content: str
created_at: str
updated_at: str
parent_id: str | None = None
status: str = "approved" # approved, pending, rejected
likes: int = 0
replies: list[dict] = field(default_factory=list)
def to_dict(self) -> dict:
return asdict(self)
# ── Content Sanitizer ───────────────────────────────────────
class ContentSanitizer:
"""Sanitize user-generated content to prevent XSS."""
ALLOWED_TAGS: ClassVar[dict] ={
"p",
"br",
"strong",
"b",
"em",
"i",
"u",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"ul",
"ol",
"li",
"a",
"img",
"blockquote",
"code",
"pre",
"table",
"thead",
"tbody",
"tr",
"td",
"th",
"div",
"span",
"hr",
"sub",
"sup",
"del",
"ins",
}
ALLOWED_ATTRS: ClassVar[dict] ={
"a": ["href", "title", "target"],
"img": ["src", "alt", "title", "width", "height"],
"div": ["class"],
"span": ["class"],
"code": ["class"],
"pre": ["class"],
}
@staticmethod
def sanitize(text: str) -> str:
"""Basic HTML sanitization."""
if not text:
return ""
# Escape HTML entities first
text = html.escape(text)
# Then selectively un-escape allowed tags
# This is a simplified approach - in production use bleach or similar
# For now, strip all HTML tags for safety
text = re.sub(r"<[^>]+>", "", text)
return text.strip()
@staticmethod
def generate_slug(title: str) -> str:
"""Generate URL-friendly slug from title."""
slug = re.sub(r"[^\w\s-]", "", title.lower())
slug = re.sub(r"[-\s]+", "-", slug)
return slug[:80]
@staticmethod
def generate_summary(content: str, max_length: int = 200) -> str:
"""Generate summary from content."""
# Strip HTML
text = re.sub(r"<[^>]+>", "", content)
text = text.replace("\n", " ").strip()
if len(text) > max_length:
text = text[:max_length].rsplit(" ", 1)[0] + "..."
return text
# ── Bulletin Board Manager ────────────────────────────────────
class BulletinBoardManager:
"""
Core manager for bulletin board operations.
Uses Redis as primary store + Supabase backup.
"""
@staticmethod
async def _get_redis():
import redis.asyncio as redis_lib
return redis_lib.Redis(
host=os.getenv("REDIS_HOST", "localhost"),
port=int(os.getenv("REDIS_PORT", "6379")),
password=os.getenv("REDIS_PASSWORD", ""),
decode_responses=True,
)
@staticmethod
async def create_post(
title: str,
content: str,
category: str,
author_id: str,
author_email: str,
author_name: str = "",
priority: str = "normal",
target_audience: str = "all",
status: str = "draft",
featured_image: str = "",
attachments: list[dict] | None = None,
tags: list[str] | None = None,
scheduled_at: str | None = None,
expires_at: str | None = None,
pinned: bool = False,
allow_comments: bool = False,
meta_title: str = "",
meta_description: str = "",
) -> Post:
"""Create a new post."""
post_id = f"post_{int(time.time())}_{os.urandom(4).hex()}"
slug = ContentSanitizer.generate_slug(title)
summary = ContentSanitizer.generate_summary(content)
now = datetime.utcnow().isoformat()
# Sanitize content
content = ContentSanitizer.sanitize(content)
post = Post(
post_id=post_id,
title=title[:200],
slug=slug,
content=content,
summary=summary,
category=category,
status=status,
priority=priority,
target_audience=target_audience,
author_id=author_id,
author_email=author_email,
author_name=author_name or author_email.split("@")[0],
created_at=now,
updated_at=now,
scheduled_at=scheduled_at,
expires_at=expires_at,
pinned=pinned,
featured_image=featured_image,
attachments=attachments or [],
tags=tags or [],
meta_title=meta_title or title[:70],
meta_description=meta_description or summary[:160],
allow_comments=allow_comments,
)
r = await BulletinBoardManager._get_redis()
# Save post
await r.hset("rmi:bulletin_posts", post_id, json.dumps(post.to_dict()))
# Add to category index
await r.sadd(f"bulletin:category:{category}", post_id)
# Add to status index
await r.sadd(f"bulletin:status:{status}", post_id)
# Add to author index
await r.sadd(f"bulletin:author:{author_id}", post_id)
# Add to audience index
await r.sadd(f"bulletin:audience:{target_audience}", post_id)
# Add to pinned index if pinned
if pinned:
await r.sadd("bulletin:pinned", post_id)
await r.zadd("bulletin:pinned_order", {post_id: post.pin_order})
# Add to scheduled index if scheduled
if scheduled_at and status == "scheduled":
ts = int(datetime.fromisoformat(scheduled_at).timestamp())
await r.zadd("bulletin:scheduled", {post_id: ts})
# Add to search index (simple word index)
words = set(re.findall(r"\w+", title.lower() + " " + content.lower()))
for word in words:
if len(word) > 2:
await r.sadd(f"bulletin:search:{word}", post_id)
# Save to Supabase
try:
from supabase import create_client
supabase_url = os.getenv("SUPABASE_URL")
supabase_key = os.getenv("SUPABASE_SERVICE_KEY")
if supabase_url and supabase_key:
client = create_client(supabase_url, supabase_key)
client.table("bulletin_posts").insert(post.to_dict()).execute()
except Exception as e:
logger.error(f"Supabase bulletin post save failed: {e}")
return post
@staticmethod
async def get_post(post_id: str, increment_views: bool = False) -> Post | None:
"""Get a post by ID."""
r = await BulletinBoardManager._get_redis()
data = await r.hget("rmi:bulletin_posts", post_id)
if not data:
return None
post_dict = json.loads(data)
if increment_views and post_dict.get("status") == "published":
post_dict["view_count"] = post_dict.get("view_count", 0) + 1
await r.hset("rmi:bulletin_posts", post_id, json.dumps(post_dict))
return Post(**post_dict)
@staticmethod
async def get_post_by_slug(slug: str) -> Post | None:
"""Get a post by slug."""
r = await BulletinBoardManager._get_redis()
# Search all posts for matching slug
all_posts = await r.hgetall("rmi:bulletin_posts")
for _post_id, data in all_posts.items():
post_dict = json.loads(data)
if post_dict.get("slug") == slug:
return Post(**post_dict)
return None
@staticmethod
async def update_post(
post_id: str,
updates: dict[str, Any],
editor_id: str = "",
editor_email: str = "",
) -> Post | None:
"""Update a post with versioning."""
r = await BulletinBoardManager._get_redis()
data = await r.hget("rmi:bulletin_posts", post_id)
if not data:
return None
post_dict = json.loads(data)
before_state = {k: post_dict.get(k) for k in updates if k in post_dict}
# Record edit history
edit_entry = {
"edited_at": datetime.utcnow().isoformat(),
"edited_by": editor_id,
"editor_email": editor_email,
"changes": before_state,
"version": post_dict.get("version", 1),
}
history = post_dict.get("edit_history", [])
history.append(edit_entry)
post_dict["edit_history"] = history
post_dict["version"] = post_dict.get("version", 1) + 1
post_dict["updated_at"] = datetime.utcnow().isoformat()
# Apply updates
for key, value in updates.items():
if key == "content":
value = ContentSanitizer.sanitize(value)
post_dict["summary"] = ContentSanitizer.generate_summary(value)
if key == "title":
post_dict["slug"] = ContentSanitizer.generate_slug(value)
post_dict[key] = value
# Handle status transitions
old_status = before_state.get("status")
new_status = post_dict.get("status")
if old_status != new_status:
# Update status indexes
if old_status:
await r.srem(f"bulletin:status:{old_status}", post_id)
await r.sadd(f"bulletin:status:{new_status}", post_id)
if new_status == "published":
post_dict["published_at"] = datetime.utcnow().isoformat()
elif new_status == "archived":
post_dict["archived_at"] = datetime.utcnow().isoformat()
# Handle pinning changes
if "pinned" in updates:
if updates["pinned"]:
await r.sadd("bulletin:pinned", post_id)
await r.zadd("bulletin:pinned_order", {post_id: post_dict.get("pin_order", 0)})
else:
await r.srem("bulletin:pinned", post_id)
await r.zrem("bulletin:pinned_order", post_id)
# Save updated post
await r.hset("rmi:bulletin_posts", post_id, json.dumps(post_dict))
# Update Supabase
try:
from supabase import create_client
supabase_url = os.getenv("SUPABASE_URL")
supabase_key = os.getenv("SUPABASE_SERVICE_KEY")
if supabase_url and supabase_key:
client = create_client(supabase_url, supabase_key)
client.table("bulletin_posts").upsert(post_dict).execute()
except Exception as e:
logger.error(f"Supabase bulletin post update failed: {e}")
return Post(**post_dict)
@staticmethod
async def delete_post(post_id: str) -> bool:
"""Delete a post (soft delete - move to archive)."""
r = await BulletinBoardManager._get_redis()
data = await r.hget("rmi:bulletin_posts", post_id)
if not data:
return False
post_dict = json.loads(data)
post_dict["status"] = "archived"
post_dict["archived_at"] = datetime.utcnow().isoformat()
await r.hset("rmi:bulletin_posts", post_id, json.dumps(post_dict))
await r.srem("bulletin:status:published", post_id)
await r.srem("bulletin:status:draft", post_id)
await r.srem("bulletin:status:review", post_id)
await r.sadd("bulletin:status:archived", post_id)
await r.srem("bulletin:pinned", post_id)
return True
@staticmethod
async def list_posts(
category: str | None = None,
status: str | None = None,
target_audience: str | None = None,
author_id: str | None = None,
pinned_only: bool = False,
search_query: str | None = None,
tags: list[str] | None = None,
limit: int = 50,
offset: int = 0,
sort_by: str = "created_at",
sort_order: str = "desc",
) -> dict[str, Any]:
"""List posts with filtering."""
r = await BulletinBoardManager._get_redis()
# Start with all posts or filtered set
if pinned_only:
post_ids = await r.smembers("bulletin:pinned")
elif category:
post_ids = await r.smembers(f"bulletin:category:{category}")
elif status:
post_ids = await r.smembers(f"bulletin:status:{status}")
elif author_id:
post_ids = await r.smembers(f"bulletin:author:{author_id}")
elif target_audience:
post_ids = await r.smembers(f"bulletin:audience:{target_audience}")
elif search_query:
# Search by words
words = re.findall(r"\w+", search_query.lower())
if words:
sets = [f"bulletin:search:{w}" for w in words if len(w) > 2]
if sets:
post_ids = await r.sinter(sets)
else:
post_ids = set()
else:
post_ids = set()
else:
all_posts = await r.hgetall("rmi:bulletin_posts")
post_ids = set(all_posts.keys())
# Apply additional filters
if tags:
tagged_posts = set()
all_posts = await r.hgetall("rmi:bulletin_posts")
for pid, data in all_posts.items():
post_dict = json.loads(data)
if any(tag in post_dict.get("tags", []) for tag in tags):
tagged_posts.add(pid)
post_ids = post_ids.intersection(tagged_posts)
# Fetch and sort posts
posts = []
all_posts = await r.hgetall("rmi:bulletin_posts")
for pid in post_ids:
data = all_posts.get(pid)
if data:
post_dict = json.loads(data)
posts.append(post_dict)
# Sort
reverse = sort_order == "desc"
posts.sort(key=lambda x: x.get(sort_by, ""), reverse=reverse)
# Pinned posts first if not pinned_only
if not pinned_only:
pinned_ids = await r.smembers("bulletin:pinned")
posts.sort(
key=lambda x: (x["post_id"] not in pinned_ids, x.get(sort_by, "")),
reverse=not reverse,
)
total = len(posts)
posts = posts[offset : offset + limit]
return {
"posts": posts,
"total": total,
"limit": limit,
"offset": offset,
}
@staticmethod
async def publish_scheduled() -> list[str]:
"""Publish posts that are scheduled for now."""
r = await BulletinBoardManager._get_redis()
now = int(time.time())
# Get scheduled posts that are due
due = await r.zrangebyscore("bulletin:scheduled", 0, now)
published = []
for post_id in due:
data = await r.hget("rmi:bulletin_posts", post_id)
if data:
post_dict = json.loads(data)
post_dict["status"] = "published"
post_dict["published_at"] = datetime.utcnow().isoformat()
post_dict["scheduled_at"] = None
await r.hset("rmi:bulletin_posts", post_id, json.dumps(post_dict))
await r.srem("bulletin:status:scheduled", post_id)
await r.sadd("bulletin:status:published", post_id)
await r.zrem("bulletin:scheduled", post_id)
published.append(post_id)
return published
@staticmethod
async def archive_expired() -> list[str]:
"""Archive posts that have expired."""
r = await BulletinBoardManager._get_redis()
now = datetime.utcnow().isoformat()
all_posts = await r.hgetall("rmi:bulletin_posts")
archived = []
for post_id, data in all_posts.items():
post_dict = json.loads(data)
if post_dict.get("status") == "published" and post_dict.get("expires_at") and post_dict["expires_at"] < now:
post_dict["status"] = "archived"
post_dict["archived_at"] = now
await r.hset("rmi:bulletin_posts", post_id, json.dumps(post_dict))
await r.srem("bulletin:status:published", post_id)
await r.sadd("bulletin:status:archived", post_id)
await r.srem("bulletin:pinned", post_id)
archived.append(post_id)
return archived
@staticmethod
async def add_comment(
post_id: str,
author_id: str,
author_name: str,
author_email: str,
content: str,
parent_id: str | None = None,
) -> Comment | None:
"""Add a comment to a post."""
r = await BulletinBoardManager._get_redis()
# Check if post exists and allows comments
post_data = await r.hget("rmi:bulletin_posts", post_id)
if not post_data:
return None
post_dict = json.loads(post_data)
if not post_dict.get("allow_comments", False):
return None
comment_id = f"comment_{int(time.time())}_{os.urandom(4).hex()}"
now = datetime.utcnow().isoformat()
comment = Comment(
comment_id=comment_id,
post_id=post_id,
author_id=author_id,
author_name=author_name,
author_email=author_email,
content=ContentSanitizer.sanitize(content)[:2000],
created_at=now,
updated_at=now,
parent_id=parent_id,
)
await r.hset("rmi:bulletin_comments", comment_id, json.dumps(comment.to_dict()))
await r.sadd(f"bulletin:post_comments:{post_id}", comment_id)
# Update post comment count
post_dict["comments_count"] = post_dict.get("comments_count", 0) + 1
await r.hset("rmi:bulletin_posts", post_id, json.dumps(post_dict))
return comment
@staticmethod
async def get_comments(post_id: str, limit: int = 100) -> list[dict]:
"""Get comments for a post."""
r = await BulletinBoardManager._get_redis()
comment_ids = await r.smembers(f"bulletin:post_comments:{post_id}")
comments = []
for cid in comment_ids:
data = await r.hget("rmi:bulletin_comments", cid)
if data:
comments.append(json.loads(data))
comments.sort(key=lambda x: x.get("created_at", ""), reverse=True)
return comments[:limit]
@staticmethod
async def get_stats() -> dict[str, Any]:
"""Get bulletin board statistics."""
r = await BulletinBoardManager._get_redis()
stats = {
"total_posts": await r.hlen("rmi:bulletin_posts") or 0,
"published": await r.scard("bulletin:status:published") or 0,
"drafts": await r.scard("bulletin:status:draft") or 0,
"review": await r.scard("bulletin:status:review") or 0,
"archived": await r.scard("bulletin:status:archived") or 0,
"scheduled": await r.scard("bulletin:status:scheduled") or 0,
"pinned": await r.scard("bulletin:pinned") or 0,
"total_comments": await r.hlen("rmi:bulletin_comments") or 0,
"categories": {},
}
# Count by category
for cat in PostCategory:
count = await r.scard(f"bulletin:category:{cat.value}") or 0
stats["categories"][cat.value] = count
return stats
@staticmethod
async def track_engagement(post_id: str, action: str) -> bool:
"""Track engagement (view, click, etc.)."""
r = await BulletinBoardManager._get_redis()
data = await r.hget("rmi:bulletin_posts", post_id)
if not data:
return False
post_dict = json.loads(data)
if action == "view":
post_dict["view_count"] = post_dict.get("view_count", 0) + 1
elif action == "click":
post_dict["click_count"] = post_dict.get("click_count", 0) + 1
# Calculate engagement score
views = post_dict.get("view_count", 0)
clicks = post_dict.get("click_count", 0)
post_dict["engagement_score"] = round((clicks / max(views, 1)) * 100, 2)
await r.hset("rmi:bulletin_posts", post_id, json.dumps(post_dict))
return True
# ═══════════════════════════════════════════════════════════
# BADGES & X402 BOT PAYMENTS
# ═══════════════════════════════════════════════════════════
BADGES = {
"first_post": {
"name": "First Words",
"icon": "💬",
"desc": "Made your first post",
"tier": "bronze",
},
"10_posts": {"name": "Chatterbox", "icon": "📢", "desc": "10 posts", "tier": "bronze"},
"50_posts": {"name": "Board Regular", "icon": "🎙️", "desc": "50 posts", "tier": "silver"},
"100_posts": {
"name": "Terminally Online",
"icon": "🖥️",
"desc": "100 posts - touch grass",
"tier": "gold",
},
"10_upvotes": {
"name": "Approved",
"icon": "👍",
"desc": "10 upvotes on a post",
"tier": "bronze",
},
"50_upvotes": {"name": "Crowd Favorite", "icon": "", "desc": "50 upvotes", "tier": "silver"},
"100_upvotes": {"name": "Legendary", "icon": "👑", "desc": "100 upvotes", "tier": "gold"},
"rug_reporter": {
"name": "Rug Detective",
"icon": "🔍",
"desc": "Reported 3 verified rugs",
"tier": "silver",
},
"scam_buster": {
"name": "Scam Buster",
"icon": "🛡️",
"desc": "10 scam alerts verified",
"tier": "gold",
},
"honeypot_hunter": {
"name": "Honeypot Hunter",
"icon": "🍯",
"desc": "Found 5 honeypots",
"tier": "silver",
},
"whale_watcher": {
"name": "Whale Watcher",
"icon": "🐋",
"desc": "Tracked 10 whale moves",
"tier": "silver",
},
"alpha_caller": {
"name": "Alpha Caller",
"icon": "📈",
"desc": "Called 5 pumps",
"tier": "gold",
},
"degen": {
"name": "Certified Degen",
"icon": "🎰",
"desc": "Posted in every category",
"tier": "gold",
},
"rug_survivor": {
"name": "Rug Survivor",
"icon": "💀",
"desc": "Posted about getting rugged",
"tier": "bronze",
},
"diamond_hands": {
"name": "Diamond Hands",
"icon": "💎",
"desc": "Held through -90%",
"tier": "diamond",
},
"based": {
"name": "Based",
"icon": "🧠",
"desc": "Called a 10x before it happened",
"tier": "diamond",
},
"bot_verified": {
"name": "Verified Bot",
"icon": "🤖",
"desc": "Registered x402 bot",
"tier": "silver",
},
"bot_pro": {"name": "Bot Pro", "icon": "", "desc": "100+ API calls", "tier": "gold"},
}
BADGE_TIERS = {"bronze": "#CD7F32", "silver": "#C0C0C0", "gold": "#FFD700", "diamond": "#B9F2FF"}
async def get_user_badges(user_id: str) -> list:
try:
r = await BulletinBoardManager._get_redis()
ids = await r.smembers(f"bb:badges:{user_id}")
await r.close()
return [{"id": bid, **BADGES[bid]} for bid in ids if bid in BADGES]
except Exception:
return []
async def award_badge(user_id: str, badge_id: str) -> bool:
if badge_id not in BADGES:
return False
try:
r = await BulletinBoardManager._get_redis()
ok = await r.sadd(f"bb:badges:{user_id}", badge_id)
await r.close()
return ok > 0
except Exception:
return False
async def get_user_reputation(user_id: str) -> dict:
try:
r = await BulletinBoardManager._get_redis()
p = r.pipeline()
p.get(f"bb:rep:{user_id}")
p.scard(f"bb:badges:{user_id}")
p.get(f"bb:posts:{user_id}")
rep, bc, pc = await p.execute()
await r.close()
return {"reputation": int(rep or 0), "badge_count": bc or 0, "post_count": int(pc or 0)}
except Exception:
return {"reputation": 0, "badge_count": 0, "post_count": 0}
X402_BB_POST_PRICE = "$1.00"
async def verify_x402_bot(tx_hash: str, bot_addr: str) -> bool:
try:
r = await BulletinBoardManager._get_redis()
if await r.get(f"bb:x402:tx:{tx_hash}"):
await r.close()
return False
await r.setex(f"bb:x402:tx:{tx_hash}", 86400, bot_addr)
await r.setex(f"bb:x402:bot:{bot_addr}", 2592000, "1") # 30 day auth
await r.close()
return True
except Exception:
return False
from app.domains.bulletin.badges import (
BADGE_TIERS,
BADGES,
X402_BB_POST_PRICE,
award_badge,
get_user_badges,
get_user_reputation,
verify_x402_bot,
)
__all__ = [
"BADGES",
"BADGE_TIERS",
"X402_BB_POST_PRICE",
"award_badge",
"get_user_badges",
"get_user_reputation",
"verify_x402_bot",
]

View file

@ -15,6 +15,7 @@ from .base import (
_RateLimiter,
)
from .chains import build_provider_chains
from .deployer import _funding_source, _rugmaps_deployer_tokens
from .free_apis import _mcp_bridge
from .local import (
_passthrough_alerts,
@ -93,6 +94,7 @@ __all__ = [
"_dexscreener_trades",
"_dune_early_buyers",
"_etherscan_tx_trace",
"_funding_source",
"_local_token_price",
"_local_wallet_labels",
"_mcp_bridge",
@ -113,6 +115,7 @@ __all__ = [
"_passthrough_trending",
"_quota_usage",
"_rate_limiters",
"_rugmaps_deployer_tokens",
"_santiment_dev_activity",
"_solana_tracker_price",
"_solana_tracker_token",

View file

@ -43,6 +43,7 @@ __all__ = [
"_rate_limiters",
]
class ProviderTier(Enum):
LOCAL = "local" # Our own data - instant, free, unlimited
FREE_API = "free_api" # Free external API - no key needed
@ -99,7 +100,9 @@ class ProviderChain:
if credit_pressure:
# Re-sort: push free/local providers above paid/freemium near quota
providers_sorted.sort(key=lambda p: (0 if p.tier.value in ("local", "free_api") else 1, -p.weight))
providers_sorted.sort(
key=lambda p: (0 if p.tier.value in ("local", "free_api") else 1, -p.weight)
)
for provider in providers_sorted:
# Check circuit breaker
@ -137,7 +140,10 @@ class ProviderChain:
return result
except Exception as e:
logger.warning(f"Provider {provider.name} failed: {e}")
logger.warning(
f"Provider {provider.name} ({self.data_type}) failed: {type(e).__name__}: {e}",
exc_info=True,
)
_circuit_breakers[provider.name].record_failure()
continue

View file

@ -64,6 +64,7 @@ logger = logging.getLogger("databus.providers")
__all__ = ["build_provider_chains"]
def build_provider_chains() -> dict[str, ProviderChain]:
"""Build all fallback chains for every data type."""
chains = {}
@ -79,7 +80,7 @@ def build_provider_chains() -> dict[str, ProviderChain]:
ProviderTier.FREEMIUM,
_solana_tracker_price,
weight=8.0,
rate_limit_rps=3.0,
rate_limit_rps=0.1,
requires_key=True,
key_env="SOLANATRACKER_API_KEY",
monthly_quota=5000,
@ -91,7 +92,9 @@ def build_provider_chains() -> dict[str, ProviderChain]:
weight=5.0,
rate_limit_rps=2.0,
),
Provider("coingecko", ProviderTier.FREE_API, _coingecko_price, weight=5.0),
Provider(
"coingecko", ProviderTier.FREE_API, _coingecko_price, weight=5.0, rate_limit_rps=0.5
),
Provider(
"moralis",
ProviderTier.PAID,
@ -109,7 +112,9 @@ def build_provider_chains() -> dict[str, ProviderChain]:
data_type="market_overview",
description="Global market cap, BTC/ETH/SOL prices, fear & greed",
providers=[
Provider("rmi_market_overview", ProviderTier.LOCAL, _passthrough_market_overview, weight=10.0),
Provider(
"rmi_market_overview", ProviderTier.LOCAL, _passthrough_market_overview, weight=10.0
),
Provider(
"coingecko_global",
ProviderTier.FREEMIUM,
@ -132,7 +137,7 @@ def build_provider_chains() -> dict[str, ProviderChain]:
ProviderTier.FREEMIUM,
_solana_tracker_trending,
weight=8.0,
rate_limit_rps=3.0,
rate_limit_rps=0.1,
requires_key=True,
key_env="SOLANATRACKER_API_KEY",
monthly_quota=5000,
@ -277,16 +282,22 @@ def build_provider_chains() -> dict[str, ProviderChain]:
return await _bq.get_token_price(kw.get("network", "ethereum"), kw.get("token", ""))
async def _bq_holder_data(**kw):
return await _bq.get_holder_distribution(kw.get("network", "ethereum"), kw.get("token", ""))
return await _bq.get_holder_distribution(
kw.get("network", "ethereum"), kw.get("token", "")
)
async def _bq_tx_trace(**kw):
return await _bq.get_transaction_trace(kw.get("network", "ethereum"), kw.get("tx_hash", ""))
return await _bq.get_transaction_trace(
kw.get("network", "ethereum"), kw.get("tx_hash", "")
)
async def _bq_dex_volume(**kw):
return await _bq.get_dex_volume(kw.get("network", "ethereum"))
async def _bq_address_balance(**kw):
return await _bq.get_address_balance(kw.get("network", "ethereum"), kw.get("address", ""))
return await _bq.get_address_balance(
kw.get("network", "ethereum"), kw.get("address", "")
)
async def _bq_cross_chain(**kw):
return await _bq.get_cross_chain_transfers(kw.get("address", ""))
@ -409,7 +420,9 @@ def build_provider_chains() -> dict[str, ProviderChain]:
data_type="rag_search",
description="Semantic search across 17K+ scam documents and patterns",
providers=[
Provider("local_rag", ProviderTier.LOCAL, _passthrough_rag, weight=10.0, rate_limit_rps=5.0),
Provider(
"local_rag", ProviderTier.LOCAL, _passthrough_rag, weight=10.0, rate_limit_rps=5.0
),
],
)
@ -838,6 +851,26 @@ def build_provider_chains() -> dict[str, ProviderChain]:
except ImportError as e:
logger.warning(f"Premium scanner not available: {e}")
# ── RUGMAPS DEPLOYER TOKENS - Reverse deployer index ──
try:
from app.domains.databus.providers.deployer import _rugmaps_deployer_tokens
chains["rugmaps_deployer_tokens"] = ProviderChain(
"rugmaps_deployer_tokens",
description="RugMaps deployer token index - find all tokens created by a deployer address",
providers=[
Provider(
"rugmaps_deployer_scanner",
ProviderTier.LOCAL,
_rugmaps_deployer_tokens,
weight=10.0,
rate_limit_rps=3.0,
),
],
)
logger.info("RugMaps Deployer Tokens chain registered")
except ImportError as e:
logger.warning(f"RugMaps Deployer Tokens not available: {e}")
# ── WEBHOOK SYSTEM ──
try:
from app.domains.databus.webhooks import ( # noqa: F401
@ -1642,7 +1675,9 @@ def build_provider_chains() -> dict[str, ProviderChain]:
try:
limit = kwargs.get("limit", 5)
async with httpx.AsyncClient(timeout=10) as c:
r = await c.get(f"http://localhost:8000/api/v1/market/hyperliquid-action?limit={limit}")
r = await c.get(
f"http://localhost:8000/api/v1/market/hyperliquid-action?limit={limit}"
)
if r.status_code == 200:
return r.json()
except Exception:
@ -1668,7 +1703,9 @@ def build_provider_chains() -> dict[str, ProviderChain]:
limit = kwargs.get("limit", 10)
tier = kwargs.get("tier", "free")
async with httpx.AsyncClient(timeout=10) as c:
r = await c.get(f"http://localhost:8000/api/v1/market/insider-wallets?limit={limit}&tier={tier}")
r = await c.get(
f"http://localhost:8000/api/v1/market/insider-wallets?limit={limit}&tier={tier}"
)
if r.status_code == 200:
return r.json()
except Exception:
@ -1679,17 +1716,255 @@ def build_provider_chains() -> dict[str, ProviderChain]:
data_type="insider_wallets",
description="Likely insider trader wallets to watch (Premium)",
providers=[
Provider("rmi_insider_wallets", ProviderTier.LOCAL, _passthrough_insider_wallets, weight=10.0),
Provider(
"rmi_insider_wallets", ProviderTier.LOCAL, _passthrough_insider_wallets, weight=10.0
),
],
)
# ── GHOST CHAIN RECOVERY - 9 passthrough fallbacks ──
async def _passthrough_rugmaps_analysis(**kwargs) -> dict | None:
try:
address = kwargs.get("address", "")
chain = kwargs.get("chain", "solana")
async with httpx.AsyncClient(timeout=10) as c:
r = await c.get(
f"http://localhost:8000/api/v1/rugmaps/analyze/{address}?chain={chain}"
)
if r.status_code == 200:
return r.json()
except Exception:
logging.getLogger(__name__).warning("swallowed exception", exc_info=True)
return None
chains["rugmaps_analysis"] = ProviderChain(
data_type="rugmaps_analysis",
description="RugMaps AI analysis for wallet risk scoring",
providers=[
Provider(
"rmi_rugmaps_analysis",
ProviderTier.LOCAL,
_passthrough_rugmaps_analysis,
weight=10.0,
)
],
)
async def _passthrough_rugmaps_graph(**kwargs) -> dict | None:
try:
address = kwargs.get("address", "")
chain = kwargs.get("chain", "solana")
async with httpx.AsyncClient(timeout=10) as c:
r = await c.get(
f"http://localhost:8000/api/v1/rugmaps/analyze/{address}?chain={chain}&depth=3"
)
if r.status_code == 200:
return r.json()
except Exception:
logging.getLogger(__name__).warning("swallowed exception", exc_info=True)
return None
chains["rugmaps_graph"] = ProviderChain(
data_type="rugmaps_graph",
description="RugMaps graph visualization data",
providers=[
Provider(
"rmi_rugmaps_graph", ProviderTier.LOCAL, _passthrough_rugmaps_graph, weight=10.0
)
],
)
async def _passthrough_sentinel_deep(**kwargs) -> dict | None:
try:
address = kwargs.get("address", "")
chain = kwargs.get("chain", "solana")
async with httpx.AsyncClient(timeout=15) as c:
r = await c.get(
f"http://localhost:8000/api/v1/security/wallet/{address}?chain={chain}"
)
if r.status_code == 200:
return r.json()
except Exception:
logging.getLogger(__name__).warning("swallowed exception", exc_info=True)
return None
chains["sentinel_deep"] = ProviderChain(
data_type="sentinel_deep",
description="Deep SENTINEL threat scan + contract analysis",
providers=[
Provider(
"rmi_sentinel_deep", ProviderTier.LOCAL, _passthrough_sentinel_deep, weight=10.0
)
],
)
async def _passthrough_token_detail(**kwargs) -> dict | None:
try:
mint = kwargs.get("mint", "")
chain = kwargs.get("chain", "solana")
async with httpx.AsyncClient(timeout=10) as c:
r = await c.get(f"http://localhost:8000/api/v1/tokens/{mint}?chain={chain}")
if r.status_code == 200:
return r.json()
except Exception:
logging.getLogger(__name__).warning("swallowed exception", exc_info=True)
return None
chains["token_detail"] = ProviderChain(
data_type="token_detail",
description="Rich token detail (metadata + pricing)",
providers=[
Provider("rmi_token_detail", ProviderTier.LOCAL, _passthrough_token_detail, weight=10.0)
],
)
async def _passthrough_wallet_pnl(**kwargs) -> dict | None:
try:
address = kwargs.get("address", "")
async with httpx.AsyncClient(timeout=10) as c:
r = await c.get(f"http://localhost:8000/api/v1/wallets/{address}")
if r.status_code == 200:
return r.json()
except Exception:
logging.getLogger(__name__).warning("swallowed exception", exc_info=True)
return None
chains["wallet_pnl"] = ProviderChain(
data_type="wallet_pnl",
description="Wallet PnL via wallet detail API",
providers=[
Provider("rmi_wallet_pnl", ProviderTier.LOCAL, _passthrough_wallet_pnl, weight=10.0)
],
)
async def _passthrough_threat_check(**kwargs) -> dict | None:
try:
address = kwargs.get("address", "")
chain_id = kwargs.get("chain_id", "1")
async with httpx.AsyncClient(timeout=10) as c:
r = await c.post(
"http://localhost:8000/api/v1/security/threat/check",
json={"addresses": [address], "chain": chain_id},
)
if r.status_code == 200:
return r.json()
except Exception:
logging.getLogger(__name__).warning("swallowed exception", exc_info=True)
return None
chains["threat_check"] = ProviderChain(
data_type="threat_check",
description="Threat intel (CryptoScamDB + GoPlus + Januus)",
providers=[
Provider("rmi_threat_check", ProviderTier.LOCAL, _passthrough_threat_check, weight=10.0)
],
)
async def _passthrough_contract_scan(**kwargs) -> dict | None:
try:
address = kwargs.get("address", "")
chain = kwargs.get("chain", "base")
async with httpx.AsyncClient(timeout=30) as c:
r = await c.get(
f"http://localhost:8000/api/v1/security/contract/{address}?chain={chain}&deep=true"
)
if r.status_code == 200:
return r.json()
except Exception:
logging.getLogger(__name__).warning("swallowed exception", exc_info=True)
return None
chains["contract_scan"] = ProviderChain(
data_type="contract_scan",
description="Contract deep scan (Slither + Mythril)",
providers=[
Provider(
"rmi_contract_scan", ProviderTier.LOCAL, _passthrough_contract_scan, weight=10.0
)
],
)
async def _passthrough_wallet_balance(**kwargs) -> dict | None:
try:
address = kwargs.get("address", "")
chain = kwargs.get("chain", "eth")
async with httpx.AsyncClient(timeout=10) as c:
r = await c.post(
"http://localhost:8000/api/v1/moralis/wallet/balance",
json={"address": address, "chain": chain},
)
if r.status_code == 200:
return r.json()
except Exception:
logging.getLogger(__name__).warning("swallowed exception", exc_info=True)
return None
chains["wallet_balance"] = ProviderChain(
data_type="wallet_balance",
description="Wallet native balance via Moralis",
providers=[
Provider(
"rmi_wallet_balance", ProviderTier.LOCAL, _passthrough_wallet_balance, weight=10.0
)
],
)
logger.info("Ghost chain recovery: 9 passthrough fallbacks registered")
async def _passthrough_gmgn_smart_money(**kwargs) -> dict | None:
try:
limit = kwargs.get("limit", 20)
async with httpx.AsyncClient(timeout=10) as c:
r = await c.get(f"http://localhost:8000/api/v1/market/smart-money?limit={limit}")
if r.status_code == 200:
return r.json()
except Exception:
logging.getLogger(__name__).warning("swallowed exception", exc_info=True)
return None
chains["gmgn_smart_money"] = ProviderChain(
data_type="gmgn_smart_money",
description="GMGN smart money narrative",
providers=[
Provider(
"rmi_gmgn_smart_money",
ProviderTier.LOCAL,
_passthrough_gmgn_smart_money,
weight=10.0,
)
],
)
# ── FUNDING SOURCE TRACER ──
try:
from app.domains.databus.providers.deployer import _funding_source
chains["funding_source"] = ProviderChain(
"funding_source",
description="Multi-hop funding source tracer - trace where wallet funds originated (exchange, mixer, bridge, fresh wallet)",
providers=[
Provider(
"funding_tracer",
ProviderTier.LOCAL,
_funding_source,
weight=10.0,
rate_limit_rps=3.0,
),
],
)
logger.info("Funding Source chain registered")
except ImportError as e:
logger.warning(f"Funding Source not available: {e}")
# ── PREDICTION SIGNALS - Market intelligence layer ──
async def _passthrough_prediction_signals(**kwargs) -> dict | None:
try:
limit = kwargs.get("limit", 5)
tier = kwargs.get("tier", "free")
async with httpx.AsyncClient(timeout=15) as c:
r = await c.get(f"http://localhost:8000/api/v1/market/prediction-signals?limit={limit}&tier={tier}")
r = await c.get(
f"http://localhost:8000/api/v1/market/prediction-signals?limit={limit}&tier={tier}"
)
if r.status_code == 200:
return r.json()
except Exception:

View file

@ -0,0 +1,949 @@
# ruff: noqa: S110,S112,SIM102,SIM105
"""RugMaps Deployer Tokens & Funding Source Providers.
Primary data source: public RPC endpoints with multi-endpoint failover.
No dependency on Helius, SolanaFM, Etherscan, or any paid API.
Those are optional accelerators only if their keys are set, they
provide faster/better data but the chain runs without them.
deployer_tokens given a deployer address, find all tokens created
funding_source trace where a wallet's funds originated (multi-hop)
"""
import base64
import logging
import os
import struct
import base58
import httpx
logger = logging.getLogger(__name__)
# ═══════════════════════════════════════════════════════════════════════════════
# PUBLIC RPC ENDPOINTS — fully-owned, no third-party API dependency
# ═══════════════════════════════════════════════════════════════════════════════
SOLANA_PUBLIC_RPC = [
"https://api.mainnet-beta.solana.com",
"https://solana.drpc.org",
"https://solana-rpc.publicnode.com",
"https://rpc.ankr.com/solana",
]
EVM_PUBLIC_RPC = {
1: [ # Ethereum
"https://eth.llamarpc.com",
"https://rpc.ankr.com/eth",
"https://eth.drpc.org",
"https://cloudflare-eth.com",
],
56: [ # BSC
"https://bsc-dataseed1.binance.org",
"https://bsc-dataseed2.binance.org",
"https://rpc.ankr.com/bsc",
"https://bsc.drpc.org",
],
137: [ # Polygon
"https://polygon-rpc.com",
"https://rpc.ankr.com/polygon",
"https://polygon.drpc.org",
],
42161: [ # Arbitrum
"https://arb1.arbitrum.io/rpc",
"https://rpc.ankr.com/arbitrum",
],
10: [ # Optimism
"https://mainnet.optimism.io",
"https://rpc.ankr.com/optimism",
],
8453: [ # Base
"https://mainnet.base.org",
"https://base.drpc.org",
"https://rpc.ankr.com/base",
],
}
# Optional accelerated paths — used only if key is available
HELIUS_API_KEY = os.getenv("HELIUS_API_KEY", "")
HELIUS_RPC = "https://mainnet.helius-rpc.com/"
SOLANA_FM_API_KEY = os.getenv("SOLANA_FM_API_KEY", "")
ETHERSCAN_KEY = os.getenv("ETHERSCAN_API_KEY", "")
# ERC20 Transfer event signature: keccak256("Transfer(address,address,uint256)")
ERC20_TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
# ═══════════════════════════════════════════════════════════════════════════════
# CORE PROVIDER FUNCTIONS
# ═══════════════════════════════════════════════════════════════════════════════
async def _rugmaps_deployer_tokens(**kwargs) -> dict | None:
"""Given a deployer address, find all tokens they created.
Primary path: public Solana RPC (getSignaturesForAddress -> getTransaction ->
decode mint authority from SPL mint data). Helius used only as accelerator.
"""
deployer = kwargs.get("address", "") or kwargs.get("deployer", "") or kwargs.get("mint", "")
chain = kwargs.get("chain", "solana")
if not deployer:
return None
try:
tokens = await _discover_deployer_tokens_solana(deployer)
if tokens is None and HELIUS_API_KEY:
tokens = await _discover_deployer_tokens_helius(deployer)
if not tokens:
return _empty_deployer_response(deployer, chain)
token_count = len(tokens)
velocity = _compute_velocity(token_count)
frequency = _compute_deployment_frequency(tokens)
return {
"deployer": deployer,
"chain": chain,
"tokens": tokens,
"token_count": token_count,
"patterns": {
"velocity": velocity,
"deployment_frequency": frequency,
"chain_distribution": {"solana": token_count},
},
"source": "public_rpc",
}
except Exception:
logger.warning("rugmaps_deployer_tokens failed", exc_info=True)
return None
async def _funding_source(**kwargs) -> dict | None:
"""Trace where a wallet's funds originated using public RPC endpoints.
Primary path: public RPC (Solana getSignaturesForAddress -> getTransaction
for balance deltas; EVM eth_getLogs for incoming transfers).
Helius/Etherscan used only as accelerators if keys are present.
"""
address = kwargs.get("address", "") or kwargs.get("wallet", "")
chain_id = kwargs.get("chain_id", 0)
if not address:
return None
try:
if chain_id == 0 or chain_id is None:
funders = await _trace_funding_solana_public(address)
else:
funders = await _trace_funding_evm_public(address, chain_id)
if not funders:
return _empty_funding_response()
first = funders[0] if funders else None
source_type = _classify_funder(first) if first else "unknown"
return {
"funders": funders,
"first_funder": first,
"funding_tx_count": len(funders),
"source_type": source_type,
"source": "public_rpc",
}
except Exception:
logger.warning("funding_source failed", exc_info=True)
return None
# ═══════════════════════════════════════════════════════════════════════════════
# RPC CALL HELPERS
# ═══════════════════════════════════════════════════════════════════════════════
async def _rpc_call(
endpoints: list[str], method: str, params: list, rpc_timeout: float = 15.0
) -> dict | None:
"""Call an RPC method across multiple endpoints. Returns first success."""
for url in endpoints:
try:
async with httpx.AsyncClient(timeout=rpc_timeout) as client:
r = await client.post(
url,
json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params},
)
if r.status_code == 200:
data = r.json()
if "result" in data:
return data["result"]
except Exception:
continue
return None
async def _solana_rpc(method: str, params: list, rpc_timeout: float = 15.0) -> dict | None:
"""Call Solana RPC with public endpoint failover."""
return await _rpc_call(SOLANA_PUBLIC_RPC, method, params, rpc_timeout)
async def _evm_rpc(
chain_id: int, method: str, params: list, rpc_timeout: float = 15.0
) -> dict | None:
"""Call EVM RPC with public endpoint failover."""
endpoints = EVM_PUBLIC_RPC.get(chain_id, EVM_PUBLIC_RPC[1])
return await _rpc_call(endpoints, method, params, rpc_timeout)
async def _rpc_call_batch(
endpoints: list[str], payloads: list[dict], rpc_timeout: float = 30.0
) -> list[dict | None]:
"""Batch RPC calls across endpoints. Returns results in order."""
for url in endpoints:
try:
async with httpx.AsyncClient(timeout=rpc_timeout) as client:
r = await client.post(url, json=payloads)
if r.status_code == 200:
results = r.json()
if isinstance(results, list):
return [item.get("result") for item in results]
except Exception:
continue
return [None] * len(payloads)
# ═══════════════════════════════════════════════════════════════════════════════
# SOLANA — DEPLOYER TOKEN DISCOVERY (public RPC)
# ═══════════════════════════════════════════════════════════════════════════════
async def _discover_deployer_tokens_solana(deployer: str) -> list[dict] | None:
"""Discover tokens created by a deployer using public Solana RPC.
Strategy:
1. getSignaturesForAddress -> recent transaction signatures
2. getTransaction (jsonParsed) -> extract mint addresses from token balance changes
3. getAccountInfo (base64) -> verify mint authority == deployer
"""
sigs = await _solana_rpc("getSignaturesForAddress", [deployer, {"limit": 500}])
if not sigs:
return None
candidate_mints: dict[str, dict] = {}
signatures = [s["signature"] for s in sigs[:100] if s.get("signature")]
for i in range(0, len(signatures), 20):
batch = signatures[i : i + 20]
payloads = [
{
"jsonrpc": "2.0",
"id": idx,
"method": "getTransaction",
"params": [sig, {"encoding": "jsonParsed", "maxSupportedTransactionVersion": 0}],
}
for idx, sig in enumerate(batch)
]
results = await _rpc_call_batch(SOLANA_PUBLIC_RPC, payloads)
for tx_data in results:
if tx_data:
_extract_mints_from_tx(tx_data, deployer, candidate_mints)
if not candidate_mints:
return None
verified: list[dict] = []
for mint, info in list(candidate_mints.items())[:200]:
verified_info = await _verify_and_enrich_mint(mint, deployer)
if verified_info:
verified_info["block_time"] = info.get("block_time")
verified.append(verified_info)
return verified if verified else None
def _extract_mints_from_tx(tx_data: dict, deployer: str, candidate_mints: dict[str, dict]):
"""Extract mint addresses from parsed transaction data."""
meta = tx_data.get("meta", {})
block_time = tx_data.get("blockTime")
pre_tokens = meta.get("preTokenBalances", [])
post_tokens = meta.get("postTokenBalances", [])
pre_map = {tb["mint"]: tb for tb in pre_tokens}
post_map = {tb["mint"]: tb for tb in post_tokens}
for mint, post_tb in post_map.items():
post_owner = post_tb.get("owner", "")
post_amount_raw = post_tb.get("uiTokenAmount", {}).get("amount", "0")
try:
post_amount = int(post_amount_raw)
except (ValueError, TypeError):
post_amount = 0
if post_owner != deployer:
continue
pre_tb = pre_map.get(mint)
pre_amount = 0
if pre_tb and pre_tb.get("uiTokenAmount"):
try:
pre_amount = int(pre_tb["uiTokenAmount"].get("amount", "0"))
except (ValueError, TypeError):
pre_amount = 0
if post_amount > pre_amount and (pre_amount == 0 or post_amount > pre_amount * 100):
if mint not in candidate_mints:
candidate_mints[mint] = {"block_time": block_time}
async def _verify_and_enrich_mint(mint: str, deployer: str) -> dict | None:
"""Verify mint authority == deployer and enrich with metadata via public RPC."""
account_info = await _solana_rpc("getAccountInfo", [mint, {"encoding": "base64"}])
if not account_info:
return None
raw_data = None
data_field = account_info.get("data")
if isinstance(data_field, list) and len(data_field) >= 1:
raw_data = data_field[0]
elif isinstance(data_field, str):
raw_data = data_field
if not raw_data:
return None
try:
raw = base64.b64decode(raw_data)
mint_authority = _decode_mint_authority(raw)
except Exception:
return None
if not mint_authority or mint_authority != deployer:
return None
supply = _decode_supply(raw)
decimals = _decode_decimals(raw)
name, symbol = await _fetch_token_metadata_public(mint)
return {
"mint": mint,
"name": name,
"symbol": symbol,
"decimals": decimals,
"supply": supply,
}
# ── SPL Mint data layout decoders (4 + 32 + 8 + 1 + 1 + 4 + 32 = 82 bytes) ──
def _decode_mint_authority(raw: bytes) -> str | None:
"""Decode mint authority from SPL mint account data.
Layout: option<u32>(4 bytes) + authority<Pubkey>(32 bytes)
"""
if len(raw) < 36:
return None
option = struct.unpack_from("<I", raw, 0)[0]
if option == 0:
return None
return base58.b58encode(raw[4:36]).decode("ascii")
def _decode_supply(raw: bytes) -> int:
"""Decode total supply from SPL mint account data (u64 LE at offset 36)."""
if len(raw) < 44:
return 0
return struct.unpack_from("<Q", raw, 36)[0]
def _decode_decimals(raw: bytes) -> int:
"""Decode decimals from SPL mint account data (u8 at offset 44)."""
if len(raw) < 45:
return 0
return raw[44]
async def _fetch_token_metadata_public(mint: str) -> tuple[str, str]:
"""Fetch token name/symbol from public RPC via getAsset (DAS) or Metaplex.
Tries Helius DAS getAsset first (works on some public RPCs that
support the DAS extension), falls back to Metaplex metadata parsing.
"""
try:
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": "getAsset",
"params": [mint],
}
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(SOLANA_PUBLIC_RPC[0], json=payload)
if r.status_code == 200:
result = r.json().get("result", {})
content = result.get("content", {}).get("metadata", {})
if content:
return content.get("name", "Unknown"), content.get("symbol", "???")
except Exception:
pass
try:
metadata_seed = b"metadata"
metadata_program = bytes(base58.b58decode("metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s"))
mint_pubkey = bytes(base58.b58decode(mint))
seeds = [metadata_seed, metadata_program, mint_pubkey]
import hashlib
pda_input = b"".join(seeds)
pda_hash = hashlib.sha256(pda_input).digest()
pda_info = await _solana_rpc(
"getAccountInfo",
[base58.b58encode(pda_hash).decode("ascii"), {"encoding": "base64"}],
rpc_timeout=8.0,
)
if pda_info:
raw_data = None
data_field = pda_info.get("data")
if isinstance(data_field, list) and len(data_field) >= 1:
raw_data = data_field[0]
elif isinstance(data_field, str):
raw_data = data_field
if raw_data:
raw = base64.b64decode(raw_data)
return _parse_metaplex_metadata(raw)
except Exception:
pass
return "Unknown", "???"
def _parse_metaplex_metadata(raw: bytes) -> tuple[str, str]:
"""Parse Metaplex token metadata name and symbol from raw account data.
Layout (after key + update_auth + mint): name_len(u32) + name + symbol_len(u32) + symbol
"""
try:
idx = 1 + 32 + 32
if idx + 4 > len(raw):
return "Unknown", "???"
name_len = struct.unpack_from("<I", raw, idx)[0]
idx += 4
name_end = idx + name_len
if name_end > len(raw):
return "Unknown", "???"
name = raw[idx:name_end].rstrip(b"\x00").decode("utf-8", errors="replace").strip()
idx = name_end
if idx + 4 > len(raw):
return name, "???"
symbol_len = struct.unpack_from("<I", raw, idx)[0]
idx += 4
symbol_end = idx + symbol_len
if symbol_end > len(raw):
return name, "???"
symbol = raw[idx:symbol_end].rstrip(b"\x00").decode("utf-8", errors="replace").strip()
return name or "Unknown", symbol or "???"
except Exception:
return "Unknown", "???"
# ═══════════════════════════════════════════════════════════════════════════════
# SOLANA — FUNDING SOURCE TRACING (public RPC)
# ═══════════════════════════════════════════════════════════════════════════════
async def _trace_funding_solana_public(address: str) -> list[dict] | None:
"""Trace Solana wallet funding sources using public RPC.
Strategy:
1. getSignaturesForAddress -> recent transaction signatures
2. getTransaction (jsonParsed) -> compute SOL balance deltas
3. For incoming SOL, the account with decreasing balance is the funder
"""
sigs = await _solana_rpc("getSignaturesForAddress", [address, {"limit": 100}])
if not sigs:
return None
funders: dict[str, dict] = {}
seen_sigs: set[str] = set()
for sig_entry in sigs[:50]:
sig = sig_entry.get("signature", "")
if not sig or sig in seen_sigs:
continue
seen_sigs.add(sig)
tx = await _solana_rpc(
"getTransaction",
[sig, {"encoding": "jsonParsed", "maxSupportedTransactionVersion": 0}],
)
if not tx:
continue
funder_info = _extract_sol_funder_from_tx(tx, address)
if funder_info:
addr = funder_info["address"]
if addr not in funders:
funders[addr] = funder_info
if len(funders) >= 50:
break
if not funders:
return None
result = list(funders.values())
result.sort(key=lambda x: x.get("amount_sol", 0), reverse=True)
return result
def _extract_sol_funder_from_tx(tx_data: dict, target: str) -> dict | None:
"""Extract SOL funder info from a parsed Solana transaction."""
try:
meta = tx_data.get("meta", {})
account_keys = tx_data.get("transaction", {}).get("message", {}).get("accountKeys", [])
pre_balances = meta.get("preBalances", [])
post_balances = meta.get("postBalances", [])
block_time = tx_data.get("blockTime")
target_idx = None
for i, acc in enumerate(account_keys):
key = acc.get("pubkey") if isinstance(acc, dict) else acc
if key == target:
target_idx = i
break
if target_idx is None or target_idx >= len(pre_balances):
return None
pre_bal = pre_balances[target_idx]
post_bal = post_balances[target_idx]
diff = post_bal - pre_bal
if diff <= 0:
return None
for i, acc in enumerate(account_keys):
if i == target_idx or i >= len(pre_balances):
continue
pre = pre_balances[i]
post = post_balances[i]
sent = pre - post
if sent > 0 and sent >= diff * 0.5:
key = acc.get("pubkey") if isinstance(acc, dict) else acc
return {
"address": key,
"amount_sol": sent / 1e9,
"tx_hash": tx_data.get("transaction", {}).get("signatures", [""])[0],
"block_time": block_time,
}
except Exception:
logging.getLogger(__name__).warning("swallowed exception", exc_info=True)
return None
# ═══════════════════════════════════════════════════════════════════════════════
# EVM — PUBLIC RPC PROVIDERS
# ═══════════════════════════════════════════════════════════════════════════════
async def _discover_deployer_tokens_evm(deployer: str, chain_id: int) -> list[dict] | None:
"""Discover tokens created by a deployer on EVM chains using public RPC.
Uses eth_getLogs with the ERC20 Transfer event signature.
A token creation is a Transfer from 0x0000...0000 (mint event).
"""
zero_addr = "0x0000000000000000000000000000000000000000"
topics = [
ERC20_TRANSFER_TOPIC,
"0x" + zero_addr[2:].rjust(64, "0"),
]
params = [
{
"address": None,
"fromBlock": "0x0",
"toBlock": "latest",
"topics": topics,
}
]
logs = await _evm_rpc(chain_id, "eth_getLogs", params, rpc_timeout=30.0)
if not logs:
return None
contract_addresses: set[str] = set()
for log_entry in logs[:500]:
addr = log_entry.get("address", "")
if addr and addr.lower() != zero_addr.lower():
contract_addresses.add(addr.lower())
if not contract_addresses:
return None
tokens: list[dict] = []
for addr in list(contract_addresses)[:100]:
name, symbol, decimals = await _fetch_erc20_metadata_public(addr, chain_id)
tokens.append(
{
"mint": addr,
"name": name,
"symbol": symbol,
"decimals": decimals,
}
)
return tokens if tokens else None
async def _fetch_erc20_metadata_public(contract: str, chain_id: int) -> tuple[str, str, int]:
"""Fetch ERC20 token metadata using public RPC (eth_call)."""
name = "Unknown"
symbol = "???"
decimals = 18
calls = [
("name", "0x06fdde03"),
("symbol", "0x95d89b41"),
("decimals", "0x313ce567"),
]
for label, sig_hash in calls:
try:
result = await _evm_rpc(
chain_id,
"eth_call",
[{"to": contract, "data": sig_hash}, "latest"],
rpc_timeout=10.0,
)
if result and result != "0x":
hex_part = result[2:] if result.startswith("0x") else result
if label == "decimals":
try:
decimals = int(result, 16)
except (ValueError, TypeError):
pass
else:
try:
decoded = (
bytes.fromhex(hex_part)
.decode("utf-8", errors="replace")
.rstrip("\x00")
.strip()
)
if label == "name":
name = decoded or name
else:
symbol = decoded or symbol
except Exception:
pass
except Exception:
continue
return name, symbol, decimals
# ═══════════════════════════════════════════════════════════════════════════════
# EVM — FUNDING SOURCE TRACING (public RPC + Etherscan accelerator)
# ═══════════════════════════════════════════════════════════════════════════════
async def _trace_funding_evm_public(address: str, chain_id: int) -> list[dict] | None:
"""Trace EVM wallet funding sources using public RPC and optional Etherscan.
Primary: eth_getLogs for incoming ETH transfers (Transfer event with
no indexed 'from' topic, or using native ETH transfer detection).
Acceleration: Etherscan API if ETHERSCAN_API_KEY is set (much faster).
"""
if ETHERSCAN_KEY:
result = await _trace_funding_evm_etherscan(address, chain_id)
if result:
return result
addr_padded = address.lower().lstrip("0x").rjust(64, "0")
params = [
{
"address": None,
"fromBlock": "0x0",
"toBlock": "latest",
"topics": [None, None, "0x" + addr_padded],
}
]
logs = await _evm_rpc(chain_id, "eth_getLogs", params, rpc_timeout=30.0)
if not logs:
return None
funders: dict[str, dict] = {}
for log_entry in logs[:200]:
topics_list = log_entry.get("topics", [])
if len(topics_list) < 3:
continue
from_addr = "0x" + topics_list[1][-40:]
zero_addr = "0x" + "0" * 40
if from_addr == zero_addr:
continue
hex_data = log_entry.get("data", "0x0")
try:
amount_wei = int(hex_data, 16)
except (ValueError, TypeError):
amount_wei = 0
if from_addr not in funders:
funders[from_addr] = {
"address": from_addr,
"amount_eth": amount_wei / 1e18,
"tx_hash": log_entry.get("transactionHash", ""),
"block_number": int(log_entry.get("blockNumber", "0x0"), 16),
}
if not funders:
return None
result = list(funders.values())
result.sort(key=lambda x: x.get("amount_eth", 0), reverse=True)
for f in result:
f["source_type"] = "unknown"
return result[:50]
async def _trace_funding_evm_etherscan(address: str, chain_id: int) -> list[dict] | None:
"""Use Etherscan API for EVM funding trace (optional accelerator)."""
if not ETHERSCAN_KEY:
return None
api_urls = {
1: "https://api.etherscan.io/api",
56: "https://api.bscscan.com/api",
137: "https://api.polygonscan.com/api",
42161: "https://api.arbiscan.io/api",
10: "https://api-optimistic.etherscan.io/api",
8453: "https://api.basescan.org/api",
}
api_url = api_urls.get(chain_id)
if not api_url:
return None
try:
async with httpx.AsyncClient(timeout=15) as client:
r = await client.get(
api_url,
params={
"module": "account",
"action": "txlist",
"address": address,
"startblock": 0,
"endblock": 99999999,
"page": 1,
"offset": 100,
"sort": "asc",
"apikey": ETHERSCAN_KEY,
},
)
if r.status_code != 200:
return None
data = r.json()
if data.get("status") != "1":
return None
txs = data.get("result", [])
funders: list[dict] = []
seen: set[str] = set()
target_lower = address.lower()
for tx in txs:
to_addr = (tx.get("to") or "").lower()
from_addr = (tx.get("from") or "").lower()
try:
value = int(tx.get("value", 0))
except (ValueError, TypeError):
value = 0
if to_addr == target_lower and from_addr not in seen and value > 0:
seen.add(from_addr)
funders.append(
{
"address": tx.get("from"),
"amount_eth": value / 1e18,
"tx_hash": tx.get("hash"),
"block_time": int(tx.get("timeStamp", 0)),
"source_type": "unknown",
}
)
return funders[:50] if funders else None
except Exception:
logger.warning("Etherscan funding trace failed", exc_info=True)
return None
# ═══════════════════════════════════════════════════════════════════════════════
# HELIUS ACCELERATOR — optional, only if key is available
# ═══════════════════════════════════════════════════════════════════════════════
async def _discover_deployer_tokens_helius(deployer: str) -> list[dict] | None:
"""Helius-accelerated deployer token discovery. Only runs if key is set."""
if not HELIUS_API_KEY:
return None
try:
async with httpx.AsyncClient(timeout=30) as client:
r = await client.post(
f"{HELIUS_RPC}?api-key={HELIUS_API_KEY}",
json={
"jsonrpc": "2.0",
"id": "dt-1",
"method": "getSignaturesForAddress",
"params": [deployer, {"limit": 500}],
},
)
if r.status_code != 200:
return None
signatures = r.json().get("result", [])
if not signatures:
return None
token_mints: set[str] = set()
token_meta: dict[str, dict] = {}
for sig in signatures[:80]:
tx_sig = sig.get("signature")
if not tx_sig:
continue
tr = await client.post(
f"{HELIUS_RPC}?api-key={HELIUS_API_KEY}",
json={
"jsonrpc": "2.0",
"id": "tx-1",
"method": "getTransaction",
"params": [
tx_sig,
{"encoding": "jsonParsed", "maxSupportedTransactionVersion": 0},
],
},
)
if tr.status_code != 200:
continue
tx_data = tr.json().get("result")
if not tx_data:
continue
meta = tx_data.get("meta", {})
block_time = tx_data.get("blockTime")
for tb in meta.get("postTokenBalances", []):
owner = tb.get("owner", "")
mint = tb.get("mint", "")
if owner == deployer and mint:
token_mints.add(mint)
token_meta[mint] = {"block_time": block_time}
tokens = []
for mint in list(token_mints)[:200]:
name, symbol = await _fetch_token_metadata_public(mint)
tokens.append(
{
"mint": mint,
"name": name,
"symbol": symbol,
"decimals": 0,
"block_time": token_meta.get(mint, {}).get("block_time"),
}
)
return tokens if tokens else None
except Exception:
logger.warning("Helius deployer token fetch failed", exc_info=True)
return None
# ═══════════════════════════════════════════════════════════════════════════════
# CLASSIFICATION HELPERS
# ═══════════════════════════════════════════════════════════════════════════════
def _classify_funder(funder: dict | None) -> str:
"""Classify a funding source."""
if not funder:
return "unknown"
return funder.get("source_type", "unknown")
def _compute_velocity(token_count: int) -> str:
"""Classify deployment velocity."""
if token_count > 50:
return "high"
elif token_count > 10:
return "medium"
else:
return "low"
def _compute_deployment_frequency(tokens: list[dict]) -> str:
"""Compute deployment frequency from token list block times."""
block_times = [t.get("block_time") for t in tokens if t.get("block_time")]
if len(block_times) < 2:
return "insufficient_data"
block_times.sort()
time_span = max(block_times) - min(block_times)
if time_span <= 0:
return "burst"
hours = time_span / 3600
if hours <= 0:
return "burst"
tokens_per_hour = len(block_times) / hours
if tokens_per_hour > 10:
return "burst"
elif tokens_per_hour > 1:
return "high"
elif tokens_per_hour > 0.1:
return "moderate"
else:
return "low"
def _empty_deployer_response(deployer: str, chain: str) -> dict:
return {
"deployer": deployer,
"chain": chain,
"tokens": [],
"token_count": 0,
"patterns": {"velocity": "none", "deployment_frequency": "none"},
"source": "public_rpc",
}
def _empty_funding_response() -> dict:
return {
"funders": [],
"first_funder": None,
"funding_tx_count": 0,
"source_type": "unknown",
"source": "public_rpc",
}

View file

@ -13,6 +13,7 @@ __all__ = [
"_passthrough_trending",
]
async def _passthrough_market_overview(**kwargs) -> dict | None:
"""Market overview from our own /api/v1/content/market-overview."""
try:
@ -64,14 +65,16 @@ async def _passthrough_alerts(**kwargs) -> dict | None:
return {"count": count, "alerts": recent}
except Exception:
logging.getLogger(__name__).warning("swallowed exception", exc_info=True)
return {"count": 0, "alerts": []}
return None
async def _passthrough_scanner(address: str = "", chain: str = "solana", **kw) -> dict | None:
"""SENTINEL scanner - our own token security analysis."""
try:
async with httpx.AsyncClient(timeout=30) as c:
r = await c.post("http://localhost:8000/api/v1/token/scan", json={"address": address, "chain": chain})
r = await c.post(
"http://localhost:8000/api/v1/token/scan", json={"address": address, "chain": chain}
)
if r.status_code == 200:
return r.json()
except Exception:

View file

@ -1,13 +1,14 @@
"""DataBus providers - news/social data providers."""
import datetime
import logging
import os
from datetime import UTC, datetime
import httpx
__all__ = ["_coindesk_news", "_messari_news", "_santiment_dev_activity"]
async def _messari_news(limit: int = 20, **kw) -> dict | None:
"""Messari News API - curated crypto news with per-asset sentiment scores."""
api_key = kw.get("api_key", "") or os.getenv("MESSARI_API_KEY", "")
@ -16,7 +17,9 @@ async def _messari_news(limit: int = 20, **kw) -> dict | None:
try:
headers = {"X-Messari-API-Key": api_key, "Accept": "application/json"}
async with httpx.AsyncClient(timeout=15) as c:
r = await c.get("https://api.messari.io/news/v1/news/feed", params={"limit": limit}, headers=headers)
r = await c.get(
"https://api.messari.io/news/v1/news/feed", params={"limit": limit}, headers=headers
)
if r.status_code == 200:
data = r.json()
if data.get("data"):
@ -25,7 +28,9 @@ async def _messari_news(limit: int = 20, **kw) -> dict | None:
for item in data["data"]:
assets = [a.get("symbol", "Unknown") for a in item.get("assets", [])]
sentiment = item.get("sentiment", [])
avg_sentiment = sum(s.get("sentiment", 0) for s in sentiment) / max(len(sentiment), 1)
avg_sentiment = sum(s.get("sentiment", 0) for s in sentiment) / max(
len(sentiment), 1
)
articles.append(
{
@ -70,7 +75,7 @@ async def _coindesk_news(limit: int = 20, **kw) -> dict | None:
"url": item.get("url", ""),
"source": item.get("source", "CoinDesk"),
"published_at": datetime.fromtimestamp(
item.get("published_on", 0), tz=timezone.utc # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue
item.get("published_on", 0), tz=UTC
).isoformat(),
"description": item.get("body", ""),
"category": item.get("categories", "news").lower(),
@ -116,7 +121,9 @@ async def _santiment_dev_activity(project_slug: str = "bitcoin", **kw) -> dict |
data = r.json()
return {
"project": project_slug,
"dev_activity": data.get("data", {}).get("getMetric", {}).get("timeseriesData", []),
"dev_activity": data.get("data", {})
.get("getMetric", {})
.get("timeseriesData", []),
"source": "santiment",
}
except Exception:

View file

@ -98,8 +98,8 @@ class SecurityGate:
provided = request.headers.get("X-Admin-Key", "")
provided = provided or request.query_params.get("admin_key", "")
if not ADMIN_KEY:
logger.warning("ADMIN_API_KEY not set, allowing admin access")
return True
logger.critical("ADMIN_API_KEY not set, rejecting admin access")
return False
if provided and provided == ADMIN_KEY:
return True
logger.warning(f"Admin access denied for data_type={data_type}")
@ -142,7 +142,9 @@ class SecurityGate:
sanitized[k] = SecurityGate.sanitize_response(v, data_type, access_level)
elif isinstance(v, list):
sanitized[k] = [
SecurityGate.sanitize_response(item, data_type, access_level) if isinstance(item, dict) else item
SecurityGate.sanitize_response(item, data_type, access_level)
if isinstance(item, dict)
else item
for item in v
]
else:

View file

@ -275,7 +275,7 @@ def _get_hmac_key() -> bytes:
"""Load HMAC signing key from environment. Never hardcoded."""
key = os.getenv("X402_HMAC_KEY", "")
if not key:
key = os.getenv("ADMIN_API_KEY", os.getenv("WP_ADMIN_KEY", "dev-key-only-for-local"))
key = os.getenv("ADMIN_API_KEY", os.getenv("WP_ADMIN_KEY", ""))
if "dev" not in key:
logging.getLogger("wp.x402").warning("X402_HMAC_KEY not set, using ADMIN_API_KEY")
return key.encode()

View file

@ -22,37 +22,64 @@ log = logging.getLogger(__name__)
def register_middleware(app) -> None:
"""Register all middleware. Each registration is isolated."""
_try_register_cors(app)
_try_register_auth(app)
_try_register_security_headers(app)
_try_register_prometheus(app)
_try_register_cost_tracking(app)
_try_register_tracing(app)
_try_register_rate_limit(app)
def _try_register_cors(app) -> None:
"""T19 - CORS hardening with strict allowlist."""
"""T19 - CORS hardening with config-driven allowlist."""
try:
from fastapi.middleware.cors import CORSMiddleware
ALLOWED_ORIGINS = [ # noqa: N806 - list literal in function, uppercase intentional for visibility
"https://rugmunch.io",
"https://www.rugmunch.io",
"https://mcp.rugmunch.io",
"https://x402.rugmunch.io",
"https://rugcharts.io",
"https://rugmaps.io",
"http://localhost:5173",
"http://localhost:3000",
]
from app.config import settings
origins = settings.cors_origins
if not origins or origins == ["*"]:
log.warning(
"middleware_cors_wildcard origins=%s — override with CORS_ORIGINS env",
origins,
)
app.add_middleware(
CORSMiddleware,
allow_origins=ALLOWED_ORIGINS,
allow_origins=origins,
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Authorization", "Content-Type", "X-Request-ID", "X-Payment"],
)
log.info("middleware_registered name=cors")
log.info("middleware_registered name=cors origins=%d", len(origins))
except Exception as exc:
log.warning("middleware_skipped name=cors err=%s", exc)
log.critical(
"middleware_cors_fail_closed err=%s — falling back to localhost only",
exc,
)
try:
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173", "http://localhost:3000"],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Authorization", "Content-Type", "X-Request-ID", "X-Payment"],
)
log.info("middleware_registered name=cors_fallback origins=localhost_only")
except Exception as exc2:
log.critical("middleware_cors_catastrophic_fail err=%s", exc2)
def _try_register_auth(app) -> None:
"""Register AuthMiddleware for API key verification on write endpoints."""
try:
from app.core.auth import AuthMiddleware
app.add_middleware(AuthMiddleware)
log.info("middleware_registered name=auth")
except Exception as exc:
log.warning("middleware_skipped name=auth err=%s", exc)
def _try_register_security_headers(app) -> None:
@ -112,3 +139,55 @@ def _try_register_tracing(app) -> None:
log.info("middleware_registered name=tracing")
except Exception as exc:
log.warning("middleware_skipped name=tracing err=%s", exc)
def _try_register_rate_limit(app) -> None:
"""Register rate limiting middleware with Redis backend.
General endpoints: 100 req/min per client. Admin endpoints: 20 req/min.
Degrades gracefully (pass-through) when Redis is unavailable.
"""
try:
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
from app.core.redis import get_redis
from app.middleware.rate_limit import RateLimiter
redis_client = get_redis()
limiter = RateLimiter(redis_client)
class RateLimitMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
path = request.url.path
if path.startswith(("/health", "/docs", "/redoc", "/openapi.json", "/metrics")):
return await call_next(request)
client_ip = request.client.host if request.client else "unknown"
api_key = request.headers.get("X-API-Key", "")
identifier = api_key or client_ip
if path.startswith("/api/v1/admin"):
key = f"rmi:rl:admin:{identifier}"
limit = 20
else:
key = f"rmi:rl:general:{identifier}"
limit = 100
allowed, remaining = limiter.check(key, limit, window=60)
if not allowed:
return JSONResponse(
status_code=429,
content={"detail": "Rate limit exceeded", "retry_after": 60},
headers={"Retry-After": "60", "X-RateLimit-Remaining": "0"},
)
response = await call_next(request)
response.headers["X-RateLimit-Remaining"] = str(remaining)
return response
app.add_middleware(RateLimitMiddleware)
log.info("middleware_registered name=rate_limit redis=%s", redis_client is not None)
except Exception as exc:
log.warning("middleware_skipped name=rate_limit err=%s", exc)

View file

@ -57,7 +57,8 @@ ROUTER_MODULES: Final[list[str]] = [
#
# Routers (24) - total ~140 routes added:
"app.routers.bulletin", # /api/v1/bulletin/* (21 routes, supabase)
"app.routers.bulletin_board", # /api/v1/bulletin-board/* (22 routes, app/bulletin_board.py)
# "app.routers.bulletin_board" -- DEAD CODE, moved to bulletin.py.bak
"app.routers.admin.bulletin", # /api/v1/admin/bulletin/* (darkroom admin — RBAC auth)
"app.routers.intelligence_router", # /api/v1/intelligence/* (19 routes, n8n-fed)
"app.routers.intelligence_panel", # /api/v1/intelligence/* (4 routes, /feed /latest /crypto-fear-index)
"app.routers.chat", # /api/v1/chat/* (5 routes, AI chat surface)
@ -102,7 +103,6 @@ ROUTER_MODULES: Final[list[str]] = [
"app.routers.developer_routes", # /api/v1/developer/*
"app.routers.investigator_routes", # /api/v1/investigators/*
"app.routers.profile_routes", # /api/v1/profile/*
# DEFERRED Wave 3 (2):
# "app.routers.unified_wallet_scanner" — NO `router` APIRouter attribute.
# Module exposes `get_wallet_scanner()` consumed by unified_scanner_router.

View file

@ -0,0 +1 @@
"""Admin routers — darkroom backend endpoints."""

View file

@ -0,0 +1,436 @@
"""
Bulletin Board Admin Router Darkroom Backend
================================================
Admin endpoints for managing the community bulletin board.
All endpoints require admin authentication via X-Admin-Session header.
Permissions:
- MODERATOR: view reports, moderate posts, view bots
- ADMIN: all moderator + manage bots, view moderation log
- SUPERADMIN: everything including dangerous ops
Mounted at: /api/v1/admin/bulletin/*
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field
from app.domains.admin.core import AdminRole, require_admin
from app.services.supabase_service import (
create_bulletin_post,
get_bot_fee_status,
get_bulletin_posts,
get_bulletin_stats,
get_comments,
get_moderation_log,
get_reports,
list_bots,
log_moderation,
)
logger = logging.getLogger("admin.bulletin")
router = APIRouter(prefix="/api/v1/admin/bulletin", tags=["admin-bulletin"])
# ═══════════════════════════════════════════════════════════════
# MODELS
# ═══════════════════════════════════════════════════════════════
class AdminPostIn(BaseModel):
title: str = Field(..., min_length=1, max_length=300)
content: str = Field(..., min_length=1, max_length=10000)
category: str = "announcement"
pinned: bool = False
tags: list[str] = []
class ModerationAction(BaseModel):
admin_id: str
action: str # remove, pin, unpin, lock, unlock, restore, ban_user
target_type: str # post, user, bot
target_id: str
reason: str | None = None
class BotManageAction(BaseModel):
bot_id: str
action: str # ban, unban, approve, reject
# ═══════════════════════════════════════════════════════════════
# DASHBOARD — overview stats for admin panel
# ═══════════════════════════════════════════════════════════════
@router.get("/dashboard")
async def admin_dashboard(request: Request):
"""Overview dashboard for the bulletin board admin panel."""
admin = await require_admin(request, "dashboard.read", AdminRole.MODERATOR)
stats = await get_bulletin_stats()
open_reports = await get_reports(status="open", limit=100)
active_bots = await list_bots(status="active", limit=50)
return {
"stats": stats,
"open_reports": len(open_reports) if open_reports else 0,
"active_bots": len(active_bots) if active_bots else 0,
"admin": {"name": admin.get("email", "unknown"), "role": admin.get("role", "unknown")},
}
# ═══════════════════════════════════════════════════════════════
# POSTS — full control over bulletin posts
# ═══════════════════════════════════════════════════════════════
@router.get("/posts")
async def admin_list_posts(
request: Request,
category: str = Query(None),
status: str = Query(None),
limit: int = Query(50, ge=1, le=200),
cursor: str = Query(None),
):
"""List all bulletin posts with full admin visibility."""
await require_admin(request, "content.read", AdminRole.MODERATOR)
posts = await get_bulletin_posts(category=category, sort="created_at.desc", limit=limit)
result = posts or []
return {
"posts": result,
"total": len(result),
"next_cursor": result[-1]["id"] if len(result) == limit else None,
}
@router.post("/posts")
async def admin_create_post(request: Request, post: AdminPostIn):
"""Create an official bulletin post as admin."""
admin = await require_admin(request, "content.write", AdminRole.MODERATOR)
result = await create_bulletin_post(
title=post.title,
content=post.content,
author_id=admin["admin_id"],
category=post.category,
tags=post.tags,
source="admin_darkroom",
)
if not result:
raise HTTPException(status_code=500, detail="Failed to create post")
return result
@router.post("/posts/{post_id}/moderate")
async def admin_moderate_post(request: Request, post_id: str, action: ModerationAction):
"""Moderate a bulletin post — pin, unpin, lock, unlock, remove, restore."""
admin = await require_admin(request, "content.write", AdminRole.MODERATOR)
import contextlib
from app.services.supabase_service import _get_supabase
supabase = await _get_supabase()
status_update: dict = {}
if action.action == "remove":
status_update = {"removed": True, "removed_reason": action.reason}
elif action.action == "restore":
status_update = {"removed": False, "removed_reason": None}
elif action.action == "pin":
status_update = {"pinned": True}
elif action.action == "unpin":
status_update = {"pinned": False}
elif action.action == "lock":
status_update = {"locked": True}
elif action.action == "unlock":
status_update = {"locked": False}
else:
raise HTTPException(status_code=400, detail=f"Unknown action: {action.action}")
with contextlib.suppress(Exception):
await supabase.table("bulletin_posts").update(status_update).eq("id", post_id).execute()
await log_moderation(
admin_id=admin["admin_id"],
action=action.action,
target_type="post",
target_id=post_id,
reason=action.reason,
)
return {"status": "ok", "post_id": post_id, "action": action.action}
@router.delete("/posts/{post_id}")
async def admin_delete_post(request: Request, post_id: str):
"""Permanently delete a bulletin post. SUPERADMIN only."""
await require_admin(request, "content.write", AdminRole.SUPERADMIN)
import contextlib
from app.services.supabase_service import _get_supabase
supabase = await _get_supabase()
with contextlib.suppress(Exception):
await supabase.table("bulletin_posts").delete().eq("id", post_id).execute()
await log_moderation(
admin_id="superadmin",
action="delete",
target_type="post",
target_id=post_id,
reason="Permanent deletion",
)
return {"status": "deleted", "post_id": post_id}
# ═══════════════════════════════════════════════════════════════
# COMMENTS — moderation
# ═══════════════════════════════════════════════════════════════
@router.get("/posts/{post_id}/comments")
async def admin_get_comments(request: Request, post_id: str, limit: int = Query(100)):
"""Get all comments for a post with full admin visibility."""
await require_admin(request, "content.read", AdminRole.MODERATOR)
comments = await get_comments(post_id, limit)
return {"comments": comments or [], "post_id": post_id}
@router.post("/comments/{comment_id}/moderate")
async def admin_moderate_comment(request: Request, comment_id: str, action: str = Query(...)):
"""Moderate a comment — approve or remove."""
await require_admin(request, "content.write", AdminRole.MODERATOR)
import contextlib
from app.services.supabase_service import _get_supabase
supabase = await _get_supabase()
status = "approved" if action == "approve" else "removed"
with contextlib.suppress(Exception):
await (
supabase.table("bulletin_comments")
.update({"status": status})
.eq("id", comment_id)
.execute()
)
await log_moderation(
admin_id="moderator",
action=f"comment_{action}",
target_type="comment",
target_id=comment_id,
)
return {"status": "ok", "comment_id": comment_id, "action": action}
# ═══════════════════════════════════════════════════════════════
# REPORTS — manage user reports
# ═══════════════════════════════════════════════════════════════
@router.get("/reports")
async def admin_list_reports(
request: Request,
status: str = Query(None),
limit: int = Query(100, ge=1, le=500),
):
"""List all reports with optional status filter."""
await require_admin(request, "content.read", AdminRole.MODERATOR)
reports = await get_reports(status=status, limit=limit)
return {"reports": reports or [], "total": len(reports) if reports else 0}
@router.post("/reports/{report_id}/resolve")
async def admin_resolve_report(
request: Request, report_id: str, resolution: str = Query("resolved")
):
"""Resolve a report (mark as resolved or dismissed)."""
await require_admin(request, "content.write", AdminRole.MODERATOR)
import contextlib
from app.services.supabase_service import _get_supabase
supabase = await _get_supabase()
with contextlib.suppress(Exception):
await (
supabase.table("bulletin_reports")
.update({"status": resolution})
.eq("id", report_id)
.execute()
)
await log_moderation(
admin_id="moderator",
action=f"report_{resolution}",
target_type="report",
target_id=report_id,
)
return {"status": "ok", "report_id": report_id, "resolution": resolution}
# ═══════════════════════════════════════════════════════════════
# BOTS — manage bulletin board bots
# ═══════════════════════════════════════════════════════════════
@router.get("/bots")
async def admin_list_bots(request: Request, status: str = Query(None), limit: int = Query(100)):
"""List all registered bots."""
await require_admin(request, "content.read", AdminRole.MODERATOR)
bots = await list_bots(status=status, limit=limit)
return {"bots": bots or [], "total": len(bots) if bots else 0}
@router.get("/bots/{bot_id}")
async def admin_bot_detail(request: Request, bot_id: str):
"""Get full details for a bot including fee status."""
await require_admin(request, "content.read", AdminRole.MODERATOR)
fee_status = await get_bot_fee_status(bot_id)
return {"bot_id": bot_id, "fee_status": fee_status}
@router.post("/bots/{bot_id}/manage")
async def admin_manage_bot(request: Request, bot_id: str, action: BotManageAction):
"""Ban, unban, approve, or reject a bot."""
await require_admin(request, "content.write", AdminRole.ADMIN)
import contextlib
from app.services.supabase_service import _get_supabase
supabase = await _get_supabase()
status = (
"active"
if action.action == "approve"
else ("banned" if action.action == "ban" else "inactive")
)
with contextlib.suppress(Exception):
await (
supabase.table("bot_registrations")
.update({"status": status})
.eq("bot_user_id", bot_id)
.execute()
)
await log_moderation(
admin_id="admin",
action=f"bot_{action.action}",
target_type="bot",
target_id=bot_id,
)
return {"status": "ok", "bot_id": bot_id, "action": action.action}
# ═══════════════════════════════════════════════════════════════
# MODERATION LOG — audit trail
# ═══════════════════════════════════════════════════════════════
@router.get("/moderation-log")
async def admin_moderation_log(request: Request, limit: int = Query(100)):
"""View the full moderation audit trail."""
await require_admin(request, "logs.read", AdminRole.ADMIN)
log = await get_moderation_log(limit=limit)
return {"log": log or [], "total": len(log) if log else 0}
# ═══════════════════════════════════════════════════════════════
# USERS — ban/unban bulletin users
# ═══════════════════════════════════════════════════════════════
@router.post("/users/{user_id}/ban")
async def admin_ban_user(
request: Request, user_id: str, reason: str = Query("Violation of community guidelines")
):
"""Ban a user from the bulletin board."""
await require_admin(request, "users.ban", AdminRole.MODERATOR)
import contextlib
from app.services.supabase_service import _get_supabase
supabase = await _get_supabase()
with contextlib.suppress(Exception):
await (
supabase.table("profiles")
.update({"banned": True, "ban_reason": reason})
.eq("id", user_id)
.execute()
)
await log_moderation(
admin_id="moderator",
action="ban_user",
target_type="user",
target_id=user_id,
reason=reason,
)
return {"status": "banned", "user_id": user_id, "reason": reason}
@router.post("/users/{user_id}/unban")
async def admin_unban_user(request: Request, user_id: str):
"""Unban a user from the bulletin board."""
await require_admin(request, "users.ban", AdminRole.MODERATOR)
import contextlib
from app.services.supabase_service import _get_supabase
supabase = await _get_supabase()
with contextlib.suppress(Exception):
await (
supabase.table("profiles")
.update({"banned": False, "ban_reason": None})
.eq("id", user_id)
.execute()
)
await log_moderation(
admin_id="moderator",
action="unban_user",
target_type="user",
target_id=user_id,
)
return {"status": "unbanned", "user_id": user_id}
# ═══════════════════════════════════════════════════════════════
# HEALTH
# ═══════════════════════════════════════════════════════════════
@router.get("/health")
async def admin_bulletin_health():
"""Health check for the admin bulletin module."""
return {
"status": "ok",
"auth_system": "domain_admin_rbac",
"permissions_required": {
"dashboard": "MODERATOR + dashboard.read",
"posts_write": "MODERATOR + content.write",
"posts_delete": "SUPERADMIN + content.write",
"bots_manage": "ADMIN + content.write",
"users_ban": "MODERATOR + users.ban",
"moderation_log": "ADMIN + logs.read",
},
}

View file

@ -22,7 +22,9 @@ router = APIRouter(prefix="/api/v1/admin", tags=["admin-control"])
# ── Auth helper ──
async def _verify_admin(request: Request):
"""Verify admin access with API key."""
admin_key = os.getenv("ADMIN_API_KEY", "dev-key-change-me")
admin_key = os.getenv("ADMIN_API_KEY", "")
if not admin_key:
raise HTTPException(status_code=401, detail="Admin API key not configured")
key = request.headers.get("X-Admin-Key", "")
if key != admin_key:
raise HTTPException(status_code=401, detail="Invalid admin key")

View file

@ -31,7 +31,9 @@ router = APIRouter(prefix="/api/v1/admin", tags=["admin-extensions"])
# ── Auth helper ──
async def _verify_admin(request: Request):
"""Verify admin access with API key."""
admin_key = os.getenv("ADMIN_API_KEY", "dev-key-change-me")
admin_key = os.getenv("ADMIN_API_KEY", "")
if not admin_key:
raise HTTPException(status_code=401, detail="Admin API key not configured")
key = request.headers.get("X-Admin-Key", "")
if key != admin_key:
raise HTTPException(status_code=401, detail="Invalid admin key")

View file

@ -1,25 +1,25 @@
#!/usr/bin/env python3
"""
Bulletin Board & Badge API Router
Refactored to use supabase_service for clean, typed database access.
Includes bot registration, fee tracking, and bot-specific endpoints.
Uses direct Supabase REST API via httpx (no supabase-py dependency issues).
All database operations routed through supabase_service.
"""
import json
import logging
import os
from datetime import datetime
import re as _re
from datetime import UTC, datetime
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
import contextlib # noqa: E402
from app.services.supabase_service import ( # noqa: E402
from app.services.supabase_service import (
_get,
_patch,
_post,
award_badge,
check_health,
create_alert,
create_bulletin_post,
get_alerts,
@ -30,64 +30,73 @@ from app.services.supabase_service import ( # noqa: E402
vote_post,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/bulletin")
# ── Security ──────────────────────────────────────────────────
def _sanitize(text: str) -> str:
if not text:
return ""
text = _re.sub(r"<[^>]+>", "", str(text))
return text.strip()
# ── Auth ──────────────────────────────────────────────────────
# Lazy auth dependency - avoids importing jose+bcrypt at module load
async def _require_public_profile(request: Request):
from app.domains.auth import require_public_profile
return await require_public_profile(request)
router = APIRouter(prefix="/api/v1/bulletin")
# ── Migration check (D6) ─────────────────────────────────────
# ── Supabase REST helpers ──────────────────────────────────────
try:
import asyncio as _asyncio
async def _verify_tables():
await _get("bulletin_posts", limit=1)
logger.info("Bulletin board tables verified")
_verify_task = _asyncio.ensure_future(_verify_tables()) # noqa: RUF006
except Exception:
logger.warning("Bulletin board tables may not exist — some features will be degraded")
def _sb():
"""Return (url, headers) for direct Supabase REST API."""
key = os.environ["SUPABASE_SERVICE_KEY"]
url = os.environ["SUPABASE_URL"]
return url, {
"apikey": key,
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
"Prefer": "return=representation",
}
def _sb_get(table: str, params: dict | None = None) -> list:
url, headers = _sb()
r = httpx.get(f"{url}/rest/v1/{table}", headers=headers, params=params, timeout=10)
return r.json() if r.status_code == 200 else []
def _sb_post(table: str, data: dict) -> list:
url, headers = _sb()
r = httpx.post(f"{url}/rest/v1/{table}", headers=headers, json=data, timeout=10)
return r.json() if r.status_code in (200, 201) else []
def _sb_patch(table: str, data: dict, match: dict) -> list:
url, headers = _sb()
params = "&".join(f"{k}=eq.{v}" for k, v in match.items())
r = httpx.patch(f"{url}/rest/v1/{table}?{params}", headers=headers, json=data, timeout=10)
return r.json() if r.status_code == 200 else []
# ── Models ──────────────────────────────────────────────────
# ── Models ────────────────────────────────────────────────────
class BulletinPostIn(BaseModel):
title: str = Field(..., min_length=1, max_length=300)
content: str = Field(..., min_length=1, max_length=10000)
category: str = "discussion"
tags: list[str] = []
chain: str | None = None
risk_score: int | None = None
class BulletinPostOut(BaseModel):
id: str
title: str
content: str
author_id: str
category: str = "discussion"
author: dict | None = None
category: str
created_at: str
upvotes: int = 0
downvotes: int = 0
comment_count: int = 0
views: int = 0
pinned: bool = False
tags: list[str] = []
chain: str | None = None
risk_score: int | None = None
tags: list[str] | None = None
source: str | None = None
is_bot: bool = False
bot_id: str | None = None
class AlertIn(BaseModel):
@ -112,7 +121,46 @@ class BotRegistrationIn(BaseModel):
allowed_categories: list[str] = []
# ── Bulletin Posts ──────────────────────────────────────────
class BotPostIn(BaseModel):
title: str = Field(..., min_length=1, max_length=300)
content: str = Field(..., min_length=1, max_length=10000)
author_id: str
category: str = "discussion"
tags: list[str] = []
chain: str | None = None
risk_score: int | None = None
class CommentIn(BaseModel):
content: str
class ReportIn(BaseModel):
post_id: int
reporter_id: str
reason: str
details: str | None = None
category: str = "other"
class ModerationActionIn(BaseModel):
admin_id: str
action: str
target_type: str
target_id: str
reason: str | None = None
# ── Shared helpers ────────────────────────────────────────────
BOT_FEE_USD = 1.00
def _now_iso() -> str:
return datetime.now(UTC).isoformat()
# ── Bulletin Posts ────────────────────────────────────────────
@router.get("/latest")
@ -120,12 +168,28 @@ async def latest_posts(
category: str = Query(None),
sort: str = Query("created_at.desc"),
limit: int = Query(20, ge=1, le=100),
cursor: str = Query(None),
include_bots: bool = Query(True),
):
posts = await get_bulletin_posts(category=category, sort=sort, limit=limit)
if cursor:
supabase_url = os.environ.get("SUPABASE_URL", "")
key = (
os.environ.get("SUPABASE_SERVICE_KEY")
or os.environ.get("SUPABASE_SERVICE_ROLE_KEY")
or ""
)
headers = {"apikey": key, "Authorization": f"Bearer {key}"}
url = f"{supabase_url}/rest/v1/bulletin_posts?select=*&order={sort}&limit={limit}"
if category and category != "all":
url += f"&category=eq.{category}"
url += f"&id=gt.{cursor}"
async with httpx.AsyncClient(timeout=15) as client:
r = await client.get(url, headers=headers)
posts = r.json() if r.status_code == 200 else []
else:
posts = await get_bulletin_posts(category=category, sort=sort, limit=limit)
for post in posts:
if not include_bots and post.get("is_bot"):
continue
if post.get("author_id"):
profile = await get_profile(post["author_id"])
if profile:
@ -136,99 +200,92 @@ async def latest_posts(
"level": profile.get("level", 1),
"is_bot": profile.get("is_bot", False),
}
if not include_bots:
posts = [p for p in posts if not p.get("is_bot")]
return {"posts": posts, "total": len(posts)}
next_cursor = posts[-1]["id"] if posts else None
return {"posts": posts, "total": len(posts), "next_cursor": next_cursor}
@router.post("/posts")
async def create_post(post: BulletinPostIn, user: dict = Depends(_require_public_profile)):
result = await create_bulletin_post(
title=post.title,
content=post.content,
title=_sanitize(post.title),
content=_sanitize(post.content),
author_id=user["id"],
category=post.category,
chain=post.chain,
tags=post.tags,
risk_score=post.risk_score,
source=post.source,
)
if not result:
raise HTTPException(status_code=500, detail="Failed to create post")
if post.is_bot and result.get("id"):
with contextlib.suppress(Exception):
_sb_patch("bulletin_posts", {"is_bot": True, "bot_id": post.bot_id}, {"id": result["id"]})
return result
@router.post("/posts/{post_id}/vote")
async def vote(post_id: int, vote_type: str = Query(..., pattern="^(up|down)$")):
async def vote(
post_id: int,
vote_type: str = Query(..., pattern="^(up|down)$"),
user: dict = Depends(_require_public_profile),
):
ok = await vote_post(post_id, vote_type)
if not ok:
raise HTTPException(status_code=404, detail="Post not found or vote failed")
return {"status": "ok", "post_id": post_id, "vote": vote_type}
class CommentIn(BaseModel):
content: str
@router.post("/posts/{post_id}/comments")
async def create_comment(post_id: int, comment: CommentIn, user: dict = Depends(_require_public_profile)):
"""Create a comment on a bulletin post. Requires public profile."""
# Store comment in Supabase or Redis (using simple dict for now if not in supabase_service)
# For now, we'll just return success and let the frontend handle local state,
# or we can add it to supabase_service if needed.
# Let's use a simple in-memory store or supabase if available.
# Since supabase_service doesn't have create_comment, we'll add a basic implementation.
async def create_comment(
post_id: int, comment: CommentIn, user: dict = Depends(_require_public_profile)
):
try:
result = _sb_post(
result = await _post(
"bulletin_comments",
{
"post_id": post_id,
"user_id": user["id"],
"content": comment.content,
"created_at": datetime.utcnow().isoformat(),
"content": _sanitize(comment.content),
"created_at": _now_iso(),
},
)
if not result:
raise HTTPException(status_code=500, detail="Failed to create comment")
return {"status": "ok", "comment": result[0]}
return {"status": "ok", "comment": result}
except HTTPException:
raise
except Exception as e:
# Fallback if table doesn't exist yet
logger.warning(f"Comment creation failed (table may not exist): {e}")
return {
"status": "ok",
"comment": {
"id": "temp",
"content": comment.content,
"content": _sanitize(comment.content),
"user_id": user["id"],
"created_at": datetime.utcnow().isoformat(),
"created_at": _now_iso(),
},
}
@router.get("/posts/{post_id}/comments")
async def get_comments(post_id: int):
"""Get comments for a bulletin post."""
try:
comments = _sb_get("bulletin_comments", {"post_id": f"eq.{post_id}", "order": "created_at.asc"})
comments = await _get("bulletin_comments", {"post_id": post_id}, order="created_at.asc")
return {"comments": comments}
except Exception:
return {"comments": []}
# ── Bot Endpoints ───────────────────────────────────────────
# ── Bot Endpoints ─────────────────────────────────────────────
BOT_FEE_USD = 1.00 # $1 fee for bots to participate
# Bot payments require on-chain x402 verification — not yet implemented
@router.post("/bots/register")
async def register_bot(bot: BotRegistrationIn):
"""Register a new bot account. Bot must pay a fee to participate."""
# Create the bot profile
try:
profiles = _sb_post(
profile = await _post(
"profiles",
{
"username": bot.bot_name,
@ -240,20 +297,18 @@ async def register_bot(bot: BotRegistrationIn):
"tier": "FREE",
},
)
if not profiles:
if not profile:
raise HTTPException(status_code=500, detail="Failed to create bot profile")
bot_profile = profiles[0]
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Bot profile creation failed: {e}") from e
# Create bot registration record
try:
regs = _sb_post(
reg = await _post(
"bot_registrations",
{
"bot_user_id": bot_profile["id"],
"bot_user_id": profile["id"],
"owner_user_id": bot.owner_id,
"bot_name": bot.bot_name,
"bot_type": bot.bot_type,
@ -262,27 +317,30 @@ async def register_bot(bot: BotRegistrationIn):
"rules": bot.rules,
"allowed_categories": bot.allowed_categories,
"status": "active" if bot.fee_paid >= BOT_FEE_USD else "pending_fee",
"created_at": _now_iso(),
},
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Bot registration failed: {e}") from e
# Log the fee payment
if bot.fee_paid > 0:
with contextlib.suppress(Exception):
_sb_post(
try:
await _post(
"bot_fee_log",
{
"bot_id": bot_profile["id"],
"bot_id": profile["id"],
"amount": bot.fee_paid,
"currency": bot.fee_currency,
"reason": "registration_fee",
"created_at": _now_iso(),
},
)
except Exception:
logger.warning("Failed to log bot fee payment", exc_info=True)
return {
"bot_profile": bot_profile,
"registration": regs[0] if regs else None,
"bot_profile": profile,
"registration": reg,
"fee_required": BOT_FEE_USD,
"fee_paid": bot.fee_paid,
"fee_remaining": max(0, BOT_FEE_USD - bot.fee_paid),
@ -292,27 +350,25 @@ async def register_bot(bot: BotRegistrationIn):
@router.get("/bots")
async def list_bots(status: str = Query(None), limit: int = Query(50)):
"""List all registered bots."""
params: dict = {
"select": "*,profiles:bot_user_id(username,is_bot,level,reputation_score)",
"limit": str(limit),
"order": "created_at.desc",
}
if status:
params["status"] = f"eq.{status}"
result = _sb_get("bot_registrations", params)
return {"bots": result, "total": len(result)}
select = "*,profiles:bot_user_id(username,is_bot,level,reputation_score)"
bots = await _get(
"bot_registrations",
{"status": status} if status else None,
select=select,
order="created_at.desc",
limit=limit,
)
return {"bots": bots, "total": len(bots)}
@router.get("/bots/{bot_id}/fee-status")
async def bot_fee_status(bot_id: str):
"""Check a bot's fee payment status."""
reg = _sb_get("bot_registrations", {"bot_user_id": f"eq.{bot_id}", "limit": "1"})
reg = await _get("bot_registrations", {"bot_user_id": bot_id}, limit=1)
if not reg:
raise HTTPException(status_code=404, detail="Bot not registered")
registration = reg[0]
fees = _sb_get("bot_fee_log", {"bot_id": f"eq.{bot_id}", "order": "created_at.desc"})
fees = await _get("bot_fee_log", {"bot_id": bot_id}, order="created_at.desc")
total_paid = sum(f.get("amount", 0) for f in fees)
return {
"bot_id": bot_id,
@ -324,93 +380,50 @@ async def bot_fee_status(bot_id: str):
}
@router.post("/bots/{bot_id}/pay-fee")
async def pay_bot_fee(bot_id: str, amount: float, tx_hash: str | None = None, currency: str = "usdc"):
"""Record a bot fee payment."""
try:
_sb_post(
"bot_fee_log",
{
"bot_id": bot_id,
"amount": amount,
"currency": currency,
"tx_hash": tx_hash,
"reason": "participation_fee",
},
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Fee logging failed: {e}") from e
fees = _sb_get("bot_fee_log", {"bot_id": f"eq.{bot_id}", "select": "amount"})
total_paid = sum(f.get("amount", 0) for f in fees)
if total_paid >= BOT_FEE_USD:
_sb_patch("bot_registrations", {"status": "active"}, {"bot_user_id": bot_id})
_sb_patch("profiles", {"bot_fee_paid": total_paid}, {"id": bot_id})
return {
"amount_paid": amount,
"total_paid": total_paid,
"fee_required": BOT_FEE_USD,
"status": "active" if total_paid >= BOT_FEE_USD else "pending_fee",
}
@router.post("/bots/{bot_id}/post")
async def bot_create_post(bot_id: str, post: BulletinPostIn):
"""Bot creates a post - must be registered and fee-paid."""
reg = _sb_get("bot_registrations", {"bot_user_id": f"eq.{bot_id}", "status": "eq.active", "limit": "1"})
async def bot_create_post(bot_id: str, post: BotPostIn):
reg = await _get("bot_registrations", {"bot_user_id": bot_id, "status": "active"}, limit=1)
if not reg:
raise HTTPException(status_code=403, detail="Bot not registered or fee not paid")
registration = reg[0]
if registration.get("allowed_categories") and post.category not in registration["allowed_categories"]:
raise HTTPException(status_code=403, detail=f"Bot not allowed to post in category: {post.category}")
if (
registration.get("allowed_categories")
and post.category not in registration["allowed_categories"]
):
raise HTTPException(
status_code=403, detail=f"Bot not allowed to post in category: {post.category}"
)
post.is_bot = True
post.bot_id = bot_id
post.source = post.source or f"bot:{registration['bot_name']}"
source = f"bot:{registration['bot_name']}"
result = await create_bulletin_post(
title=post.title,
content=post.content,
author_id=post.author_id,
category=post.category,
chain=post.chain,
tags=post.tags,
risk_score=post.risk_score,
source=post.source,
result = await _post(
"bulletin_posts",
{
"title": _sanitize(post.title),
"content": _sanitize(post.content),
"author_id": post.author_id,
"category": post.category,
"chain": post.chain,
"tags": post.tags,
"risk_score": post.risk_score,
"source": source,
"is_bot": True,
"bot_id": bot_id,
"created_at": _now_iso(),
},
)
if not result:
raise HTTPException(status_code=500, detail="Failed to create post")
with contextlib.suppress(Exception):
_sb_patch("bulletin_posts", {"is_bot": True, "bot_id": bot_id}, {"id": result["id"]})
return result
# ── Reports & Moderation ──────────────────────────────────────
class ReportIn(BaseModel):
post_id: int
reporter_id: str
reason: str
details: str | None = None
category: str = "other" # spam, abuse, misinformation, scam, bot_violation, other
class ModerationActionIn(BaseModel):
admin_id: str
action: str # remove, lock, pin, unpin, unlock, restore, ban_user
target_type: str # post, user, bot
target_id: str
reason: str | None = None
@router.post("/reports")
async def create_report(report: ReportIn):
"""Submit a report on a post."""
result = _sb_post(
result = await _post(
"bulletin_reports",
{
"post_id": report.post_id,
@ -419,28 +432,28 @@ async def create_report(report: ReportIn):
"details": report.details,
"category": report.category,
"status": "open",
"created_at": _now_iso(),
},
)
if not result:
raise HTTPException(status_code=500, detail="Failed to create report")
return result[0]
return result
@router.get("/reports")
async def list_reports(status: str = Query(None), limit: int = Query(50, ge=1, le=200)):
"""List reports (admin view)."""
params: dict = {"order": "created_at.desc", "limit": str(limit)}
if status:
params["status"] = f"eq.{status}"
result = _sb_get("bulletin_reports", params)
return {"reports": result, "total": len(result)}
reports = await _get(
"bulletin_reports",
{"status": status} if status else None,
order="created_at.desc",
limit=limit,
)
return {"reports": reports, "total": len(reports)}
@router.post("/moderate")
async def moderate(moderation: ModerationActionIn):
"""Take a moderation action."""
# Log the action
_sb_post(
await _post(
"bulletin_moderation_log",
{
"admin_id": moderation.admin_id,
@ -448,28 +461,32 @@ async def moderate(moderation: ModerationActionIn):
"target_type": moderation.target_type,
"target_id": moderation.target_id,
"reason": moderation.reason,
"created_at": _now_iso(),
},
)
# Apply the action
if moderation.target_type == "post":
match = {"id": moderation.target_id}
if moderation.action == "remove":
_sb_patch("bulletin_posts", {"removed": True, "removed_reason": moderation.reason}, match)
await _patch(
"bulletin_posts", match, {"removed": True, "removed_reason": moderation.reason}
)
elif moderation.action == "restore":
_sb_patch("bulletin_posts", {"removed": False, "removed_reason": None}, match)
await _patch("bulletin_posts", match, {"removed": False, "removed_reason": None})
elif moderation.action == "pin":
_sb_patch("bulletin_posts", {"pinned": True}, match)
await _patch("bulletin_posts", match, {"pinned": True})
elif moderation.action == "unpin":
_sb_patch("bulletin_posts", {"pinned": False}, match)
await _patch("bulletin_posts", match, {"pinned": False})
elif moderation.action == "lock":
_sb_patch("bulletin_posts", {"locked": True}, match)
await _patch("bulletin_posts", match, {"locked": True})
elif moderation.action == "unlock":
_sb_patch("bulletin_posts", {"locked": False}, match)
await _patch("bulletin_posts", match, {"locked": False})
elif moderation.target_type == "bot":
if moderation.action == "ban_user":
_sb_patch("bot_registrations", {"status": "banned"}, {"bot_user_id": moderation.target_id})
await _patch(
"bot_registrations", {"bot_user_id": moderation.target_id}, {"status": "banned"}
)
return {
"status": "ok",
@ -480,12 +497,11 @@ async def moderate(moderation: ModerationActionIn):
@router.get("/moderation/log")
async def moderation_log(limit: int = Query(50)):
"""View moderation log."""
result = _sb_get("bulletin_moderation_log", {"order": "created_at.desc", "limit": str(limit)})
return {"log": result, "total": len(result)}
log = await _get("bulletin_moderation_log", order="created_at.desc", limit=limit)
return {"log": log, "total": len(log)}
# ── Badges ──────────────────────────────────────────────────
# ── Badges ────────────────────────────────────────────────────
@router.get("/badges")
@ -507,7 +523,7 @@ async def award(badge_id: str, user_id: str, earned_via: str = "auto"):
return result
# ── Alerts ──────────────────────────────────────────────────
# ── Alerts ────────────────────────────────────────────────────
@router.get("/alerts")
@ -534,55 +550,73 @@ async def submit_alert(alert: AlertIn):
return result
# ── Stats ──────────────────────────────────────────────────
# ── Stats (D5 — Redis-cached with 5-minute TTL) ───────────────
async def get_stats_cached():
try:
import redis.asyncio as aioredis
r = aioredis.Redis(
host=os.getenv("REDIS_HOST", "localhost"),
port=int(os.getenv("REDIS_PORT", "6379")),
password=os.getenv("REDIS_PASSWORD", ""),
decode_responses=True,
)
cached = await r.get("bulletin:stats")
if cached:
return json.loads(cached)
total_posts = 0
total_users = 0
total_bots = 0
total_alerts = 0
try:
posts = await _get("bulletin_posts", select="id", limit=1000)
total_posts = len(posts)
except Exception:
logger.warning("Failed to fetch post count", exc_info=True)
try:
profiles = await _get("profiles", select="id", limit=1000)
total_users = len(profiles)
except Exception:
logger.warning("Failed to fetch user count", exc_info=True)
try:
bots = await _get("profiles", {"is_bot": True}, select="id", limit=100)
total_bots = len(bots)
except Exception:
logger.warning("Failed to fetch bot count", exc_info=True)
try:
alerts = await _get("alerts", select="id", limit=500)
total_alerts = len(alerts)
except Exception:
logger.warning("Failed to fetch alert count", exc_info=True)
stats = {
"total_posts": total_posts,
"total_users": total_users,
"total_bots": total_bots,
"total_alerts": total_alerts,
}
await r.setex("bulletin:stats", 300, json.dumps(stats))
return stats
except Exception:
return {"total_posts": 0, "total_users": 0, "total_bots": 0, "total_alerts": 0}
@router.get("/stats")
async def community_stats():
"""Get aggregate community stats from Supabase."""
total_posts = 0
total_users = 0
total_bots = 0
total_alerts = 0
try:
posts = _sb_get("bulletin_posts", {"select": "id", "limit": "1000"})
total_posts = len(posts)
except Exception:
logging.getLogger(__name__).warning("swallowed exception", exc_info=True)
try:
profiles = _sb_get("profiles", {"select": "id", "limit": "1000"})
total_users = len(profiles)
except Exception:
logging.getLogger(__name__).warning("swallowed exception", exc_info=True)
try:
bots = _sb_get("profiles", {"select": "id", "is_bot": "eq.true", "limit": "100"})
total_bots = len(bots)
except Exception:
logging.getLogger(__name__).warning("swallowed exception", exc_info=True)
try:
alerts = _sb_get("alerts", {"select": "id", "limit": "500"})
total_alerts = len(alerts)
except Exception:
logging.getLogger(__name__).warning("swallowed exception", exc_info=True)
return {
"total_posts": total_posts,
"total_users": total_users,
"total_bots": total_bots,
"total_alerts": total_alerts,
"bot_fee_required_usd": BOT_FEE_USD,
}
stats = await get_stats_cached()
return {**stats, "bot_fee_required_usd": BOT_FEE_USD}
# ── Health ──────────────────────────────────────────────────
# ── Health ────────────────────────────────────────────────────
@router.get("/health")
async def health():
from app.services.supabase_service import check_health
return await check_health()

View file

@ -40,7 +40,9 @@ router = APIRouter(prefix="/api/v1/admin/tokens/airdrop", tags=["darkroom-airdro
async def _verify_admin(request: Request):
admin_key = os.getenv("ADMIN_API_KEY", "dev-key-change-me")
admin_key = os.getenv("ADMIN_API_KEY", "")
if not admin_key:
raise HTTPException(status_code=401, detail="Admin API key not configured")
key = request.headers.get("X-Admin-Key", "")
if key != admin_key:
raise HTTPException(status_code=401, detail="Invalid admin key")
@ -132,7 +134,9 @@ async def create_snapshot(request: Request, body: SnapshotRequest, _=Depends(_ve
if body.source_chain in ["ethereum", "base", "bsc"]:
rpc = os.getenv(f"{body.source_chain.upper()}_RPC_URL", "")
if not rpc:
raise HTTPException(status_code=400, detail=f"No RPC configured for {body.source_chain}")
raise HTTPException(
status_code=400, detail=f"No RPC configured for {body.source_chain}"
)
snapshot = await SnapshotEngine.create_evm_snapshot(
token_address=body.source_token,
@ -169,7 +173,9 @@ async def create_snapshot(request: Request, body: SnapshotRequest, _=Depends(_ve
"total_supply_snapshotted": snapshot.total_supply_snapshotted,
"timestamp": snapshot.timestamp,
},
"holders_preview": [{"address": h.address, "amount": h.amount} for h in snapshot.holders[:10]],
"holders_preview": [
{"address": h.address, "amount": h.amount} for h in snapshot.holders[:10]
],
}
except HTTPException:
@ -211,15 +217,23 @@ async def execute_airdrop(request: Request, body: AirdropExecuteRequest, _=Depen
recipients = [AirdropRecipient(**r) for r in body.recipients]
else:
raise HTTPException(status_code=400, detail="No recipients provided (need snapshot_id or recipients)")
raise HTTPException(
status_code=400, detail="No recipients provided (need snapshot_id or recipients)"
)
# Execute based on chain
if deployment.chain in ["ethereum", "base", "bsc"]:
result = await AirdropDistributor.execute_evm_airdrop(deployer, deployment.contract_address, recipients)
result = await AirdropDistributor.execute_evm_airdrop(
deployer, deployment.contract_address, recipients
)
elif deployment.chain == "solana":
result = await AirdropDistributor.execute_solana_airdrop(deployer, deployment.contract_address, recipients)
result = await AirdropDistributor.execute_solana_airdrop(
deployer, deployment.contract_address, recipients
)
else:
raise HTTPException(status_code=400, detail=f"Airdrop not supported for {deployment.chain}")
raise HTTPException(
status_code=400, detail=f"Airdrop not supported for {deployment.chain}"
)
# Save campaign
campaign = AirdropCampaign(
@ -301,7 +315,9 @@ async def apply_antisniper(request: Request, body: AntiSniperRequest, _=Depends(
)
# Update deployment record
await storage.update_status(body.deployment_id, "anti_sniper_applied", {"anti_sniper": result})
await storage.update_status(
body.deployment_id, "anti_sniper_applied", {"anti_sniper": result}
)
return {
"success": True,
@ -413,7 +429,9 @@ async def enable_trading(request: Request, body: EnableTradingRequest, _=Depends
deployer = TokenDeployerFactory.get_deployer(deployment.chain)
tx_hash = await deployer.set_trading_enabled(deployment.contract_address, True)
await storage.update_status(body.deployment_id, "trading_enabled", {"trading_enabled_tx": tx_hash})
await storage.update_status(
body.deployment_id, "trading_enabled", {"trading_enabled_tx": tx_hash}
)
return {
"success": True,

View file

@ -35,7 +35,9 @@ router = APIRouter(prefix="/api/v1/admin/tokens/multichain", tags=["darkroom-mul
async def _verify_admin(request: Request):
admin_key = os.getenv("ADMIN_API_KEY", "dev-key-change-me")
admin_key = os.getenv("ADMIN_API_KEY", "")
if not admin_key:
raise HTTPException(status_code=401, detail="Admin API key not configured")
key = request.headers.get("X-Admin-Key", "")
if key != admin_key:
raise HTTPException(status_code=401, detail="Invalid admin key")

View file

@ -41,7 +41,9 @@ router = APIRouter(prefix="/api/v1/admin/tokens", tags=["darkroom-tokens"])
async def _verify_admin(request: Request):
"""Verify admin access with API key."""
admin_key = os.getenv("ADMIN_API_KEY", "dev-key-change-me")
admin_key = os.getenv("ADMIN_API_KEY", "")
if not admin_key:
raise HTTPException(status_code=401, detail="Admin API key not configured")
key = request.headers.get("X-Admin-Key", "")
if key != admin_key:
raise HTTPException(status_code=401, detail="Invalid admin key")
@ -204,10 +206,14 @@ async def mint_tokens(request: Request, body: MintRequest, _=Depends(_verify_adm
raise HTTPException(status_code=404, detail="Deployment not found")
if deployment.status != "deployed":
raise HTTPException(status_code=400, detail=f"Cannot mint - status is {deployment.status}")
raise HTTPException(
status_code=400, detail=f"Cannot mint - status is {deployment.status}"
)
deployer = TokenDeployerFactory.get_deployer(deployment.chain)
tx_hash = await deployer.mint_tokens(deployment.contract_address, body.to_address, body.amount)
tx_hash = await deployer.mint_tokens(
deployment.contract_address, body.to_address, body.amount
)
await storage.update_status(body.deployment_id, "minting", {"last_mint_tx": tx_hash})
@ -252,7 +258,9 @@ async def burn_tokens(request: Request, body: BurnRequest, _=Depends(_verify_adm
@router.post("/transfer")
async def transfer_ownership(request: Request, body: TransferOwnershipRequest, _=Depends(_verify_admin)):
async def transfer_ownership(
request: Request, body: TransferOwnershipRequest, _=Depends(_verify_admin)
):
"""Transfer contract ownership to a new address."""
try:
storage = await get_storage()
@ -371,7 +379,9 @@ async def blacklist_remove(request: Request, body: BlacklistRequest, _=Depends(_
@router.get("/blacklist/check")
async def check_blacklist(request: Request, deployment_id: str, address: str, _=Depends(_verify_admin)):
async def check_blacklist(
request: Request, deployment_id: str, address: str, _=Depends(_verify_admin)
):
"""Check if an address is blacklisted."""
try:
storage = await get_storage()
@ -478,7 +488,9 @@ async def set_max_tx(request: Request, body: SetLimitRequest, _=Depends(_verify_
@router.get("/list")
async def list_deployments(request: Request, chain: str | None = None, limit: int = 100, _=Depends(_verify_admin)):
async def list_deployments(
request: Request, chain: str | None = None, limit: int = 100, _=Depends(_verify_admin)
):
"""List all token deployments."""
try:
storage = await get_storage()
@ -525,7 +537,9 @@ async def get_deployment(request: Request, deployment_id: str, _=Depends(_verify
@router.get("/{deployment_id}/balance/{wallet_address}")
async def get_balance(request: Request, deployment_id: str, wallet_address: str, _=Depends(_verify_admin)):
async def get_balance(
request: Request, deployment_id: str, wallet_address: str, _=Depends(_verify_admin)
):
"""Get token balance for a specific wallet."""
try:
storage = await get_storage()

View file

@ -1,22 +1,32 @@
"""Developer API routes."""
"""Developer API routes — stub, coming Q3 2026."""
from fastapi import APIRouter
from pydantic import BaseModel
from fastapi.responses import JSONResponse
router = APIRouter(prefix="/api/v1/developer", tags=["developer"])
class DeveloperRegister(BaseModel):
email: str
name: str = ""
use_case: str = ""
def _not_implemented(feature: str) -> JSONResponse:
return JSONResponse(
status_code=501,
content={
"status": "not_implemented",
"message": f"Developer {feature} — coming soon. Contact @cryptorugmunch for early access.",
"available_in": "Q3 2026",
},
)
@router.post("/register")
async def dev_register(body: DeveloperRegister):
return {"api_key": "rmi_dev_trial", "tier": "free"}
async def dev_register():
return _not_implemented("Registration")
@router.get("/tiers")
async def dev_tiers():
return {"tiers": [{"name": "free", "rpm": 10}, {"name": "pro", "rpm": 100}, {"name": "enterprise", "rpm": 1000}]}
return _not_implemented("Tiers")
@router.post("/verify")
async def dev_verify(api_key: str = ""):
return {"valid": False, "tier": "unknown"}
return _not_implemented("API Key Verification")

View file

@ -1,8 +1,18 @@
"""Investigator profile routes."""
"""Investigator profile routes — stub, coming Q3 2026."""
from fastapi import APIRouter
from fastapi.responses import JSONResponse
router = APIRouter(prefix="/api/v1/investigators", tags=["investigators"])
@router.get("/{username}")
async def get_investigator(username: str):
return {"username": username, "badges": [], "cases": 0, "reputation": 0}
return JSONResponse(
status_code=501,
content={
"status": "not_implemented",
"message": "Investigator Profiles — coming soon. Contact @cryptorugmunch for early access.",
"available_in": "Q3 2026",
},
)

View file

@ -1,36 +1,57 @@
"""Premium tier API routes."""
"""Premium tier API routes — stub, coming Q3 2026."""
from fastapi import APIRouter
from fastapi.responses import JSONResponse
router = APIRouter(prefix="/api/v1/premium", tags=["premium"])
def _not_implemented(feature: str) -> JSONResponse:
return JSONResponse(
status_code=501,
content={
"status": "not_implemented",
"message": f"Premium {feature} — coming soon. Contact @cryptorugmunch for early access.",
"available_in": "Q3 2026",
},
)
@router.get("/ai-classifier/predict")
async def ai_classifier(token: str = ""):
return {"prediction": "pending", "token": token, "confidence": 0.0}
return _not_implemented("AI Classifier")
@router.get("/vulnerability-map")
async def vuln_map(chain: str = "ethereum"):
return {"chain": chain, "vulnerabilities": []}
return _not_implemented("Vulnerability Map")
@router.get("/velocity-risk")
async def velocity_risk(token: str = ""):
return {"token": token, "risk_score": 0}
return _not_implemented("Velocity Risk Scoring")
@router.get("/wallet-fingerprint")
async def wallet_fingerprint(address: str = ""):
return {"address": address, "fingerprint": {}}
return _not_implemented("Wallet Fingerprinting")
@router.get("/market-context")
async def market_context(token: str = ""):
return {"token": token, "context": {}}
return _not_implemented("Market Context Analysis")
@router.get("/campaigns")
async def campaigns():
return {"campaigns": []}
return _not_implemented("Campaign Tracking")
@router.post("/campaigns/check")
async def campaign_check(address: str = ""):
return {"address": address, "in_campaign": False}
return _not_implemented("Campaign Check")
@router.get("/entity-graph")
async def entity_graph(address: str = ""):
return {"address": address, "nodes": [], "edges": []}
return _not_implemented("Entity Graph Analysis")

View file

@ -4,12 +4,15 @@ RMI Supabase Service - Unified backend layer for all Supabase tables.
Replaces scattered REST calls with a clean, typed service interface.
"""
import logging
import os
from datetime import UTC, datetime
import httpx
from dotenv import load_dotenv
logger = logging.getLogger(__name__)
# Ensure .env is loaded before reading env vars - override stale Docker env
load_dotenv("/app/.env", override=True)
@ -19,7 +22,9 @@ def _get_url() -> str:
def _get_key() -> str:
return os.environ.get("SUPABASE_SERVICE_KEY") or os.environ.get("SUPABASE_SERVICE_ROLE_KEY") or ""
return (
os.environ.get("SUPABASE_SERVICE_KEY") or os.environ.get("SUPABASE_SERVICE_ROLE_KEY") or ""
)
def _get_headers() -> dict:
@ -111,6 +116,58 @@ async def _rpc(fn: str, params: dict | None = None) -> dict:
return r.json() if r.status_code == 200 else None
__all__ = [
"acknowledge_alert",
"add_to_watchlist",
"award_badge",
"check_health",
"create_alert",
"create_bulletin_post",
"create_comment",
"create_report",
"ensure_x402_payments_table",
"get_alerts",
"get_badges",
"get_bot_fee_status",
"get_bulletin_posts",
"get_bulletin_stats",
"get_cluster_wallets",
"get_comments",
"get_contract_analysis",
"get_entity_clusters",
"get_market_data",
"get_moderation_log",
"get_profile",
"get_reports",
"get_subscription",
"get_syndicate_wallets",
"get_token",
"get_tokens",
"get_transactions",
"get_user_badges",
"get_wallet",
"get_wallet_connections",
"get_wallet_intel",
"get_wallets",
"get_watchlist",
"get_x402_earnings_summary",
"get_x402_payment_by_tx",
"get_x402_payments",
"list_bots",
"log_moderation",
"record_x402_payment_supabase",
"register_bot",
"remove_from_watchlist",
"store_market_data",
"track_event",
"update_profile",
"upsert_contract_analysis",
"upsert_token",
"upsert_wallet",
"vote_post",
]
# ═════════════════════════════════════════════════════════
# ALERTS
# ═════════════════════════════════════════════════════════
@ -233,7 +290,9 @@ async def upsert_wallet(address: str, chain: str, **fields) -> dict:
# ═════════════════════════════════════════════════════════
async def get_wallet_intel(address: str | None = None, chain: str | None = None, limit: int = 100) -> list:
async def get_wallet_intel(
address: str | None = None, chain: str | None = None, limit: int = 100
) -> list:
url = _url("wallet_intel")
query_parts = ["select=*", f"limit={limit}"]
if address:
@ -260,7 +319,9 @@ async def get_wallet_connections(address: str) -> list:
# ═════════════════════════════════════════════════════════
async def get_syndicate_wallets(status: str | None = None, chain: str | None = None, limit: int = 200) -> list:
async def get_syndicate_wallets(
status: str | None = None, chain: str | None = None, limit: int = 200
) -> list:
url = _url("syndicate_wallets")
query_parts = ["select=*", f"limit={limit}"]
if status:
@ -319,7 +380,9 @@ async def remove_from_watchlist(user_id: str, address: str, chain: str = "ethere
# ═════════════════════════════════════════════════════════
async def get_tokens(chain: str | None = None, limit: int = 50, order_by: str = "volume_24h.desc") -> list:
async def get_tokens(
chain: str | None = None, limit: int = 50, order_by: str = "volume_24h.desc"
) -> list:
params = {}
if chain:
params["chain"] = chain
@ -351,7 +414,9 @@ async def upsert_token(address: str, chain: str, **fields) -> dict:
# ═════════════════════════════════════════════════════════
async def get_bulletin_posts(category: str | None = None, sort: str = "created_at.desc", limit: int = 20) -> list:
async def get_bulletin_posts(
category: str | None = None, sort: str = "created_at.desc", limit: int = 20
) -> list:
params = {}
if category and category != "all":
params["category"] = category
@ -398,6 +463,226 @@ async def vote_post(post_id: int, vote_type: str) -> bool:
return await _patch("bulletin_posts", {"id": post_id}, {field: current + 1})
# ============================================================
# BULLETIN COMMENTS
# ============================================================
async def create_comment(post_id: int, user_id: str, content: str) -> dict:
try:
return await _post(
"bulletin_comments",
{
"post_id": post_id,
"user_id": user_id,
"content": content,
"created_at": datetime.now(UTC).isoformat(),
},
)
except Exception as e:
logger.error(f"create_comment failed: {e}")
return None
async def get_comments(post_id: int, limit: int = 50) -> list:
try:
return await _get(
"bulletin_comments", {"post_id": post_id}, order="created_at.asc", limit=limit
)
except Exception as e:
logger.error(f"get_comments failed: {e}")
return []
# ============================================================
# BULLETIN REPORTS
# ============================================================
async def create_report(
post_id: int,
reporter_id: str,
reason: str,
details: str | None = None,
category: str = "other",
) -> dict:
try:
return await _post(
"bulletin_reports",
{
"post_id": post_id,
"reporter_id": reporter_id,
"reason": reason,
"details": details,
"category": category,
"status": "open",
"created_at": datetime.now(UTC).isoformat(),
},
)
except Exception as e:
logger.error(f"create_report failed: {e}")
return None
async def get_reports(status: str | None = None, limit: int = 50) -> list:
try:
params = {}
if status:
params["status"] = status
return await _get("bulletin_reports", params, order="created_at.desc", limit=limit)
except Exception as e:
logger.error(f"get_reports failed: {e}")
return []
# ============================================================
# BULLETIN MODERATION
# ============================================================
async def log_moderation(
admin_id: str,
action: str,
target_type: str,
target_id: str,
reason: str | None = None,
) -> dict:
try:
return await _post(
"bulletin_moderation_log",
{
"admin_id": admin_id,
"action": action,
"target_type": target_type,
"target_id": target_id,
"reason": reason,
"created_at": datetime.now(UTC).isoformat(),
},
)
except Exception as e:
logger.error(f"log_moderation failed: {e}")
return None
async def get_moderation_log(limit: int = 50) -> list:
try:
return await _get("bulletin_moderation_log", {}, order="created_at.desc", limit=limit)
except Exception as e:
logger.error(f"get_moderation_log failed: {e}")
return []
# ============================================================
# BULLETIN BOTS
# ============================================================
async def register_bot(
owner_id: str,
bot_name: str,
bot_type: str = "general",
fee_paid: float = 0,
currency: str = "usdc",
rules: list | None = None,
allowed_categories: list | None = None,
) -> dict:
try:
profile = await _post(
"profiles",
{
"username": bot_name,
"is_bot": True,
"bot_owner": owner_id,
"bot_fee_paid": fee_paid,
"bot_rules": rules or [],
"role": "BOT",
"tier": "FREE",
},
)
if not profile:
return None
reg = await _post(
"bot_registrations",
{
"bot_user_id": profile["id"],
"owner_user_id": owner_id,
"bot_name": bot_name,
"bot_type": bot_type,
"fee_paid": fee_paid,
"fee_currency": currency,
"rules": rules or [],
"allowed_categories": allowed_categories or [],
"status": "active" if fee_paid >= 1.0 else "pending_fee",
"created_at": datetime.now(UTC).isoformat(),
},
)
return {"profile": profile, "registration": reg}
except Exception as e:
logger.error(f"register_bot failed: {e}")
return None
async def list_bots(status: str | None = None, limit: int = 50) -> list:
try:
params = {}
if status:
params["status"] = status
return await _get("bot_registrations", params, order="created_at.desc", limit=limit)
except Exception as e:
logger.error(f"list_bots failed: {e}")
return []
async def get_bot_fee_status(bot_id: str) -> dict:
try:
reg = await _get("bot_registrations", {"bot_user_id": bot_id}, limit=1)
if not reg:
return None
fees = await _get("bot_fee_log", {"bot_id": bot_id}, order="created_at.desc")
total_paid = sum(f.get("amount", 0) for f in fees)
return {
"bot_id": bot_id,
"registration": reg[0],
"total_paid": total_paid,
"fee_required": 1.0,
"fee_remaining": max(0, 1.0 - total_paid),
"fee_history": fees,
}
except Exception as e:
logger.error(f"get_bot_fee_status failed: {e}")
return None
# ============================================================
# BULLETIN STATS
# ============================================================
async def get_bulletin_stats() -> dict:
try:
posts = await _get("bulletin_posts", {}, limit=1000)
profiles = await _get("profiles", {}, limit=1000, select="id,is_bot")
alerts = await _get("alerts", {}, limit=500)
bots = await _get("bot_registrations", {}, limit=500)
total_users = len([p for p in profiles if not p.get("is_bot")])
total_bots = len([p for p in profiles if p.get("is_bot")])
return {
"total_posts": len(posts),
"total_users": total_users,
"total_bots": total_bots,
"total_alerts": len(alerts),
"total_bot_registrations": len(bots),
}
except Exception as e:
logger.error(f"get_bulletin_stats failed: {e}")
return {}
# ═════════════════════════════════════════════════════════
# BADGES
# ═════════════════════════════════════════════════════════
@ -645,7 +930,9 @@ async def get_x402_earnings_summary() -> dict:
Aggregates total by chain, tool, and day using Supabase data.
"""
try:
rows = await _get("x402_payments", select="tool,amount_atoms,chain,created_at,status", limit=5000)
rows = await _get(
"x402_payments", select="tool,amount_atoms,chain,created_at,status", limit=5000
)
total_atoms = 0
by_chain = {}
by_tool = {}

View file

@ -13,7 +13,7 @@ services:
networks:
- rmi_network
ports:
- "172.20.0.1:8888:8888"
- "127.0.0.1:8888:8888"
volumes:
- /srv/snappymail/data:/var/lib/snappymail
environment: