""" Scanner control endpoints — kill switch + per-scanner toggle + status. GET /scanners is public (read-only status, polled by the UI). ALL mutating endpoints (toggle / all-disable / all-enable) require a SIGNED request from a SUBSCRIBED wallet. Rationale: the scanner switch is a global control — flipping it OFF stops the signal engine for every user. Before, it was unauthenticated and any anonymous visitor could kill it. Now the caller must prove wallet ownership (EIP-191 signature) AND already be a subscriber. # See what's running (still public) curl http://localhost:8000/api/scanners Mutations are no longer curl-able without a wallet signature — that's the point. Use the dashboard (which signs via the connected wallet). """ from __future__ import annotations import logging from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.models import Subscription from app.services import scanner_state from app.services.signed_request import verify_signed_request router = APIRouter() logger = logging.getLogger(__name__) ACTION_TOGGLE_SCANNER = "toggle_scanner" ACTION_SCANNER_KILL = "scanner_kill_all" ACTION_SCANNER_REVIVE = "scanner_revive_all" class ScannerSummary(BaseModel): name: str enabled: bool last_run_at: Optional[str] = None last_status: str last_message: Optional[str] = None last_fired_at: Optional[str] = None total_runs: int total_fires: int consecutive_errors: int class ScannersResponse(BaseModel): count: int enabled: int scanners: list[ScannerSummary] class ToggleResponse(BaseModel): status: str name: Optional[str] = None enabled: Optional[bool] = None count: Optional[int] = None async def _require_subscribed_signer( request: Request, db: AsyncSession, *, action: str, body: Optional[dict], ) -> str: """Verify the signed envelope and that the signer is a subscriber. Expects JSON body: { wallet, timestamp, signature, ...body fields }. `body` is the canonical signed payload (must match what the client hashed) — pass None for no-body actions (kill/revive). Returns the verified lower-cased wallet. """ raw = await request.json() wallet = (raw.get("wallet") or "").lower().strip() timestamp = raw.get("timestamp") signature = raw.get("signature") if not wallet: raise HTTPException(422, "wallet required") if not isinstance(timestamp, int): raise HTTPException(422, "timestamp (ms) required") if not isinstance(signature, str) or not signature: raise HTTPException(422, "signature required") verify_signed_request( action=action, wallet=wallet, timestamp_ms=timestamp, signature=signature, body=body, ) sub = (await db.execute( select(Subscription).where(Subscription.wallet_address == wallet) )).scalar_one_or_none() if sub is None: raise HTTPException(403, "Wallet is not a subscriber — scanner control denied") return wallet @router.get("/scanners", response_model=ScannersResponse) async def list_scanners() -> ScannersResponse: """Operational snapshot of every registered scanner. Polled by the UI every 15-30s. Idempotent + cheap (in-memory dict read). Public.""" all_states = scanner_state.get_all() return ScannersResponse( count=len(all_states), enabled=sum(1 for s in all_states if s.enabled), scanners=[ScannerSummary(**s.to_dict()) for s in all_states], ) @router.post("/scanners/{name}/toggle", response_model=ToggleResponse) async def toggle_scanner( name: str, request: Request, db: AsyncSession = Depends(get_db), ) -> ToggleResponse: """Flip ONE scanner. Signed + subscriber-gated. Body: { wallet, timestamp, signature, name, enabled } The signed body is { "enabled": , "name": }. """ raw = await request.json() enabled = raw.get("enabled") body_name = raw.get("name") if not isinstance(enabled, bool): raise HTTPException(422, "enabled (bool) required") if body_name != name: raise HTTPException(400, "scanner name mismatch (path vs signed body)") # NOTE: request.json() is cached by Starlette, so re-reading inside the # helper returns the same payload. wallet = await _require_subscribed_signer( request, db, action=ACTION_TOGGLE_SCANNER, body={"enabled": enabled, "name": name}, ) s = scanner_state.set_enabled(name, enabled) if s is None: raise HTTPException(404, f"unknown scanner {name!r}") logger.info("Scanner %s set enabled=%s by %s", name, s.enabled, wallet) return ToggleResponse(status="ok", name=name, enabled=s.enabled) @router.post("/scanners/all/disable", response_model=ToggleResponse) async def kill_switch( request: Request, db: AsyncSession = Depends(get_db), ) -> ToggleResponse: """Emergency stop — disables ALL scanners. Signed + subscriber-gated. Body: { wallet, timestamp, signature }. Does NOT close open positions — manage those via /positions/{id}/close or the Trades page.""" wallet = await _require_subscribed_signer( request, db, action=ACTION_SCANNER_KILL, body=None, ) n = scanner_state.disable_all() logger.warning("ALL scanners disabled (%d) by %s", n, wallet) return ToggleResponse(status="killed", count=n) @router.post("/scanners/all/enable", response_model=ToggleResponse) async def revive_all( request: Request, db: AsyncSession = Depends(get_db), ) -> ToggleResponse: """Re-enable every scanner that was disabled. Signed + subscriber-gated. Body: { wallet, timestamp, signature }.""" wallet = await _require_subscribed_signer( request, db, action=ACTION_SCANNER_REVIVE, body=None, ) n = scanner_state.enable_all() logger.info("ALL scanners re-enabled (%d) by %s", n, wallet) return ToggleResponse(status="revived", count=n)