diff --git a/app/api/positions.py b/app/api/positions.py index 77a60e9..253bb19 100644 --- a/app/api/positions.py +++ b/app/api/positions.py @@ -427,21 +427,36 @@ class HLPositionsResponse(BaseModel): @router.get("/positions/hl/{wallet}", response_model=HLPositionsResponse) -async def list_hl_positions_endpoint(wallet: str): +async def list_hl_positions_endpoint( + wallet: str, + ts: int = Query(..., description="Signed timestamp (ms)"), + sig: str = Query(..., description="EIP-191 signature"), +): """Read the wallet's CURRENT Hyperliquid open positions, annotated with - 'already adopted' flag. Used by the Adopt picker on the frontend and by - the Telegram /adopt command. + 'already adopted' flag. Used by the Adopt picker on the frontend. - No auth — same trust model as /positions/open (wallet address IS the - access key). Reading HL state is read-only and harmless. + Signed read — a wallet's live HL positions + adoption status are private, + so this requires the SAME view signature as /positions/open (knowing a + wallet address alone must NOT expose its positions). The Telegram /adopt + command calls the `list_hl_positions` service function directly and is + unaffected by this HTTP-layer check. """ + wallet = wallet.lower().strip() + verify_signed_request_any( + actions=[ACTION_VIEW_POSITIONS, ACTION_VIEW_USER], + wallet=wallet, + timestamp_ms=ts, + signature=sig, + body=None, + allow_replay=True, + ) from app.services.adoption import list_hl_positions, AdoptionError try: items = await list_hl_positions(wallet) except AdoptionError as exc: raise HTTPException(400, exc.message) return HLPositionsResponse( - wallet=wallet.lower().strip(), + wallet=wallet, count=len(items), positions=[HLPositionItem(**vars(it)) for it in items], ) diff --git a/app/api/user.py b/app/api/user.py index c43bbbf..455fe93 100644 --- a/app/api/user.py +++ b/app/api/user.py @@ -156,12 +156,19 @@ async def get_user( 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. + # Masked hint MUST match what set_hl_api_key returned at save time, i.e. + # the last 6 chars of the real key ("...abc123"). Previously this hashed + # the encrypted blob instead, so the suffix shown after a reload differed + # from the one shown right after saving — users thought their key changed. + # We decrypt only to take the last 6 chars (never return the full key). if hl_api_key_set: - import hashlib - masked = "..." + hashlib.sha256(sub.hl_api_key.encode()).hexdigest()[-6:] + try: + from app.services.crypto import decrypt_api_key + masked = f"...{decrypt_api_key(sub.hl_api_key)[-6:]}" + except Exception: + # Decryption failed (rotated KEK, corrupt blob) — don't leak the + # ciphertext or crash settings load; show a neutral "set" marker. + masked = "…(set)" else: masked = None