improve signed reads, crypto hardening, and scraper transport

This commit is contained in:
k
2026-06-14 21:43:43 +08:00
parent 54884f3e24
commit 78fb63be8e
27 changed files with 1326 additions and 202 deletions
+10
View File
@@ -244,6 +244,16 @@ async def _adopt_locked(wallet_l: str, asset_u: str, mode_n: str) -> AdoptionRes
"no Hyperliquid position to manage. Turn off paper mode in "
"Settings to use /adopt.")
# Macro Vibes (System-2) must be ENABLED to hand a position to the bot.
# /adopt is the entry point to sys2 management, so honouring the
# macro_enabled switch here is what makes that toggle real — otherwise
# a user who turned Macro Vibes OFF could still /adopt and the bot would
# manage it, contradicting their setting.
if not getattr(sub, "macro_enabled", False):
raise AdoptionError("macro_disabled",
"Macro Vibes is turned off for this wallet. Enable Macro Vibes "
"in Settings before using /adopt.")
# System-2 circuit breaker. Same gate the auto-open path used to run:
# if recent losses tripped the sys2 breaker, block new adoptions for
# the lockout window. Otherwise the breaker would be useless under
+13 -14
View File
@@ -4,7 +4,6 @@ import logging
from datetime import datetime, timezone
from typing import Optional
import httpx
import websockets
from app.config import settings
@@ -127,19 +126,19 @@ async def fetch_historical(asset: str, symbol: str, interval: str = "1m", limit:
"""Fetch historical klines from Binance REST API to pre-fill price_store."""
url = f"{settings.binance_rest_url}/api/v3/klines?symbol={symbol.upper()}&interval={interval}&limit={limit}"
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(url)
resp.raise_for_status()
for row in resp.json():
candle = {
"time": row[0], # open time ms
"open": float(row[1]),
"high": float(row[2]),
"low": float(row[3]),
"close": float(row[4]),
"volume": float(row[5]),
}
price_store.update(asset, candle)
from app.services.http_client import get_client
resp = await get_client().get(url, timeout=15)
resp.raise_for_status()
for row in resp.json():
candle = {
"time": row[0], # open time ms
"open": float(row[1]),
"high": float(row[2]),
"low": float(row[3]),
"close": float(row[4]),
"volume": float(row[5]),
}
price_store.update(asset, candle)
logger.info("Loaded %d historical %s candles for %s", limit, interval, asset)
except Exception as exc:
logger.error("Failed to fetch historical data for %s: %s", asset, exc)
+107 -28
View File
@@ -1,62 +1,141 @@
"""
Envelope encryption for HL API private keys.
Plaintext keys never touch disk: stored values are Fernet-encrypted with a KEK
loaded from env (ENCRYPTION_KEY). Rotate KEK → re-encrypt all keys offline.
Plaintext keys never touch disk: stored values are Fernet-encrypted with a key
derived from the env KEK (ENCRYPTION_KEY).
Blob formats:
enc:v2:<salt_b64url>:<fernet_token> — current. Per-blob random 16-byte
salt; Fernet key = PBKDF2-HMAC-SHA256(KEK, salt, 600k iters). Derived
keys are cached per salt so steady-state decryption pays the KDF once.
enc:v1:<fernet_token> — legacy. Fernet key = single unsalted
SHA-256(KEK). Read-only: decrypt still works, encrypt always writes v2.
Upgrade rows with scripts/reencrypt_keys.py (H4 fix).
<plaintext> — pre-encryption rows. Refused in prod.
Rotate KEK → re-encrypt all keys offline (scripts/reencrypt_keys.py).
"""
import base64
import hashlib
import logging
from typing import Optional
import os
from typing import Dict, Optional
from cryptography.fernet import Fernet, InvalidToken
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from app.config import settings
logger = logging.getLogger(__name__)
# OWASP-recommended floor for PBKDF2-HMAC-SHA256. The KEK is already a
# high-entropy secret, so the KDF mainly buys defence-in-depth against a
# weak / partially-leaked / reused KEK. ~0.2-0.4s, paid once per unique salt.
_PBKDF2_ITERATIONS = 600_000
_SALT_BYTES = 16
def _derive_fernet_key(raw: str) -> bytes:
"""Accept any reasonably-long secret and derive a valid 32-byte Fernet key."""
ENC_PREFIX_V1 = "enc:v1:"
ENC_PREFIX_V2 = "enc:v2:"
# Kept for older imports / scripts that reference the original name.
ENC_PREFIX = ENC_PREFIX_V1
def _check_kek(raw: str) -> str:
if not raw or len(raw) < 32:
raise RuntimeError(
"ENCRYPTION_KEY must be set to at least 32 random chars (e.g. `openssl rand -hex 32`)"
)
digest = hashlib.sha256(raw.encode("utf-8")).digest()
return raw
def _derive_v1_key(raw: str) -> bytes:
"""Legacy: single unsalted SHA-256 of the KEK."""
digest = hashlib.sha256(_check_kek(raw).encode("utf-8")).digest()
return base64.urlsafe_b64encode(digest)
_fernet: Optional[Fernet] = None
def _derive_v2_key(raw: str, salt: bytes) -> bytes:
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=salt,
iterations=_PBKDF2_ITERATIONS,
)
return base64.urlsafe_b64encode(kdf.derive(_check_kek(raw).encode("utf-8")))
def _cipher() -> Fernet:
global _fernet
if _fernet is None:
_fernet = Fernet(_derive_fernet_key(settings.encryption_key))
return _fernet
_fernet_v1: Optional[Fernet] = None
# salt → Fernet. One entry per unique salt actually seen: encryption reuses a
# single process-lifetime salt, decryption adds one per distinct stored blob.
_fernet_v2_cache: Dict[bytes, Fernet] = {}
_V2_CACHE_MAX = 4096
# Salt for NEW encryptions in this process — generated once so the encrypt
# path pays the 600k-iteration KDF a single time per process, while blobs
# written by other processes/runs still carry their own salt.
_encrypt_salt: Optional[bytes] = None
# Prefix lets us distinguish encrypted blobs from any legacy plaintext rows during migration
ENC_PREFIX = "enc:v1:"
def _cipher_v1() -> Fernet:
global _fernet_v1
if _fernet_v1 is None:
_fernet_v1 = Fernet(_derive_v1_key(settings.encryption_key))
return _fernet_v1
def _cipher_v2(salt: bytes) -> Fernet:
cached = _fernet_v2_cache.get(salt)
if cached is not None:
return cached
f = Fernet(_derive_v2_key(settings.encryption_key, salt))
if len(_fernet_v2_cache) >= _V2_CACHE_MAX:
_fernet_v2_cache.clear()
_fernet_v2_cache[salt] = f
return f
def encrypt_api_key(plaintext: str) -> str:
token = _cipher().encrypt(plaintext.encode("utf-8")).decode("utf-8")
return ENC_PREFIX + token
global _encrypt_salt
if _encrypt_salt is None:
_encrypt_salt = os.urandom(_SALT_BYTES)
salt_b64 = base64.urlsafe_b64encode(_encrypt_salt).decode("ascii")
token = _cipher_v2(_encrypt_salt).encrypt(plaintext.encode("utf-8")).decode("utf-8")
return f"{ENC_PREFIX_V2}{salt_b64}:{token}"
def decrypt_api_key(stored: str) -> str:
if not stored:
raise ValueError("Empty api key")
if not stored.startswith(ENC_PREFIX):
# Legacy plaintext row (from before encryption was added). Refuse to use in prod.
if settings.environment == "production":
raise RuntimeError(
"Found legacy-plaintext HL key; run migration script before production"
)
logger.warning("Reading LEGACY plaintext HL key — migrate ASAP")
return stored
try:
return _cipher().decrypt(stored[len(ENC_PREFIX):].encode("utf-8")).decode("utf-8")
except InvalidToken as exc:
raise RuntimeError("HL key decryption failed — wrong ENCRYPTION_KEY?") from exc
if stored.startswith(ENC_PREFIX_V2):
rest = stored[len(ENC_PREFIX_V2):]
try:
salt_b64, token = rest.split(":", 1)
salt = base64.urlsafe_b64decode(salt_b64.encode("ascii"))
except Exception as exc:
raise RuntimeError("Malformed enc:v2 blob") from exc
try:
return _cipher_v2(salt).decrypt(token.encode("utf-8")).decode("utf-8")
except InvalidToken as exc:
raise RuntimeError("HL key decryption failed — wrong ENCRYPTION_KEY?") from exc
if stored.startswith(ENC_PREFIX_V1):
try:
return _cipher_v1().decrypt(
stored[len(ENC_PREFIX_V1):].encode("utf-8")).decode("utf-8")
except InvalidToken as exc:
raise RuntimeError("HL key decryption failed — wrong ENCRYPTION_KEY?") from exc
# Legacy plaintext row (from before encryption was added). Refuse to use in prod.
if settings.environment == "production":
raise RuntimeError(
"Found legacy-plaintext HL key; run migration script before production"
)
logger.warning("Reading LEGACY plaintext HL key — migrate ASAP")
return stored
def is_current_format(stored: Optional[str]) -> bool:
"""True if the blob is already enc:v2 (used by scripts/reencrypt_keys.py)."""
return bool(stored) and stored.startswith(ENC_PREFIX_V2)
+4 -5
View File
@@ -31,7 +31,6 @@ import time
from datetime import datetime, timezone
from typing import Optional
import httpx
from app.services.price_store import price_store
from app.ws.manager import manager
@@ -96,10 +95,10 @@ async def _tick() -> None:
"""Single price fetch + dispatch cycle for all HL_PRICE_ASSETS."""
now_ms = int(time.time() * 1000)
async with httpx.AsyncClient(timeout=4.0) as c:
r = await c.post(HL_API_URL, json={"type": "allMids"})
r.raise_for_status()
mids: dict = r.json() # {"BTC": "74541.0", "HYPE": "13.5", …}
from app.services.http_client import get_client
r = await get_client().post(HL_API_URL, json={"type": "allMids"}, timeout=4.0)
r.raise_for_status()
mids: dict = r.json() # {"BTC": "74541.0", "HYPE": "13.5", …}
# Feed is alive the moment we successfully fetch mids, even if a specific
# asset is momentarily absent from the response.
+37
View File
@@ -0,0 +1,37 @@
"""Shared pooled httpx.AsyncClient.
Hot paths (scrapers, Telegram send/poll, price feeds, X poster) used to build
a fresh AsyncClient per request, paying a TCP+TLS handshake every time. This
module owns one process-wide client with keep-alive pooling; callers override
the timeout per request (`client.get(url, timeout=10)`).
Lifecycle: lazily created on first use; main.py's lifespan closes it on
shutdown. Low-frequency daily jobs (KOL/macro fetchers) may keep their own
ad-hoc clients — pooling only matters on the per-second paths.
"""
import logging
from typing import Optional
import httpx
logger = logging.getLogger(__name__)
_client: Optional[httpx.AsyncClient] = None
def get_client() -> httpx.AsyncClient:
global _client
if _client is None or _client.is_closed:
_client = httpx.AsyncClient(
timeout=httpx.Timeout(20.0),
follow_redirects=True,
limits=httpx.Limits(max_connections=50, max_keepalive_connections=20),
)
return _client
async def aclose() -> None:
global _client
if _client is not None and not _client.is_closed:
await _client.aclose()
_client = None
+29
View File
@@ -315,6 +315,8 @@ async def extract_kol_signal(
if post_type not in valid_post_types:
post_type = "other"
tier = _derive_tier(cleaned, tvt_score)
return {
"summary": (data.get("summary") or "").strip() or None,
"post_type": post_type,
@@ -322,6 +324,33 @@ async def extract_kol_signal(
"talks_vs_trades_score": tvt_score,
# Keep old boolean for any callers that still check it
"talks_vs_trades_flag": tvt_score >= 0.5,
# tier mirrors x_analysis' vocabulary (trade_signal / directional /
# noise) so blog/substack/podcast posts get the same SIGNAL/VIEW UI
# badges + the "Signals only" filter that Twitter posts already have.
"tier": tier,
"model": model,
"version": ANALYSIS_VERSION,
}
def _derive_tier(tickers: list[dict], tvt_score: float) -> str:
"""Map kol_analysis output → the trade_signal/directional/noise tiers that
x_analysis emits directly. Non-Twitter analyzers don't ask the model for a
tier, so we derive one from the per-ticker conviction + the talks-vs-trades
(divergence) score. Without this, blog/substack/podcast rows have tier=NULL
and the "Signals only" filter + SIGNAL/VIEW badges never apply to them.
* directional ticker = an explicit non-"mention" action (buy/sell/
reduce/bullish/bearish).
* trade_signal = high conviction (>= 0.6) on a directional ticker, or a
strong talks-vs-trades divergence (>= 0.6) — the platform's top signal.
* directional = a directional view exists but below the trade_signal bar.
* noise = no directional ticker and no notable divergence.
"""
directional = [t for t in tickers if (t.get("action") or "mention") != "mention"]
max_conv = max((float(t.get("conviction") or 0) for t in directional), default=0.0)
if max_conv >= 0.6 or tvt_score >= 0.6:
return "trade_signal"
if directional or tvt_score >= 0.5:
return "directional"
return "noise"
+3
View File
@@ -468,6 +468,9 @@ async def _ingest_kol(
# Extended analysis fields (migration 027)
row.post_type = result.get("post_type")
row.talks_vs_trades_flag = bool(result.get("talks_vs_trades_flag", False))
# tier (trade_signal/directional/noise) so the "Signals only"
# filter + SIGNAL/VIEW badges work for non-Twitter KOLs too.
row.tier = result.get("tier")
stats["analyzed"] += 1
except Exception as e:
logger.warning("[kol_substack] analysis failed for %s post %s: %s",
+55
View File
@@ -144,3 +144,58 @@ def verify_signed_request_any(
last_exc = exc
if last_exc is not None:
raise last_exc
# ── Signed-read credential extraction (C3) ────────────────────────────────
# Read endpoints historically took the signature as `?ts=&sig=` query params,
# which leaks signatures into access logs / proxies / browser history. The
# canonical transport is now the X-Sig-Ts / X-Sig-Sig HEADERS; the query
# params are kept as a deprecated fallback so older clients keep working.
from dataclasses import dataclass
from fastapi import Header, Query
@dataclass
class SignedReadCreds:
ts: int
sig: str
def signed_read_creds(
ts: Optional[int] = Query(default=None, deprecated=True,
description="DEPRECATED — use X-Sig-Ts header"),
sig: Optional[str] = Query(default=None, deprecated=True,
description="DEPRECATED — use X-Sig-Sig header"),
x_sig_ts: Optional[int] = Header(default=None, alias="X-Sig-Ts",
description="Signed timestamp (ms)"),
x_sig_sig: Optional[str] = Header(default=None, alias="X-Sig-Sig",
description="EIP-191 signature"),
) -> SignedReadCreds:
"""FastAPI dependency: required signed-read credentials.
Headers win over query params when both are present."""
t = x_sig_ts if x_sig_ts is not None else ts
s = x_sig_sig if x_sig_sig else sig
if t is None or not s:
raise HTTPException(
422, "Missing signed-read credentials (X-Sig-Ts / X-Sig-Sig headers)")
return SignedReadCreds(ts=t, sig=s)
def optional_signed_read_creds(
# Legacy query names used by /telegram/{wallet}/status.
timestamp: Optional[int] = Query(default=None, deprecated=True,
description="DEPRECATED — use X-Sig-Ts header"),
signature: Optional[str] = Query(default=None, deprecated=True,
description="DEPRECATED — use X-Sig-Sig header"),
x_sig_ts: Optional[int] = Header(default=None, alias="X-Sig-Ts"),
x_sig_sig: Optional[str] = Header(default=None, alias="X-Sig-Sig"),
) -> Optional[SignedReadCreds]:
"""Like signed_read_creds but returns None when absent — for endpoints
that serve a redacted response to unauthenticated callers."""
t = x_sig_ts if x_sig_ts is not None else timestamp
s = x_sig_sig if x_sig_sig else signature
if t is None or not s:
return None
return SignedReadCreds(ts=t, sig=s)
+6 -7
View File
@@ -27,7 +27,6 @@ import logging
from datetime import datetime, timezone
from typing import Optional
import httpx
from sqlalchemy import select, update
from app.config import settings
@@ -274,8 +273,8 @@ async def send_message(chat_id: int | str, text: str, *,
if reply_markup is not None:
payload["reply_markup"] = reply_markup
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(url, json=payload)
from app.services.http_client import get_client
r = await get_client().post(url, json=payload, timeout=10)
if r.status_code != 200:
logger.warning("Telegram sendMessage failed chat=%s status=%d body=%s",
chat_id, r.status_code, r.text[:200])
@@ -302,8 +301,8 @@ async def edit_message(chat_id: int, message_id: int, text: str, *,
if reply_markup is not None:
payload["reply_markup"] = reply_markup
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(url, json=payload)
from app.services.http_client import get_client
r = await get_client().post(url, json=payload, timeout=10)
if r.status_code != 200:
# Telegram returns 400 on "message is not modified" — harmless.
if "message is not modified" not in r.text:
@@ -331,8 +330,8 @@ async def answer_callback(callback_query_id: str, text: str = "",
payload["text"] = text
payload["show_alert"] = show_alert
try:
async with httpx.AsyncClient(timeout=10) as client:
await client.post(url, json=payload)
from app.services.http_client import get_client
await get_client().post(url, json=payload, timeout=10)
return True
except Exception as exc:
logger.debug("Telegram answerCallback exception: %s", exc)
+5 -6
View File
@@ -1007,15 +1007,14 @@ async def run_bot_loop() -> None:
# them without processing, then start the real loop fresh.
try:
drain_url = TG_API.format(token=token, method="getUpdates")
async with httpx.AsyncClient(timeout=10) as client:
r = await client.get(drain_url, params={"timeout": 0, "limit": 100})
from app.services.http_client import get_client
r = await get_client().get(drain_url, params={"timeout": 0, "limit": 100}, timeout=10)
if r.status_code == 200:
pending = r.json().get("result", [])
if pending:
drain_offset = pending[-1]["update_id"] + 1
# ACK by sending offset back — Telegram won't re-deliver these.
async with httpx.AsyncClient(timeout=10) as client:
await client.get(drain_url, params={"timeout": 0, "offset": drain_offset})
await get_client().get(drain_url, params={"timeout": 0, "offset": drain_offset}, timeout=10)
logger.info(
"Startup drain: skipped %d stale update(s), offset now %d",
len(pending), drain_offset,
@@ -1032,8 +1031,8 @@ async def run_bot_loop() -> None:
params: dict = {"timeout": 25}
if offset is not None:
params["offset"] = offset
async with httpx.AsyncClient(timeout=35) as client:
r = await client.get(url, params=params)
from app.services.http_client import get_client
r = await get_client().get(url, params=params, timeout=35)
if r.status_code != 200:
logger.warning("Telegram getUpdates HTTP %d: %s", r.status_code, r.text[:200])
await asyncio.sleep(backoff)
+2 -3
View File
@@ -33,7 +33,6 @@ import urllib.parse
from datetime import datetime, timezone
from typing import Optional
import httpx
from sqlalchemy import select
from app.config import settings
@@ -146,8 +145,8 @@ async def _post_tweet(text: str, reply_to: Optional[str] = None) -> Optional[str
"Content-Type": "application/json",
}
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(TWEET_URL, json=payload, headers=headers)
from app.services.http_client import get_client
resp = await get_client().post(TWEET_URL, json=payload, headers=headers, timeout=10)
if resp.status_code in (200, 201):
_record_sent()
tid = resp.json().get("data", {}).get("id")