fix: sign /positions/hl reads, make HL key mask consistent across save/reload

- /positions/hl/{wallet} was unauthenticated — knowing a wallet address alone
  exposed that wallet's live Hyperliquid positions + already_adopted status.
  Its docstring claimed "same trust model as /positions/open", but that route
  IS signed. Now requires the same view signature (view_positions/view_user,
  allow_replay). The Telegram /adopt path calls list_hl_positions() directly,
  so it's unaffected.

- HL API key masked hint was inconsistent: set_hl_api_key returns the real
  key's last 6 chars ("...abc123"), but get_user returned 6 chars of
  sha256(encrypted_blob) — a different string. After a reload the suffix
  changed, making users think their key was altered. get_user now decrypts
  only to take the real last 6 chars (matching save), with a neutral
  "…(set)" fallback if decryption fails.

72 tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
k
2026-05-29 14:59:24 +08:00
parent 6876c0c280
commit 4c34abcd55
2 changed files with 33 additions and 11 deletions
+21 -6
View File
@@ -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],
)