import logging from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.models import BotTrade, Subscription, iso_utc from app.schemas import ( BotTrade as BotTradeSchema, UserResponse, UserSettings, SetApiKeyRequest, SetApiKeyResponse, SetSettingsRequest, ) from app.services.crypto import encrypt_api_key from app.services.hyperliquid import HyperliquidTrader from app.services.signed_request import verify_signed_request router = APIRouter() logger = logging.getLogger(__name__) # Action names — must match frontend exactly (used in the signed message) ACTION_SET_API_KEY = "set_hl_api_key" ACTION_SET_SETTINGS = "set_settings" ACTION_VIEW_USER = "view_user" ACTION_SET_MANUAL_WINDOW = "set_manual_window" ACTION_SET_AUTO_TRADE = "set_auto_trade" async def verify_hl_api_key_can_trade(api_key: str, account_address: str) -> None: """Fail before storing an unusable Hyperliquid API wallet key.""" try: trader = HyperliquidTrader( api_private_key=api_key, account_address=account_address, ) await trader.get_balance() except Exception as exc: raise HTTPException(422, f"Hyperliquid rejected this API key: {exc}") def _trade_to_schema(trade: BotTrade) -> BotTradeSchema: return BotTradeSchema( id=trade.id, asset=trade.asset, side=trade.side, entry_price=trade.entry_price, exit_price=trade.exit_price or 0.0, pnl_usd=trade.pnl_usd or 0.0, hold_seconds=trade.hold_seconds or 0, trigger_post_id=trade.trigger_post_id or 0, opened_at=iso_utc(trade.opened_at) or "", closed_at=iso_utc(trade.closed_at) or "", ) @router.put("/user/{wallet}/hl-api-key", response_model=SetApiKeyResponse) async def set_hl_api_key( wallet: str, body: SetApiKeyRequest, db: AsyncSession = Depends(get_db), ): wallet = wallet.lower().strip() if wallet != body.wallet.lower().strip(): raise HTTPException(400, "Wallet mismatch between path and body") api_key = body.api_key.strip() if not api_key.startswith("0x") or len(api_key) != 66: raise HTTPException(422, "Invalid API key format — must be a 0x-prefixed 64-char hex string") # Signature binds to the full payload (including api_key) so a leaked sig # can't be reused to change the key. verify_signed_request( action=ACTION_SET_API_KEY, wallet=wallet, timestamp_ms=body.timestamp, signature=body.signature, body={"api_key": api_key}, ) result = await db.execute(select(Subscription).where(Subscription.wallet_address == wallet)) sub = result.scalar_one_or_none() if sub is None: raise HTTPException(404, "Wallet not subscribed. Subscribe first.") await verify_hl_api_key_can_trade(api_key=api_key, account_address=wallet) sub.hl_api_key = encrypt_api_key(api_key) await db.commit() masked = f"...{api_key[-6:]}" logger.info("HL API key updated for wallet %s (masked: %s)", wallet, masked) return SetApiKeyResponse(status="ok", masked_key=masked, verified=True) @router.get("/user/{wallet}/public") async def get_user_public(wallet: str, db: AsyncSession = Depends(get_db)): """No-auth endpoint returning only boolean state. Safe to call on every page load to decide whether to show the Subscribe/API-key UI.""" wallet = wallet.lower().strip() result = await db.execute(select(Subscription).where(Subscription.wallet_address == wallet)) sub = result.scalar_one_or_none() if sub is None: return { "wallet_address": wallet, "active": False, "hl_api_key_set": False, "paper_mode": False, "manual_window_until": None, "circuit_breaker_tripped_at": None, "circuit_breaker_reason": None, "auto_trade": False, } return { "wallet_address": wallet, "active": sub.active, "hl_api_key_set": bool(sub.hl_api_key), # Operational state shown on /signals page. Not sensitive. "paper_mode": bool(sub.paper_mode), "manual_window_until": iso_utc(sub.manual_window_until), "circuit_breaker_tripped_at": iso_utc(sub.circuit_breaker_tripped_at), "circuit_breaker_reason": sub.circuit_breaker_reason, "auto_trade": bool(sub.auto_trade), } @router.get("/user/{wallet}", response_model=UserResponse) async def get_user( wallet: str, ts: int = Query(..., description="Signed timestamp (ms)"), sig: str = Query(..., description="EIP-191 signature"), db: AsyncSession = Depends(get_db), ): wallet = wallet.lower().strip() # Require a fresh signed timestamp from the owner — stops wallet-enumeration # and trade-history leakage. Frontend re-signs ~every 5 min via sessionStorage. verify_signed_request( action=ACTION_VIEW_USER, wallet=wallet, timestamp_ms=ts, signature=sig, body=None, allow_replay=True, # idempotent GET — allow sessionStorage caching for UX ) result = await db.execute(select(Subscription).where(Subscription.wallet_address == wallet)) sub = result.scalar_one_or_none() if sub is None: raise HTTPException(404, "Wallet not found") trades_result = await db.execute( select(BotTrade) .where(BotTrade.wallet_address == wallet) .order_by(BotTrade.opened_at.desc()) .limit(50) ) trades = trades_result.scalars().all() hl_api_key_set = bool(sub.hl_api_key) # Never return the encrypted blob or any decrypted key. The masked hint # shown to the user is derived lazily and only from the last 6 chars of # the stored blob's sha-hash, not the plaintext. if hl_api_key_set: import hashlib masked = "..." + hashlib.sha256(sub.hl_api_key.encode()).hexdigest()[-6:] else: masked = None return UserResponse( wallet_address=sub.wallet_address, active=sub.active, subscribed_at=iso_utc(sub.subscribed_at), hl_api_key_set=hl_api_key_set, hl_api_key_masked=masked, paper_mode=bool(sub.paper_mode), trades=[_trade_to_schema(t) for t in trades], settings=UserSettings( leverage=sub.leverage, position_size_usd=sub.position_size_usd, take_profit_pct=sub.take_profit_pct, stop_loss_pct=sub.stop_loss_pct, min_confidence=sub.min_confidence, daily_budget_usd=sub.daily_budget_usd, sys2_leverage=sub.sys2_leverage, sys2_mode=sub.sys2_mode, active_from=iso_utc(sub.active_from), active_until=iso_utc(sub.active_until), trump_enabled=sub.trump_enabled, macro_enabled=sub.macro_enabled, ), manual_window_until=iso_utc(sub.manual_window_until), ) @router.put("/user/{wallet}/settings", response_model=UserSettings) async def set_user_settings( wallet: str, request: Request, db: AsyncSession = Depends(get_db), ): wallet = wallet.lower().strip() # Read raw JSON FIRST — the frontend signs the payload exactly as it's # serialized in JSON (e.g. `5` stays `5`, not `5.0`). If we let Pydantic # coerce ints→floats first, the server-side hash won't match the client's. raw = await request.json() raw_settings = raw.get("settings") if not isinstance(raw_settings, dict): raise HTTPException(422, "Missing settings block") # Now validate with Pydantic body = SetSettingsRequest(**raw) if wallet != body.wallet.lower().strip(): raise HTTPException(400, "Wallet mismatch") s = body.settings if not (1 <= s.leverage <= 50): raise HTTPException(422, "leverage must be 1–50") if not (5 <= s.position_size_usd <= 10000): raise HTTPException(422, "position_size_usd must be $5–$10,000") # TP/SL required only when Trump module is enabled. if s.trump_enabled: if s.take_profit_pct is None or not (0.1 <= s.take_profit_pct <= 50): raise HTTPException(422, "take_profit_pct is required when trump_enabled (0.1–50)") if s.stop_loss_pct is None or not (0.1 <= s.stop_loss_pct <= 50): raise HTTPException(422, "stop_loss_pct is required when trump_enabled (0.1–50)") else: if s.take_profit_pct is not None and not (0.1 <= s.take_profit_pct <= 50): raise HTTPException(422, "take_profit_pct must be 0.1–50 if provided") if s.stop_loss_pct is not None and not (0.1 <= s.stop_loss_pct <= 50): raise HTTPException(422, "stop_loss_pct must be 0.1–50 if provided") if not (0 <= s.min_confidence <= 100): raise HTTPException(422, "min_confidence must be 0–100") if s.sys2_leverage is not None and not (1 <= s.sys2_leverage <= 10): raise HTTPException(422, "sys2_leverage must be 1–10") if s.sys2_mode is not None and s.sys2_mode not in ("standard", "aggressive", ""): raise HTTPException(422, "sys2_mode must be 'standard', 'aggressive', or '' (reset)") if s.daily_budget_usd is not None and not (0 < s.daily_budget_usd <= 100000): raise HTTPException(422, "daily_budget_usd must be >0 and ≤100,000 if provided") # Parse schedule (ISO strings → naive-UTC datetimes). Either both or neither. from datetime import datetime as _dt, timezone as _tz def _parse_iso_utc(v): if v is None: return None try: # Accept "...Z" or "+00:00" forms dt = _dt.fromisoformat(v.replace("Z", "+00:00")) if dt.tzinfo is not None: dt = dt.astimezone(_tz.utc).replace(tzinfo=None) return dt except Exception: raise HTTPException(422, f"invalid ISO datetime: {v!r}") af = _parse_iso_utc(s.active_from) au = _parse_iso_utc(s.active_until) if (af is None) != (au is None): raise HTTPException(422, "active_from and active_until must be set together or both empty") if af is not None and au is not None and au <= af: raise HTTPException(422, "active_until must be after active_from") # Hash the RAW dict (matches what frontend signed) verify_signed_request( action=ACTION_SET_SETTINGS, wallet=wallet, timestamp_ms=body.timestamp, signature=body.signature, body=raw_settings, ) result = await db.execute(select(Subscription).where(Subscription.wallet_address == wallet)) sub = result.scalar_one_or_none() if sub is None: raise HTTPException(404, "Wallet not subscribed") sub.leverage = s.leverage sub.position_size_usd = s.position_size_usd sub.take_profit_pct = s.take_profit_pct sub.stop_loss_pct = s.stop_loss_pct sub.min_confidence = s.min_confidence sub.daily_budget_usd = s.daily_budget_usd sub.sys2_leverage = s.sys2_leverage if s.sys2_mode is not None: # Empty string = reset to default; otherwise store the chosen mode. sub.sys2_mode = "standard" if s.sys2_mode == "" else s.sys2_mode sub.active_from = af sub.active_until = au if s.trump_enabled is not None: sub.trump_enabled = s.trump_enabled if s.macro_enabled is not None: sub.macro_enabled = s.macro_enabled await db.commit() logger.info("Settings updated for %s: %s", wallet, s.model_dump()) return s # ─── Manual window (convex-strategy "enable for N hours" override) ─────────── @router.post("/user/{wallet}/manual-window") async def set_manual_window( wallet: str, request: Request, db: AsyncSession = Depends(get_db), ): """Arm the bot for the next N hours, overriding the active_from/until schedule. Body: { wallet, timestamp, signature, hours } hours: integer 0–168. Pass 0 to clear (immediate disarm). The bot's main gate (Subscription.active) still applies. This endpoint only flips the schedule override; an inactive subscription stays paused. """ from datetime import datetime as _dt, timezone as _tz, timedelta as _td wallet = wallet.lower().strip() raw = await request.json() # Manual auth payload — keep deliberately minimal so the signed string is # short and easy to reason about. body_wallet = (raw.get("wallet") or "").lower().strip() timestamp = raw.get("timestamp") signature = raw.get("signature") hours_raw = raw.get("hours") if body_wallet != wallet: raise HTTPException(400, "Wallet mismatch") if not isinstance(timestamp, int): raise HTTPException(422, "timestamp (ms) required") if not isinstance(signature, str) or not signature: raise HTTPException(422, "signature required") if not isinstance(hours_raw, int) or hours_raw < 0 or hours_raw > 168: raise HTTPException(422, "hours must be 0–168") verify_signed_request( action=ACTION_SET_MANUAL_WINDOW, wallet=wallet, timestamp_ms=timestamp, signature=signature, body={"hours": hours_raw}, ) result = await db.execute(select(Subscription).where(Subscription.wallet_address == wallet)) sub = result.scalar_one_or_none() if sub is None: raise HTTPException(404, "Wallet not subscribed") if hours_raw == 0: sub.manual_window_until = None new_until_iso = None logger.info("Manual window cleared for %s", wallet) else: until = _dt.now(_tz.utc).replace(tzinfo=None) + _td(hours=hours_raw) sub.manual_window_until = until new_until_iso = iso_utc(until) # Explicit re-arm clears any active circuit-breaker trip on BOTH # systems — the human ack covers the whole wallet. (Earlier only the # sys1 breaker was cleared, leaving the sys2 book locked for 24h.) cb_cleared = False for _col_at, _col_reason in ( ("circuit_breaker_tripped_at", "circuit_breaker_reason"), ("sys2_cb_tripped_at", "sys2_cb_reason"), ): if getattr(sub, _col_at) is not None: setattr(sub, _col_at, None) setattr(sub, _col_reason, None) cb_cleared = True logger.info("Manual window armed for %s: until %s (%dh)%s", wallet, until, hours_raw, " — CB cleared" if cb_cleared else "") await db.commit() return {"manual_window_until": new_until_iso} # ─── Master Auto-Trade switch (simplified operator model) ─────────────────── @router.post("/user/{wallet}/auto-trade") async def set_auto_trade( wallet: str, request: Request, db: AsyncSession = Depends(get_db), ): """Flip the ONE persistent Auto-Trade gate. Body: { wallet, timestamp, signature, enabled } enabled=false → signals are still ingested/shown but NO trade opens. enabled=true → qualifying signals auto-open trades. Turning it ON also acknowledges + clears a tripped circuit breaker (human-in-the-loop), mirroring the old manual-window arm. """ wallet = wallet.lower().strip() raw = await request.json() body_wallet = (raw.get("wallet") or "").lower().strip() timestamp = raw.get("timestamp") signature = raw.get("signature") enabled = raw.get("enabled") if body_wallet != wallet: raise HTTPException(400, "Wallet mismatch") if not isinstance(timestamp, int): raise HTTPException(422, "timestamp (ms) required") if not isinstance(signature, str) or not signature: raise HTTPException(422, "signature required") if not isinstance(enabled, bool): raise HTTPException(422, "enabled (bool) required") verify_signed_request( action=ACTION_SET_AUTO_TRADE, wallet=wallet, timestamp_ms=timestamp, signature=signature, body={"enabled": enabled}, ) result = await db.execute(select(Subscription).where(Subscription.wallet_address == wallet)) sub = result.scalar_one_or_none() if sub is None: raise HTTPException(404, "Wallet not subscribed") sub.auto_trade = enabled cb_cleared = False if enabled: # Turning Auto-Trade ON = explicit human ack → clear BOTH breakers # (sys1 Trump + sys2 reversal). Previously only sys1 was cleared, # leaving the reversal book locked even after the user re-armed. for _col_at, _col_reason in ( ("circuit_breaker_tripped_at", "circuit_breaker_reason"), ("sys2_cb_tripped_at", "sys2_cb_reason"), ): if getattr(sub, _col_at) is not None: setattr(sub, _col_at, None) setattr(sub, _col_reason, None) cb_cleared = True await db.commit() logger.info("Auto-Trade %s for %s%s", "ON" if enabled else "OFF", wallet, " — CB cleared" if cb_cleared else "") return {"auto_trade": enabled, "circuit_breaker_cleared": cb_cleared}