This commit is contained in:
k
2026-04-21 19:33:24 +08:00
parent 9a72566753
commit 3268080401
26 changed files with 1816 additions and 318 deletions
+152 -12
View File
@@ -1,16 +1,30 @@
import logging
from fastapi import APIRouter, Depends, HTTPException
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
from app.schemas import BotTrade as BotTradeSchema, UserResponse
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(
@@ -22,23 +36,89 @@ def _trade_to_schema(trade: BotTrade) -> BotTradeSchema:
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=trade.opened_at.isoformat(),
closed_at=trade.closed_at.isoformat() if trade.closed_at else "",
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, db: AsyncSession = Depends(get_db)):
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()
result = await db.execute(
select(Subscription).where(Subscription.wallet_address == wallet)
# 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(status_code=404, detail="Wallet not found")
raise HTTPException(404, "Wallet not found")
# Personal trades (all — open and closed)
trades_result = await db.execute(
select(BotTrade)
.where(BotTrade.wallet_address == wallet)
@@ -47,13 +127,73 @@ async def get_user(wallet: str, db: AsyncSession = Depends(get_db)):
)
trades = trades_result.scalars().all()
# Mask hl_api_key: show only last 4 chars if set
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=sub.subscribed_at.isoformat() if sub.subscribed_at else None,
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 150")
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.150")
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.150")
if not (0 <= s.min_confidence <= 100):
raise HTTPException(422, "min_confidence must be 0100")
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