import math from typing import Any from fastapi import APIRouter, Request, Response, Query, Depends from fastapi.responses import JSONResponse from pydantic import BaseModel from limiter import limiter from auth import require_admin, require_local_operator, _scoped_view_authenticated from services.data_fetcher import get_latest_data from services.mesh.mesh_protocol import normalize_payload from services.mesh.mesh_signed_events import ( MeshWriteExemption, SignedWriteKind, get_prepared_signed_write, mesh_write_exempt, requires_signed_write, ) router = APIRouter() def _signed_body(request: Request) -> dict[str, Any]: prepared = get_prepared_signed_write(request) if prepared is None: return {} return dict(prepared.body) def _safe_int(val, default=0): try: return int(val) except (TypeError, ValueError): return default def _safe_float(val, default=0.0): try: parsed = float(val) if not math.isfinite(parsed): return default return parsed except (TypeError, ValueError): return default def _redact_public_oracle_profile(payload: dict, authenticated: bool) -> dict: redacted = dict(payload) if authenticated: return redacted redacted["active_stakes"] = [] redacted["prediction_history"] = [] return redacted def _redact_public_oracle_predictions(predictions: list, authenticated: bool) -> dict: if authenticated: return {"predictions": list(predictions)} return {"predictions": [], "count": len(predictions)} def _redact_public_oracle_stakes(payload: dict, authenticated: bool) -> dict: redacted = dict(payload) if authenticated: return redacted redacted["truth_stakers"] = [] redacted["false_stakers"] = [] return redacted @router.post("/api/mesh/oracle/predict") @limiter.limit("10/minute") @requires_signed_write(kind=SignedWriteKind.ORACLE_PREDICT) async def oracle_predict(request: Request): """Place a prediction on a market outcome.""" from services.mesh.mesh_oracle import oracle_ledger body = _signed_body(request) node_id = body.get("node_id", "") market_title = body.get("market_title", "") side = body.get("side", "") stake_amount = _safe_float(body.get("stake_amount", 0)) public_key = body.get("public_key", "") public_key_algo = body.get("public_key_algo", "") signature = body.get("signature", "") sequence = _safe_int(body.get("sequence", 0) or 0) protocol_version = body.get("protocol_version", "") if not node_id or not market_title or not side: return {"ok": False, "detail": "Missing node_id, market_title, or side"} prediction_payload = {"market_title": market_title, "side": side, "stake_amount": stake_amount} try: from services.mesh.mesh_reputation import reputation_ledger reputation_ledger.register_node(node_id, public_key, public_key_algo) except Exception: pass data = get_latest_data() markets = data.get("prediction_markets", []) matched = None for m in markets: if m.get("title", "").lower() == market_title.lower(): matched = m break if not matched: for m in markets: if market_title.lower() in m.get("title", "").lower(): matched = m break if not matched: return {"ok": False, "detail": f"Market '{market_title}' not found in active markets."} probability = 50.0 side_lower = side.lower() outcomes = matched.get("outcomes", []) if outcomes: for o in outcomes: if o.get("name", "").lower() == side_lower: probability = float(o.get("pct", 50)) break else: consensus = matched.get("consensus_pct") if consensus is None: consensus = matched.get("polymarket_pct") or matched.get("kalshi_pct") or 50 probability = float(consensus) if side_lower == "no": probability = 100.0 - probability if stake_amount > 0: ok, detail = oracle_ledger.place_market_stake(node_id, matched["title"], side, stake_amount, probability) mode = "staked" else: ok, detail = oracle_ledger.place_prediction(node_id, matched["title"], side, probability) mode = "free" if ok: try: from services.mesh.mesh_hashchain import infonet normalized_payload = normalize_payload("prediction", prediction_payload) infonet.append(event_type="prediction", node_id=node_id, payload=normalized_payload, signature=signature, sequence=sequence, public_key=public_key, public_key_algo=public_key_algo, protocol_version=protocol_version) except Exception: pass return {"ok": ok, "detail": detail, "probability": probability, "mode": mode} @router.get("/api/mesh/oracle/markets") @limiter.limit("30/minute") async def oracle_markets(request: Request): """List active prediction markets.""" from collections import defaultdict from services.mesh.mesh_oracle import oracle_ledger data = get_latest_data() markets = data.get("prediction_markets", []) all_consensus = oracle_ledger.get_all_market_consensus() by_category = defaultdict(list) for m in markets: by_category[m.get("category", "NEWS")].append(m) _fields = ("title", "consensus_pct", "polymarket_pct", "kalshi_pct", "volume", "volume_24h", "end_date", "description", "category", "sources", "slug", "kalshi_ticker", "outcomes") categories = {} cat_totals = {} for cat in ["POLITICS", "CONFLICT", "NEWS", "FINANCE", "CRYPTO"]: all_cat = sorted(by_category.get(cat, []), key=lambda x: x.get("volume", 0) or 0, reverse=True) cat_totals[cat] = len(all_cat) cat_list = [] for m in all_cat[:10]: entry = {k: m.get(k) for k in _fields} entry["consensus"] = all_consensus.get(m.get("title", ""), {}) cat_list.append(entry) categories[cat] = cat_list return {"categories": categories, "total_count": len(markets), "cat_totals": cat_totals} @router.get("/api/mesh/oracle/search") @limiter.limit("20/minute") async def oracle_search(request: Request, q: str = "", limit: int = 50): """Search prediction markets across Polymarket + Kalshi APIs.""" if not q or len(q) < 2: return {"results": [], "query": q, "count": 0} from services.fetchers.prediction_markets import search_polymarket_direct, search_kalshi_direct import concurrent.futures # Search both APIs in parallel for speed with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: poly_fut = pool.submit(search_polymarket_direct, q, limit) kalshi_fut = pool.submit(search_kalshi_direct, q, limit) poly_results = poly_fut.result(timeout=20) kalshi_results = kalshi_fut.result(timeout=20) # Also check cached/merged markets data = get_latest_data() markets = data.get("prediction_markets", []) q_lower = q.lower() cached_matches = [m for m in markets if q_lower in m.get("title", "").lower()] seen_titles = set() combined = [] # Cached first (already merged Poly+Kalshi with consensus) for m in cached_matches: seen_titles.add(m["title"].lower()) combined.append(m) # Then Polymarket direct hits for m in poly_results: if m["title"].lower() not in seen_titles: seen_titles.add(m["title"].lower()) combined.append(m) # Then Kalshi direct hits for m in kalshi_results: if m["title"].lower() not in seen_titles: seen_titles.add(m["title"].lower()) combined.append(m) combined.sort(key=lambda x: x.get("volume", 0) or 0, reverse=True) _fields = ("title", "consensus_pct", "polymarket_pct", "kalshi_pct", "volume", "volume_24h", "end_date", "description", "category", "sources", "slug", "kalshi_ticker", "outcomes") results = [{k: m.get(k) for k in _fields} for m in combined[:limit]] return {"results": results, "query": q, "count": len(results)} @router.get("/api/mesh/oracle/markets/more") @limiter.limit("30/minute") async def oracle_markets_more(request: Request, category: str = "NEWS", offset: int = 0, limit: int = 10): """Load more markets for a specific category (paginated).""" data = get_latest_data() markets = data.get("prediction_markets", []) cat_markets = sorted([m for m in markets if m.get("category") == category], key=lambda x: x.get("volume", 0) or 0, reverse=True) page = cat_markets[offset : offset + limit] _fields = ("title", "consensus_pct", "polymarket_pct", "kalshi_pct", "volume", "volume_24h", "end_date", "description", "category", "sources", "slug", "kalshi_ticker", "outcomes") results = [{k: m.get(k) for k in _fields} for m in page] return {"markets": results, "category": category, "offset": offset, "has_more": offset + limit < len(cat_markets), "total": len(cat_markets)} @router.post( "/api/mesh/oracle/resolve", dependencies=[Depends(require_admin)], ) @limiter.limit("5/minute") @mesh_write_exempt(MeshWriteExemption.ADMIN_CONTROL) async def oracle_resolve(request: Request): """Resolve a prediction market. Issue #240 (tg12): requires admin authentication. The ``mesh_write_exempt`` decorator below is **metadata only** — it tags the route as not requiring a mesh signed-write envelope, it does NOT itself enforce caller authorization. The ``Depends(require_admin)`` on the route decorator is what actually gates access. """ from services.mesh.mesh_oracle import oracle_ledger body = await request.json() market_title = body.get("market_title", "") outcome = body.get("outcome", "") if not market_title or not outcome: return {"ok": False, "detail": "Need market_title and outcome"} winners, losers = oracle_ledger.resolve_market(market_title, outcome) stake_result = oracle_ledger.resolve_market_stakes(market_title, outcome) return {"ok": True, "detail": f"Resolved: {winners} free winners, {losers} free losers, " f"{stake_result.get('winners', 0)} stake winners, {stake_result.get('losers', 0)} stake losers", "free": {"winners": winners, "losers": losers}, "stakes": stake_result} @router.get("/api/mesh/oracle/consensus") @limiter.limit("30/minute") async def oracle_consensus(request: Request, market_title: str = ""): """Get network consensus for a market.""" from services.mesh.mesh_oracle import oracle_ledger if not market_title: return {"error": "market_title required"} return oracle_ledger.get_market_consensus(market_title) @router.post("/api/mesh/oracle/stake") @limiter.limit("10/minute") @requires_signed_write(kind=SignedWriteKind.ORACLE_STAKE) async def oracle_stake(request: Request): """Stake oracle rep on a post's truthfulness.""" from services.mesh.mesh_oracle import oracle_ledger body = _signed_body(request) staker_id = body.get("staker_id", "") message_id = body.get("message_id", "") poster_id = body.get("poster_id", "") side = body.get("side", "").lower() amount = _safe_float(body.get("amount", 0)) duration_days = _safe_int(body.get("duration_days", 1), 1) public_key = body.get("public_key", "") public_key_algo = body.get("public_key_algo", "") signature = body.get("signature", "") sequence = _safe_int(body.get("sequence", 0) or 0) protocol_version = body.get("protocol_version", "") if not staker_id or not message_id or not side: return {"ok": False, "detail": "Missing staker_id, message_id, or side"} stake_payload = {"message_id": message_id, "poster_id": poster_id, "side": side, "amount": amount, "duration_days": duration_days} try: from services.mesh.mesh_reputation import reputation_ledger reputation_ledger.register_node(staker_id, public_key, public_key_algo) except Exception: pass ok, detail = oracle_ledger.place_stake(staker_id, message_id, poster_id, side, amount, duration_days) if ok: try: from services.mesh.mesh_hashchain import infonet normalized_payload = normalize_payload("stake", stake_payload) infonet.append(event_type="stake", node_id=staker_id, payload=normalized_payload, signature=signature, sequence=sequence, public_key=public_key, public_key_algo=public_key_algo, protocol_version=protocol_version) except Exception: pass return {"ok": ok, "detail": detail} @router.get("/api/mesh/oracle/stakes/{message_id}") @limiter.limit("30/minute") async def oracle_stakes_for_message(request: Request, message_id: str): """Get all oracle stakes on a message.""" from services.mesh.mesh_oracle import oracle_ledger return _redact_public_oracle_stakes( oracle_ledger.get_stakes_for_message(message_id), authenticated=_scoped_view_authenticated(request, "mesh.audit"), ) @router.get("/api/mesh/oracle/profile") @limiter.limit("30/minute") async def oracle_profile(request: Request, node_id: str = ""): """Get full oracle profile.""" from services.mesh.mesh_oracle import oracle_ledger if not node_id: return {"ok": False, "detail": "Provide ?node_id=xxx"} profile = oracle_ledger.get_oracle_profile(node_id) return _redact_public_oracle_profile( profile, authenticated=_scoped_view_authenticated(request, "mesh.audit")) @router.get("/api/mesh/oracle/predictions") @limiter.limit("30/minute") async def oracle_predictions(request: Request, node_id: str = ""): """Get a node's active (unresolved) predictions.""" from services.mesh.mesh_oracle import oracle_ledger if not node_id: return {"ok": False, "detail": "Provide ?node_id=xxx"} active_predictions = oracle_ledger.get_active_predictions(node_id) return _redact_public_oracle_predictions( active_predictions, authenticated=_scoped_view_authenticated(request, "mesh.audit")) @router.post( "/api/mesh/oracle/resolve-stakes", dependencies=[Depends(require_admin)], ) @limiter.limit("5/minute") @mesh_write_exempt(MeshWriteExemption.ADMIN_CONTROL) async def oracle_resolve_stakes(request: Request): """Resolve all expired stake contests. Issue #241 (tg12): requires admin authentication. See the note on ``oracle_resolve`` above — ``mesh_write_exempt`` is metadata only. """ from services.mesh.mesh_oracle import oracle_ledger resolutions = oracle_ledger.resolve_expired_stakes() return {"ok": True, "resolutions": resolutions, "count": len(resolutions)}