Pre-launch hardening: KOL module, Telegram, scanners, WS resilience
Big-picture changes since b941223:
KOL pipeline (new) — Substack/podcast/blog RSS → AI ticker extraction →
on-chain wallet diff → talks-vs-trades divergence detection. Daily polls,
19 feeds, divergence emits Post + Telegram fan-out.
Telegram push (new) — walletless free tier + wallet-linked Pro upgrade,
in-bot preference commands (/trump /btc /funding /kol /conf /quiet),
signed-envelope API for dashboard. Disconnect-wallet keeps free
subscription.
BTC funding-rate reversal scanner (new) — hourly cron, 30d cumulative
funding threshold + mean-revert + 7d price confirm, emits via
/api/signals/ingest. BTC bottom-reversal scanner promoted to System 2.
WS broadcast rewrite — per-client send timeout + parallel fan-out
(asyncio.gather). Fixes "Binance WS no close frame" reconnect storms +
APScheduler 11-min job misses, both caused by one slow client stalling
the kline loop.
Error visibility — three silent-error sites (trumpstruth/truth_social
fetchers, funding_reversal scanner) now include exception type name so
httpx ConnectError-style empty-message errors stop logging blank lines.
Telegram bot loop now classifies ReadTimeout vs network vs unknown +
logger.exception for the unknown bucket.
Security hygiene — trumpsignal.db untracked from git (held subscriber
wallets + encrypted HL keys + 22 bot trades); .gitignore now blocks
*.db/.next/backups. CORS only allows FRONTEND_URL in production.
New ops scripts —
- scripts/preflight.py: env/DB/Telegram/AI auth verification gate
- scripts/backup_db.sh: cron-friendly daily DB backup (SQLite + Postgres)
- scripts/seed_kol_wallets.py: idempotent KOL on-chain wallet seeder
15 new Alembic migrations (007-021) covering convex strategy fields,
phase-1 safety, two-system frozen exits, invalidation prices, dynamic
SYS2 leverage, staged de-risk + pyramiding, peak gain tracking, risk
mode, auto-trade + grow flags, KOL module, KOL on-chain, KOL divergence,
Telegram bindings + walletless.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
"""
|
||||
Scanner control endpoints — kill switch + per-scanner toggle + status.
|
||||
|
||||
GET /scanners is public (read-only status, polled by the UI).
|
||||
|
||||
ALL mutating endpoints (toggle / all-disable / all-enable) require a SIGNED
|
||||
request from a SUBSCRIBED wallet. Rationale: the scanner switch is a global
|
||||
control — flipping it OFF stops the signal engine for every user. Before, it
|
||||
was unauthenticated and any anonymous visitor could kill it. Now the caller
|
||||
must prove wallet ownership (EIP-191 signature) AND already be a subscriber.
|
||||
|
||||
# See what's running (still public)
|
||||
curl http://localhost:8000/api/scanners
|
||||
|
||||
Mutations are no longer curl-able without a wallet signature — that's the
|
||||
point. Use the dashboard (which signs via the connected wallet).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Subscription
|
||||
from app.services import scanner_state
|
||||
from app.services.signed_request import verify_signed_request
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ACTION_TOGGLE_SCANNER = "toggle_scanner"
|
||||
ACTION_SCANNER_KILL = "scanner_kill_all"
|
||||
ACTION_SCANNER_REVIVE = "scanner_revive_all"
|
||||
|
||||
|
||||
class ScannerSummary(BaseModel):
|
||||
name: str
|
||||
enabled: bool
|
||||
last_run_at: Optional[str] = None
|
||||
last_status: str
|
||||
last_message: Optional[str] = None
|
||||
last_fired_at: Optional[str] = None
|
||||
total_runs: int
|
||||
total_fires: int
|
||||
consecutive_errors: int
|
||||
|
||||
|
||||
class ScannersResponse(BaseModel):
|
||||
count: int
|
||||
enabled: int
|
||||
scanners: list[ScannerSummary]
|
||||
|
||||
|
||||
class ToggleResponse(BaseModel):
|
||||
status: str
|
||||
name: Optional[str] = None
|
||||
enabled: Optional[bool] = None
|
||||
count: Optional[int] = None
|
||||
|
||||
|
||||
async def _require_subscribed_signer(
|
||||
request: Request,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
action: str,
|
||||
body: Optional[dict],
|
||||
) -> str:
|
||||
"""Verify the signed envelope and that the signer is a subscriber.
|
||||
|
||||
Expects JSON body: { wallet, timestamp, signature, ...body fields }.
|
||||
`body` is the canonical signed payload (must match what the client
|
||||
hashed) — pass None for no-body actions (kill/revive).
|
||||
Returns the verified lower-cased wallet.
|
||||
"""
|
||||
raw = await request.json()
|
||||
wallet = (raw.get("wallet") or "").lower().strip()
|
||||
timestamp = raw.get("timestamp")
|
||||
signature = raw.get("signature")
|
||||
|
||||
if not wallet:
|
||||
raise HTTPException(422, "wallet required")
|
||||
if not isinstance(timestamp, int):
|
||||
raise HTTPException(422, "timestamp (ms) required")
|
||||
if not isinstance(signature, str) or not signature:
|
||||
raise HTTPException(422, "signature required")
|
||||
|
||||
verify_signed_request(
|
||||
action=action,
|
||||
wallet=wallet,
|
||||
timestamp_ms=timestamp,
|
||||
signature=signature,
|
||||
body=body,
|
||||
)
|
||||
|
||||
sub = (await db.execute(
|
||||
select(Subscription).where(Subscription.wallet_address == wallet)
|
||||
)).scalar_one_or_none()
|
||||
if sub is None:
|
||||
raise HTTPException(403, "Wallet is not a subscriber — scanner control denied")
|
||||
return wallet
|
||||
|
||||
|
||||
@router.get("/scanners", response_model=ScannersResponse)
|
||||
async def list_scanners() -> ScannersResponse:
|
||||
"""Operational snapshot of every registered scanner. Polled by the UI
|
||||
every 15-30s. Idempotent + cheap (in-memory dict read). Public."""
|
||||
all_states = scanner_state.get_all()
|
||||
return ScannersResponse(
|
||||
count=len(all_states),
|
||||
enabled=sum(1 for s in all_states if s.enabled),
|
||||
scanners=[ScannerSummary(**s.to_dict()) for s in all_states],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/scanners/{name}/toggle", response_model=ToggleResponse)
|
||||
async def toggle_scanner(
|
||||
name: str,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ToggleResponse:
|
||||
"""Flip ONE scanner. Signed + subscriber-gated.
|
||||
|
||||
Body: { wallet, timestamp, signature, name, enabled }
|
||||
The signed body is { "enabled": <bool>, "name": <str> }.
|
||||
"""
|
||||
raw = await request.json()
|
||||
enabled = raw.get("enabled")
|
||||
body_name = raw.get("name")
|
||||
if not isinstance(enabled, bool):
|
||||
raise HTTPException(422, "enabled (bool) required")
|
||||
if body_name != name:
|
||||
raise HTTPException(400, "scanner name mismatch (path vs signed body)")
|
||||
|
||||
# NOTE: request.json() is cached by Starlette, so re-reading inside the
|
||||
# helper returns the same payload.
|
||||
wallet = await _require_subscribed_signer(
|
||||
request, db, action=ACTION_TOGGLE_SCANNER,
|
||||
body={"enabled": enabled, "name": name},
|
||||
)
|
||||
|
||||
s = scanner_state.set_enabled(name, enabled)
|
||||
if s is None:
|
||||
raise HTTPException(404, f"unknown scanner {name!r}")
|
||||
logger.info("Scanner %s set enabled=%s by %s", name, s.enabled, wallet)
|
||||
return ToggleResponse(status="ok", name=name, enabled=s.enabled)
|
||||
|
||||
|
||||
@router.post("/scanners/all/disable", response_model=ToggleResponse)
|
||||
async def kill_switch(
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ToggleResponse:
|
||||
"""Emergency stop — disables ALL scanners. Signed + subscriber-gated.
|
||||
Body: { wallet, timestamp, signature }. Does NOT close open positions —
|
||||
manage those via /positions/{id}/close or the Trades page."""
|
||||
wallet = await _require_subscribed_signer(
|
||||
request, db, action=ACTION_SCANNER_KILL, body=None,
|
||||
)
|
||||
n = scanner_state.disable_all()
|
||||
logger.warning("ALL scanners disabled (%d) by %s", n, wallet)
|
||||
return ToggleResponse(status="killed", count=n)
|
||||
|
||||
|
||||
@router.post("/scanners/all/enable", response_model=ToggleResponse)
|
||||
async def revive_all(
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ToggleResponse:
|
||||
"""Re-enable every scanner that was disabled. Signed + subscriber-gated.
|
||||
Body: { wallet, timestamp, signature }."""
|
||||
wallet = await _require_subscribed_signer(
|
||||
request, db, action=ACTION_SCANNER_REVIVE, body=None,
|
||||
)
|
||||
n = scanner_state.enable_all()
|
||||
logger.info("ALL scanners re-enabled (%d) by %s", n, wallet)
|
||||
return ToggleResponse(status="revived", count=n)
|
||||
Reference in New Issue
Block a user