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.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" 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.") 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) @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} return { "wallet_address": wallet, "active": sub.active, "hl_api_key_set": bool(sub.hl_api_key), } @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, 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, active_from=iso_utc(sub.active_from), active_until=iso_utc(sub.active_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") # Take-profit, stop-loss and daily budget are now MANDATORY — the bot won't # run without them, so we reject null here and force the client to supply values. if s.take_profit_pct is None or not (0.1 <= s.take_profit_pct <= 50): raise HTTPException(422, "take_profit_pct is required (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 (0.1–50)") if not (0 <= s.min_confidence <= 100): raise HTTPException(422, "min_confidence must be 0–100") if s.daily_budget_usd is None or not (0 < s.daily_budget_usd <= 100000): raise HTTPException(422, "daily_budget_usd is required (>0, ≤100,000)") # 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.active_from = af sub.active_until = au await db.commit() logger.info("Settings updated for %s: %s", wallet, s.model_dump()) return s