import logging from fastapi import APIRouter, Depends, Header, HTTPException, Query 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, ), ) @router.put("/user/{wallet}/settings", response_model=UserSettings) async def set_user_settings( wallet: str, body: SetSettingsRequest, db: AsyncSession = Depends(get_db), ): wallet = wallet.lower().strip() 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") 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 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 not (0 <= s.min_confidence <= 100): raise HTTPException(422, "min_confidence must be 0–100") verify_signed_request( action=ACTION_SET_SETTINGS, wallet=wallet, timestamp_ms=body.timestamp, signature=body.signature, body=s.model_dump(), ) 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 await db.commit() logger.info("Settings updated for %s: %s", wallet, s.model_dump()) return s