rmi-backend/app/services/supabase_service.py
opencode c762564d40 style(rmi-backend): complete lint cleanup — 1175→0 ruff errors
- Fix 71 invalid-syntax files (class-body newline-broken assignments)
- Add from/None chain to 307 B904 raise-without-from sites
- Add B008 ignore to ruff.toml (already in pyproject.toml)
- Noqa F401 on __init__.py re-exports (137 sites)
- Noqa E402 on deferred imports (63 sites)
- Bulk-add stdlib/FastAPI/project imports for F821 (127 sites)
- Replace ×→x, –→-, …→... in docstrings (4093 chars)
- Manual refactor of 5 SIM103/SIM116 patterns

Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py)
Co-authored-by: opencode <opencode@rugmunch.io>
2026-07-06 15:43:20 +02:00

723 lines
26 KiB
Python

#!/usr/bin/env python3
"""
RMI Supabase Service - Unified backend layer for all Supabase tables.
Replaces scattered REST calls with a clean, typed service interface.
"""
import os
from datetime import UTC, datetime
import httpx
from dotenv import load_dotenv
# Ensure .env is loaded before reading env vars - override stale Docker env
load_dotenv("/app/.env", override=True)
def _get_url() -> str:
return os.environ.get("SUPABASE_URL", "")
def _get_key() -> str:
return os.environ.get("SUPABASE_SERVICE_KEY") or os.environ.get("SUPABASE_SERVICE_ROLE_KEY") or ""
def _get_headers() -> dict:
key = _get_key()
return {
"apikey": key,
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
}
def _url(table: str) -> str:
return f"{_get_url()}/rest/v1/{table}"
async def _get(
table: str,
params: dict | None = None,
select: str = "*",
order: str | None = None,
limit: int | None = None,
) -> list:
"""Generic GET query."""
url = _url(table)
query_parts = [f"select={select}"]
if params:
for k, v in params.items():
query_parts.append(f"{k}=eq.{v}")
if order:
query_parts.append(f"order={order}")
if limit:
query_parts.append(f"limit={limit}")
url += "?" + "&".join(query_parts)
async with httpx.AsyncClient(timeout=15) as c:
r = await c.get(url, headers=_get_headers())
if r.status_code == 200:
return r.json()
return []
async def _get_in(table: str, field: str, values: list, select: str = "*") -> list:
"""GET with IN filter."""
vals = ",".join(str(v) for v in values)
url = f"{_url(table)}?select={select}&{field}=in.({vals})"
async with httpx.AsyncClient(timeout=15) as c:
r = await c.get(url, headers=_get_headers())
return r.json() if r.status_code == 200 else []
async def _post(table: str, data: dict) -> dict:
"""Insert a row."""
url = f"{_url(table)}"
headers = dict(_get_headers())
headers["Prefer"] = "return=representation"
async with httpx.AsyncClient(timeout=15) as c:
r = await c.post(url, json=data, headers=headers)
if r.status_code in (200, 201):
return r.json()[0] if r.json() else {}
return None
async def _patch(table: str, match: dict, data: dict) -> bool:
"""Update row(s)."""
url = _url(table)
query_parts = [f"{k}=eq.{v}" for k, v in match.items()]
url += "?" + "&".join(query_parts)
async with httpx.AsyncClient(timeout=15) as c:
r = await c.patch(url, json=data, headers=_get_headers())
return r.status_code in (200, 204)
async def _delete(table: str, match: dict) -> bool:
"""Delete row(s)."""
url = _url(table)
query_parts = [f"{k}=eq.{v}" for k, v in match.items()]
url += "?" + "&".join(query_parts)
async with httpx.AsyncClient(timeout=15) as c:
r = await c.delete(url, headers=_get_headers())
return r.status_code in (200, 204)
async def _rpc(fn: str, params: dict | None = None) -> dict:
"""Call a stored procedure."""
url = f"{_get_url()}/rest/v1/rpc/{fn}"
headers = dict(_get_headers())
async with httpx.AsyncClient(timeout=15) as c:
r = await c.post(url, json=params or {}, headers=headers)
return r.json() if r.status_code == 200 else None
# ═════════════════════════════════════════════════════════
# ALERTS
# ═════════════════════════════════════════════════════════
async def get_alerts(
severity: str | None = None,
user_id: str | None = None,
limit: int = 50,
unread_only: bool = False,
) -> list:
params = {}
if severity:
params["severity"] = severity
if user_id:
params["user_id"] = user_id
order = "created_at.desc"
url = _url("alerts")
query_parts = ["select=*", f"order={order}", f"limit={limit}"]
for k, v in params.items():
query_parts.append(f"{k}=eq.{v}")
if unread_only:
query_parts.append("is_read=eq.false")
url += "?" + "&".join(query_parts)
async with httpx.AsyncClient(timeout=15) as c:
r = await c.get(url, headers=_get_headers())
return r.json() if r.status_code == 200 else []
async def create_alert(
title: str,
description: str,
severity: str = "medium",
alert_type: str = "security",
chain: str | None = None,
token: str | None = None,
wallet: str | None = None,
amount_usd: float | None = None,
tx_hash: str | None = None,
user_id: str | None = None,
metadata: dict | None = None,
) -> dict:
data = {
"title": title,
"description": description,
"severity": severity,
"type": alert_type,
"created_at": datetime.now(UTC).isoformat(),
}
if chain:
data["chain"] = chain
if token:
data["token"] = token
if wallet:
data["wallet"] = wallet
if amount_usd:
data["amount_usd"] = amount_usd
if tx_hash:
data["tx_hash"] = tx_hash
if user_id:
data["user_id"] = user_id
if metadata:
data["metadata"] = metadata
return await _post("alerts", data)
async def acknowledge_alert(alert_id: str) -> bool:
return await _patch("alerts", {"id": alert_id}, {"acknowledged": True, "is_read": True})
# ═════════════════════════════════════════════════════════
# WALLETS
# ═════════════════════════════════════════════════════════
async def get_wallets(
chain: str | None = None,
risk_min: int | None = None,
risk_max: int | None = None,
limit: int = 50,
) -> list:
url = _url("wallets")
query_parts = ["select=*", f"limit={limit}"]
if chain:
query_parts.append(f"chain=eq.{chain}")
if risk_min is not None:
query_parts.append(f"risk_score=gte.{risk_min}")
if risk_max is not None:
query_parts.append(f"risk_score=lte.{risk_max}")
url += "?" + "&".join(query_parts)
async with httpx.AsyncClient(timeout=15) as c:
r = await c.get(url, headers=_get_headers())
return r.json() if r.status_code == 200 else []
async def get_wallet(address: str, chain: str = "ethereum") -> dict:
url = f"{_url('wallets')}?select=*&address=eq.{address}&chain=eq.{chain}&limit=1"
async with httpx.AsyncClient(timeout=15) as c:
r = await c.get(url, headers=_get_headers())
data = r.json() if r.status_code == 200 else []
return data[0] if data else None
async def upsert_wallet(address: str, chain: str, **fields) -> dict:
data = {"address": address, "chain": chain, **fields}
url = f"{_url('wallets')}"
headers = dict(_get_headers())
headers["Prefer"] = "resolution=merge-duplicates"
async with httpx.AsyncClient(timeout=15) as c:
r = await c.post(url, json=data, headers=headers)
return r.json()[0] if r.status_code in (200, 201) and r.json() else None
# ═════════════════════════════════════════════════════════
# WALLET INTEL (analysis results)
# ═════════════════════════════════════════════════════════
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:
query_parts.append(f"address=eq.{address}")
if chain:
query_parts.append(f"chain=eq.{chain}")
url += "?" + "&".join(query_parts)
async with httpx.AsyncClient(timeout=15) as c:
r = await c.get(url, headers=_get_headers())
return r.json() if r.status_code == 200 else []
async def get_wallet_connections(address: str) -> list:
"""Find all wallets connected to this address."""
url = f"{_url('wallet_intel')}?select=*&related_addresses=cs.{{'{address}'}}&limit=50"
async with httpx.AsyncClient(timeout=15) as c:
r = await c.get(url, headers=_get_headers())
return r.json() if r.status_code == 200 else []
# ═════════════════════════════════════════════════════════
# SYNDICATE WALLETS
# ═════════════════════════════════════════════════════════
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:
query_parts.append(f"status=eq.{status}")
if chain:
query_parts.append(f"chain=eq.{chain}")
url += "?" + "&".join(query_parts)
async with httpx.AsyncClient(timeout=15) as c:
r = await c.get(url, headers=_get_headers())
return r.json() if r.status_code == 200 else []
# ═════════════════════════════════════════════════════════
# WATCHLIST
# ═════════════════════════════════════════════════════════
async def get_watchlist(user_id: str) -> list:
return await _get("watchlist", {"user_id": user_id}, order="created_at.desc")
async def add_to_watchlist(
user_id: str,
address: str,
chain: str = "ethereum",
tags: list | None = None,
item_type: str = "wallet",
) -> dict:
"""Add an address to a user's watchlist. item_type: 'token' or 'wallet'."""
return await _post(
"watchlist",
{
"user_id": user_id,
"address": address,
"type": item_type,
"chain": chain,
"tags": tags or [],
},
)
async def remove_from_watchlist(user_id: str, address: str, chain: str = "ethereum") -> bool:
return await _delete(
"watchlist",
{
"user_id": user_id,
"address": address,
"chain": chain,
},
)
# ═════════════════════════════════════════════════════════
# TOKENS
# ═════════════════════════════════════════════════════════
async def get_tokens(chain: str | None = None, limit: int = 50, order_by: str = "volume_24h.desc") -> list:
params = {}
if chain:
params["chain"] = chain
return await _get("tokens", params, order=order_by, limit=limit)
async def get_token(address: str, chain: str = "ethereum") -> dict:
data = await _get("tokens", {"address": address, "chain": chain}, limit=1)
return data[0] if data else None
async def upsert_token(address: str, chain: str, **fields) -> dict:
data = {
"address": address,
"chain": chain,
"last_fetched": datetime.now(UTC).isoformat(),
**fields,
}
url = f"{_url('tokens')}"
headers = dict(_get_headers())
headers["Prefer"] = "resolution=merge-duplicates"
async with httpx.AsyncClient(timeout=15) as c:
r = await c.post(url, json=data, headers=headers)
return r.json()[0] if r.status_code in (200, 201) and r.json() else None
# ═════════════════════════════════════════════════════════
# BULLETIN BOARD
# ═════════════════════════════════════════════════════════
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
return await _get("bulletin_posts", params, order=sort, limit=limit)
async def create_bulletin_post(
title: str,
content: str,
author_id: str,
category: str,
chain: str | None = None,
tags: list | None = None,
risk_score: int | None = None,
source: str | None = None,
) -> dict:
data = {
"title": title,
"content": content,
"author_id": author_id,
"category": category,
"created_at": datetime.now(UTC).isoformat(),
}
if chain:
data["chain"] = chain
if tags:
data["tags"] = tags
if risk_score:
data["risk_score"] = risk_score
if source:
data["source"] = source
return await _post("bulletin_posts", data)
async def vote_post(post_id: int, vote_type: str) -> bool:
"""vote_type: 'up' or 'down'"""
field = "upvotes" if vote_type == "up" else "downvotes"
# Get current value
posts = await _get("bulletin_posts", {"id": post_id}, select=f"id,{field}", limit=1)
if not posts:
return False
current = posts[0].get(field, 0) or 0
return await _patch("bulletin_posts", {"id": post_id}, {field: current + 1})
# ═════════════════════════════════════════════════════════
# BADGES
# ═════════════════════════════════════════════════════════
async def get_badges(rarity: str | None = None) -> list:
params = {}
if rarity:
params["rarity"] = rarity
return await _get("badges", params, order="rarity.asc")
async def get_user_badges(user_id: str) -> list:
"""Get user's earned badges with full badge details."""
user_badges = await _get("user_badges", {"user_id": user_id})
if not user_badges:
return []
badge_ids = [ub["badge_id"] for ub in user_badges]
badges = await _get_in("badges", "id", badge_ids)
badge_map = {b["id"]: b for b in badges}
result = []
for ub in user_badges:
badge = badge_map.get(ub["badge_id"])
if badge:
result.append({**ub, "badge_detail": badge})
return result
async def award_badge(user_id: str, badge_id: str, earned_via: str = "auto") -> dict:
return await _post(
"user_badges",
{
"user_id": user_id,
"badge_id": badge_id,
"earned_at": datetime.now(UTC).isoformat(),
"earned_via": earned_via,
},
)
# ═════════════════════════════════════════════════════════
# PROFILES
# ═════════════════════════════════════════════════════════
async def get_profile(user_id: str) -> dict:
data = await _get("profiles", {"id": user_id}, limit=1)
return data[0] if data else None
async def update_profile(user_id: str, **fields) -> bool:
return await _patch("profiles", {"id": user_id}, fields)
# ═════════════════════════════════════════════════════════
# CONTRACT ANALYSES
# ═════════════════════════════════════════════════════════
async def get_contract_analysis(address: str, chain: str = "ethereum") -> dict:
data = await _get("contract_analyses", {"address": address, "chain": chain}, limit=1)
return data[0] if data else None
async def upsert_contract_analysis(address: str, chain: str, **fields) -> dict:
data = {
"address": address,
"chain": chain,
"analyzed_at": datetime.now(UTC).isoformat(),
**fields,
}
url = f"{_url('contract_analyses')}"
headers = dict(_get_headers())
headers["Prefer"] = "resolution=merge-duplicates"
async with httpx.AsyncClient(timeout=15) as c:
r = await c.post(url, json=data, headers=headers)
return r.json()[0] if r.status_code in (200, 201) and r.json() else None
# ═════════════════════════════════════════════════════════
# ENTITY CLUSTERS
# ═════════════════════════════════════════════════════════
async def get_entity_clusters(limit: int = 50) -> list:
return await _get("entity_clusters", order="created_at.desc", limit=limit)
async def get_cluster_wallets(cluster_id: str) -> list:
clusters = await _get("entity_clusters", {"cluster_id": cluster_id}, limit=1)
if not clusters:
return []
addresses = clusters[0].get("addresses", [])
return addresses
# ═════════════════════════════════════════════════════════
# TRANSACTIONS
# ═════════════════════════════════════════════════════════
async def get_transactions(wallet_address: str, chain: str | None = None, limit: int = 50) -> list:
params = {"wallet_address": wallet_address}
if chain:
params["chain"] = chain
return await _get("wallet_transactions", params, order="timestamp.desc", limit=limit)
# ═════════════════════════════════════════════════════════
# MARKET DATA
# ═════════════════════════════════════════════════════════
async def get_market_data(data_type: str | None = None, limit: int = 10) -> list:
params = {}
if data_type:
params["data_type"] = data_type
return await _get("market_data", params, order="fetched_at.desc", limit=limit)
async def store_market_data(source: str, data_type: str, data: dict) -> dict:
return await _post(
"market_data",
{
"source": source,
"data_type": data_type,
"data": data,
"fetched_at": datetime.now(UTC).isoformat(),
},
)
# ═════════════════════════════════════════════════════════
# SUBSCRIPTIONS
# ═════════════════════════════════════════════════════════
async def get_subscription(user_id: str) -> dict:
data = await _get("subscriptions", {"user_id": user_id}, limit=1)
return data[0] if data else None
# ═════════════════════════════════════════════════════════
# ANALYTICS
# ═════════════════════════════════════════════════════════
async def track_event(
event_type: str,
user_id: str | None = None,
event_data: dict | None = None,
page: str | None = None,
session_id: str | None = None,
) -> dict:
return await _post(
"analytics_events",
{
"event_type": event_type,
"user_id": user_id,
"event_data": event_data or {},
"page": page,
"session_id": session_id,
},
)
# ═════════════════════════════════════════════════════════
# HEALTH CHECK
# ═════════════════════════════════════════════════════════
async def check_health() -> dict:
"""Verify Supabase connectivity."""
try:
async with httpx.AsyncClient(timeout=5) as c:
r = await c.get(f"{_get_url()}/rest/v1/profiles?limit=0", headers=_get_headers())
return {
"status": "healthy" if r.status_code == 200 else "degraded",
"tables": 18, # total tables we expect
"timestamp": datetime.now(UTC).isoformat(),
}
except Exception as e:
return {"status": "unreachable", "error": str(e)}
# ═════════════════════════════════════════════════════════
# X402 PAYMENTS (Supabase persistence layer)
# ═════════════════════════════════════════════════════════
async def record_x402_payment_supabase(
tool: str,
amount_atoms: str,
chain: str,
payer: str = "unknown",
tx_hash: str = "",
status: str = "fulfilled",
) -> dict:
"""Insert a payment record into x402_payments table."""
return await _post(
"x402_payments",
{
"tool": tool,
"amount_atoms": str(amount_atoms),
"chain": chain,
"payer": payer,
"tx_hash": tx_hash,
"status": status,
"created_at": datetime.now(UTC).isoformat(),
},
)
async def get_x402_payments(
tool: str | None = None,
chain: str | None = None,
payer: str | None = None,
status: str | None = None,
limit: int = 100,
) -> list:
"""Query x402_payments with optional filters."""
params = {}
if tool:
params["tool"] = tool
if chain:
params["chain"] = chain
if payer:
params["payer"] = payer
if status:
params["status"] = status
return await _get("x402_payments", params, order="created_at.desc", limit=limit)
async def get_x402_payment_by_tx(tx_hash: str) -> dict:
"""Get a specific payment by tx_hash."""
data = await _get("x402_payments", {"tx_hash": tx_hash}, limit=1)
return data[0] if data else None
async def get_x402_earnings_summary() -> dict:
"""Get earnings summary from Supabase.
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)
total_atoms = 0
by_chain = {}
by_tool = {}
by_day = {}
for row in rows:
if row.get("status") != "fulfilled":
continue
try:
amt = int(row.get("amount_atoms", "0") or "0")
except (ValueError, TypeError):
amt = 0
total_atoms += amt
chain = row.get("chain", "unknown")
by_chain[chain] = by_chain.get(chain, 0) + amt
tool = row.get("tool", "unknown")
by_tool[tool] = by_tool.get(tool, 0) + amt
# Day extraction
created = row.get("created_at", "")
day = created[:10] if created else "unknown"
by_day[day] = by_day.get(day, 0) + amt
return {
"total_usdc": round(total_atoms / 1e6, 6),
"by_chain_usdc": {k: round(v / 1e6, 6) for k, v in by_chain.items()},
"by_tool_usdc": {k: round(v / 1e6, 6) for k, v in by_tool.items()},
"by_day_usdc": {k: round(v / 1e6, 6) for k, v in sorted(by_day.items())[-30:]},
"record_count": len(rows),
}
except Exception:
return {
"total_usdc": 0,
"by_chain_usdc": {},
"by_tool_usdc": {},
"by_day_usdc": {},
"record_count": 0,
}
async def ensure_x402_payments_table():
"""Create x402_payments table in Supabase if it doesn't exist."""
sql = """
DO $$ BEGIN
CREATE TABLE IF NOT EXISTS x402_payments (
id BIGSERIAL PRIMARY KEY,
tool TEXT NOT NULL DEFAULT 'unknown',
amount_atoms TEXT NOT NULL DEFAULT '0',
chain TEXT NOT NULL DEFAULT 'unknown',
payer TEXT NOT NULL DEFAULT 'unknown',
tx_hash TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
status TEXT NOT NULL DEFAULT 'fulfilled'
);
END $$;
CREATE INDEX IF NOT EXISTS idx_x402_payments_tool ON x402_payments(tool);
CREATE INDEX IF NOT EXISTS idx_x402_payments_chain ON x402_payments(chain);
CREATE INDEX IF NOT EXISTS idx_x402_payments_tx_hash ON x402_payments(tx_hash);
CREATE INDEX IF NOT EXISTS idx_x402_payments_created_at ON x402_payments(created_at);
CREATE INDEX IF NOT EXISTS idx_x402_payments_payer ON x402_payments(payer);
-- RLS: service_role only -- payment data is backend-only
ALTER TABLE x402_payments ENABLE ROW LEVEL SECURITY;
DO $$ BEGIN
DROP POLICY IF EXISTS "Service role full access" ON x402_payments;
EXCEPTION WHEN OTHERS THEN NULL; END $$;
CREATE POLICY "Service role full access" ON x402_payments FOR ALL TO service_role USING (true) WITH CHECK (true);
"""
try:
async with httpx.AsyncClient(timeout=15) as c:
r = await c.post(
f"{_get_url()}/rest/v1/rpc/exec_sql",
json={"sql": sql},
headers=_get_headers(),
)
return r.status_code in (200, 204)
except Exception:
return False