File size: 29,879 Bytes
115a637
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
de83fbe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115a637
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f57b232
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115a637
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a7d295c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115a637
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
de83fbe
 
 
 
 
115a637
de83fbe
115a637
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a7d295c
 
 
 
 
 
 
 
 
 
 
 
 
de83fbe
a7d295c
de83fbe
a7d295c
 
 
 
 
 
 
84005c5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6bb5dc5
 
 
 
 
 
 
84005c5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
de83fbe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bf3ef55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
de83fbe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7bf9c46
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0b60b70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
de83fbe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84005c5
 
 
de83fbe
 
bf3ef55
0b60b70
7bf9c46
84005c5
115a637
 
a7d295c
115a637
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
"""
modules/bot_api_server.py — direct HTTP API the AWS bot exposes (v18.0).

Replaces the old push-heartbeat/poll-relay pair (bot.py's
_status_heartbeat_loop/_command_poll_loop/_handle_query_command, all
deleted — see bot.py's v18.0 changelog) that used to bounce every dashboard
interaction through a Cloudflare Worker's /status, /command and
/command-result routes. That Worker is retired; this bot now runs
standalone on AWS (Frankfurt) and serves its own status/control API
directly, which the Hugging Face dashboard (app.py, DASHBOARD_ONLY mode)
calls over the internet using BOT_API_URL + BOT_API_TOKEN instead of
ORACLE_URL + RELAY_AUTH_TOKEN.

Endpoints:
  GET  /status   — live-computed JSON status snapshot (no auth — same
                   shape the old /status push payload had, plus a new
                   jupiter_tier field). Computed fresh on every request,
                   never cached.
  POST /command  — Bearer-token gated (BOT_API_TOKEN). Body:
                   {"action": str, "params": dict|null}. Routes `action`
                   to the same handler functions the old poll loop called,
                   but SYNCHRONOUSLY — one HTTP request/response, no more
                   request_id + second poll for the result. Returns
                   {"ok": true, "result": "..."} or {"ok": false, "error":
                   "..."}.

Run as an asyncio background task from bot.py (self._spawn_background),
started unconditionally — with no BOT_API_TOKEN configured, /command
always 503s (logged once) but /status still works, since it carries no
secrets.
"""

from __future__ import annotations

import asyncio
import hmac
import logging
import os
import time
from typing import Any

logger = logging.getLogger(__name__)

_DEFAULT_HOST = "0.0.0.0"
_DEFAULT_PORT = 8081

# Fire-and-forget actions — reuse the exact same CommandHandlers methods
# Telegram's /ghost_mode, /mint_mode and /hunt already call, and report to
# the same Telegram chat, so a dashboard-triggered action leaves the same
# audit trail a Telegram command would. Each takes (chat_id) and sends its
# own Telegram message; the HTTP response below just confirms it ran.
_FIRE_AND_FORGET_ACTIONS = ("toggle_ghost", "toggle_mint", "hunt")

# Data-query actions — rendered via the shared modules/dashboard_panels.py
# renderers (same ones app.py's non-DASHBOARD_ONLY path and the /speedtest
# Telegram command use) and returned directly in the HTTP response body.
_QUERY_ACTIONS = (
    "wallet_status", "gas_health", "price_check", "scanner_tokens",
    "solana_prices", "solana_routes", "arbitrage_detail", "audit_log",
    "speedtest",
)


def _bot_api_token() -> str:
    return os.getenv("BOT_API_TOKEN", "").strip()


def _note_auth(request: Any, ok: bool) -> None:
    """Report an authentication outcome to the HTTP guard (v18.2).

    Best-effort by design: if the guard is unavailable or raises, the auth
    decision itself has already been made and must stand. This can only
    ever add a future reason to refuse, never grant access.
    """
    try:
        from modules.http_guard import client_key, get_guard

        guard = get_guard()
        key = request.get("client_key") or client_key(request)
        if ok:
            guard.note_auth_success(key)
        else:
            guard.note_auth_failure(key)
    except Exception as exc:  # noqa: BLE001
        logger.debug("[BotAPI] could not record auth outcome: %s", exc)


def _bot_api_host() -> str:
    return os.getenv("BOT_API_HOST", _DEFAULT_HOST).strip() or _DEFAULT_HOST


def _bot_api_port() -> int:
    try:
        return int(os.getenv("BOT_API_PORT", str(_DEFAULT_PORT)).strip())
    except ValueError:
        return _DEFAULT_PORT


def _auto_hunt_enabled() -> bool:
    """Same semantics as bot.py's own _auto_hunt_enabled() — duplicated as
    a tiny env-var read rather than imported, to avoid a circular import
    (bot.py imports this module at startup, before its own module-level
    code has finished running)."""
    return os.environ.get("AUTO_HUNT_ENABLED", "false").strip().lower() in (
        "1", "true", "yes", "on",
    )


async def _build_status_snapshot(bot: Any) -> dict[str, Any]:
    """Same fields the old _status_heartbeat_loop's push payload had, plus
    jupiter_tier (new). Computed fresh every call — no caching."""
    from constants import jupiter_tier_status

    h = getattr(bot, "_handlers", None)
    payout = getattr(bot, "_payout", None)
    ledger = await payout.get_status() if payout is not None else None
    solana = getattr(bot, "_solana", None)

    _is_paid, tier_name = jupiter_tier_status()

    return {
        "uptime_s":           int(time.time() - getattr(bot, "start_time", time.time())),
        "telegram_connected": bool(getattr(getattr(bot, "_tg", None), "reachable", False)),
        "ghost_mode":         bool(getattr(h, "_ghost_mode", False)) if h else False,
        "mint_mode":          bool(getattr(h, "_mint_mode", False)) if h else False,
        "auto_hunt":          _auto_hunt_enabled(),
        "min_profit_arb":     h.effective_min_profit if h else getattr(bot.config, "min_profit_usd", None),
        # FIX — this used to independently re-read SOLANA_MIN_PROFIT_USD
        # with its own hardcoded default (2.0, different from
        # solana_arb.py's own default), the same class of banner-vs-
        # enforced-value drift already fixed once for mint mode (see
        # modules/command_handlers.py's v17.20 FIX). Now reads the LIVE
        # SolanaArbEngine instance's own .min_profit attribute so the
        # displayed value can never drift from what's actually enforced.
        "min_profit_sol":     solana.min_profit if solana is not None else None,
        "total_profit_usd":   (ledger or {}).get("net_profit", 0.0),
        "scans_total":        (ledger or {}).get("scan_count", 0),
        "scans_buy":          (ledger or {}).get("buy_count", 0),
        "dry_run":            getattr(bot.config, "dry_run", None),
        "jupiter_tier":       tier_name,
        # v18.1 — operational fields for the public page. Audited as
        # non-secret before being added here, because this endpoint is
        # unauthenticated and fronted by bot.elghaly.dev: scan age is a
        # timestamp delta, route counts are integers, and the governor's
        # rate is a number about our own pacing. No URL, no address, no
        # key, no config value that would help an attacker. The RPC URL in
        # particular stays OUT — SOLANA_RPC_PRIMARY embeds a provider API
        # key in its path on this deployment.
        "solana": ({
            "last_scan_age_secs": (
                round(time.time() - solana.last_scan_ts, 1) if solana.last_scan_ts else None
            ),
            "scan_count":  solana.scan_count,
            "signal_count": solana.signal_count,
            "routes":      len(solana.routes),
            "paused":      bool(getattr(solana, "paused", False)),
        } if solana is not None else None),
    }


async def _dispatch_action(bot: Any, action: str, params: dict) -> str:
    """Compute one action's result. Fire-and-forget actions return a short
    confirmation string (the real audit trail is the Telegram message they
    send); query actions return the rendered markdown directly. Any
    failure is rendered as an error string rather than raised, so a bad
    request can't 500 the whole endpoint for an unrelated reason."""
    from modules import dashboard_panels as panels

    handlers = getattr(bot, "_handlers", None)
    chat_id = getattr(bot.config, "telegram_chat_id", None)

    if action in _FIRE_AND_FORGET_ACTIONS:
        if handlers is None:
            return "❌ Bot not ready yet — handlers not constructed."
        if action == "toggle_ghost":
            await handlers._cmd_ghost_mode(chat_id)
        elif action == "toggle_mint":
            await handlers._cmd_mint_mode(chat_id)
        elif action == "hunt":
            await handlers._cmd_hunt(chat_id)
        return f"✅ {action} executed — see Telegram for confirmation."

    if action in _QUERY_ACTIONS:
        if action == "wallet_status":
            return await panels.render_wallet_status(bot)
        if action == "gas_health":
            return await panels.render_gas_health(bot)
        if action == "price_check":
            return await panels.render_price_check(bot, params.get("assets", ""))
        if action == "scanner_tokens":
            return await panels.render_scanner_tokens(bot, _auto_hunt_enabled())
        if action == "solana_prices":
            return await panels.render_solana_prices(bot)
        if action == "solana_routes":
            return await panels.render_solana_routes(bot)
        if action == "arbitrage_detail":
            return await panels.render_arbitrage_detail(bot)
        if action == "audit_log":
            return await panels.render_audit_log(bot)
        if action == "speedtest":
            return await panels.render_speedtest(bot)

    return f"❌ Unknown action: {action}"


def _read_recent_log_lines(limit: int = 200) -> str:
    """v18.1 — tail the bot's own rotating log file.

    Operator need: the Hugging Face dashboard runs in DASHBOARD_ONLY mode and
    therefore has no bot of its own, so the only logs it could ever show were
    its own (empty) process — i.e. never the AWS bot actually trading. This
    serves the real thing.

    Reads modules/logger.py's LOG_FILE rather than shelling out to
    journalctl: no subprocess, no sudo, and it works regardless of which
    user the service runs as or whether journald is even in use. Bounded to
    the tail so a multi-megabyte log can't be pulled into memory or pushed
    down the wire in one response."""
    path = os.getenv("LOG_FILE", "").strip()
    if not path:
        return ("LOG_FILE is not set, so the bot is not writing a log file — "
                "only stdout/journald. Set LOG_FILE=bot.log in .env and "
                "restart to enable this view.")
    try:
        with open(path, "r", encoding="utf-8", errors="replace") as fh:
            # Seek to a bounded window from EOF instead of reading the whole
            # file; 200 lines is far under 256 KiB in this log's format.
            try:
                fh.seek(0, os.SEEK_END)
                fh.seek(max(0, fh.tell() - 256_000))
                fh.readline()  # discard the partial line the seek landed in
            except OSError:
                pass
            lines = fh.readlines()
        tail = [ln.rstrip("\n") for ln in lines[-limit:]]
        return "\n".join(tail) if tail else "(log file is empty)"
    except FileNotFoundError:
        return f"Log file not found at {path} — the bot may not have written to it yet."
    except Exception as exc:
        return f"Could not read log file: {exc}"


async def run_bot_api_server(bot: Any) -> None:
    """Serve GET /status and POST /command until the bot stops. Spawned as
    a background task from bot.py's start() — see this module's own
    docstring for the full endpoint contract."""
    try:
        from aiohttp import web
    except ImportError:
        logger.error(
            "[BotAPI] aiohttp not installed — the bot's HTTP status/"
            "command API will not run. pip install aiohttp."
        )
        return

    token = _bot_api_token()
    if not token:
        logger.warning(
            "[BotAPI] BOT_API_TOKEN is not set — /status will still work "
            "(no secrets in it), but /command will reject every request. "
            "Set BOT_API_TOKEN to let the Hugging Face dashboard control "
            "this bot remotely."
        )

    async def handle_status(request: "web.Request") -> "web.Response":
        try:
            snapshot = await _build_status_snapshot(bot)
            return web.json_response(snapshot)
        except Exception as exc:
            logger.warning("[BotAPI] /status failed: %s", exc)
            return web.json_response({"error": str(exc)}, status=500)

    async def handle_command(request: "web.Request") -> "web.Response":
        configured_token = _bot_api_token()
        if not configured_token:
            return web.json_response(
                {"ok": False, "error": "BOT_API_TOKEN not configured on this bot"},
                status=503,
            )
        auth = request.headers.get("Authorization", "")
        if not hmac.compare_digest(auth, f"Bearer {configured_token}"):
            # v18.2 — feed the failure to modules/http_guard.py, which locks
            # the client out after a handful of attempts. Without this, the
            # only thing bounding a brute-force run against a fund-moving
            # endpoint is how fast the attacker's network is.
            _note_auth(request, ok=False)
            return web.json_response({"ok": False, "error": "unauthorized"}, status=401)
        _note_auth(request, ok=True)

        try:
            body = await request.json()
        except Exception:
            return web.json_response({"ok": False, "error": "invalid JSON body"}, status=400)

        action = (body or {}).get("action")
        params = (body or {}).get("params") or {}
        if not action:
            return web.json_response({"ok": False, "error": "missing action"}, status=400)

        try:
            logger.info("[BotAPI] executing remote command: %s", action)
            result = await _dispatch_action(bot, action, params)
            return web.json_response({"ok": True, "result": result})
        except Exception as exc:
            logger.warning("[BotAPI] action %s failed: %s", action, exc)
            return web.json_response({"ok": False, "error": str(exc)}, status=500)

    async def handle_logs(request: "web.Request") -> "web.Response":
        """Bearer-gated like /command, not open like /status: log lines are
        operational detail (wallet addresses, tx signatures, route sizes),
        which /status deliberately does not expose."""
        configured_token = _bot_api_token()
        if not configured_token:
            return web.json_response(
                {"ok": False, "error": "BOT_API_TOKEN not configured on this bot"},
                status=503,
            )
        if not hmac.compare_digest(
            request.headers.get("Authorization", ""), f"Bearer {configured_token}"
        ):
            _note_auth(request, ok=False)
            return web.json_response({"ok": False, "error": "unauthorized"}, status=401)
        _note_auth(request, ok=True)
        try:
            limit = min(int(request.query.get("limit", 200)), 1000)
        except ValueError:
            limit = 200
        text = await asyncio.to_thread(_read_recent_log_lines, limit)
        return web.json_response({"ok": True, "logs": text})

    # ── Public read-only surface for bot.elghaly.dev (v18.1, 2026-07-29) ──
    #
    # Operator: "a public read-only stats page vs an authenticated control
    # page — you already have this instinct (the /logs 401 earlier was
    # correct behavior, keep that pattern everywhere)."
    #
    # So these four routes are UNAUTHENTICATED BY DESIGN and carry nothing
    # that needs protecting: the page itself is a static string, and both
    # JSON endpoints read only the trade journal (route names, sizes,
    # spreads, on-chain signatures — all of it either already public on
    # Solana or meaningless to an attacker). No wallet address, no RPC URL,
    # no key material, no config, and above all no way to ACT. /command
    # keeps its Bearer token; /logs keeps its 401.
    #
    # That line matters more than it looks: this page is meant to be public
    # at bot.elghaly.dev, and a public page that can move funds is a public
    # page that will move funds.
    async def handle_index(request: "web.Request") -> "web.Response":
        from modules.web_dashboard import INDEX_HTML

        return web.Response(text=INDEX_HTML, content_type="text/html")

    async def handle_api_pnl(request: "web.Request") -> "web.Response":
        from modules.ledger_report import pnl

        # to_thread: these read (tailed) CSVs off disk, and the event loop
        # this shares with the scan loop must never block on I/O.
        return web.json_response(await asyncio.to_thread(pnl))

    async def handle_api_receipts(request: "web.Request") -> "web.Response":
        from modules.ledger_report import receipts

        try:
            # max(1, ...) as well as min(..., 500): Python slices backwards
            # on a negative bound, so `?limit=-1` would have returned
            # nearly every row and defeated the cap entirely. This endpoint
            # is deliberately unauthenticated, so its parameters have to be
            # robust against someone who is not being polite.
            limit = max(1, min(int(request.query.get("limit", 100)), 500))
        except (TypeError, ValueError):
            limit = 100
        rows = await asyncio.to_thread(receipts, limit)
        return web.json_response({
            "receipts": rows,
            "verifiable": True,
            "note": (
                "Each signature is independently checkable on Solana. This "
                "endpoint returns ONLY trades that produced a real on-chain "
                "signature — never estimates, simulations or projections."
            ),
        })

    async def handle_receipts_csv(request: "web.Request") -> "web.Response":
        from modules.ledger_report import receipts_csv

        text = await asyncio.to_thread(receipts_csv)
        return web.Response(
            text=text, content_type="text/csv",
            headers={"Content-Disposition": 'attachment; filename="receipts.csv"'},
        )

    async def handle_api_routes(request: "web.Request") -> "web.Response":
        """v18.2 — route-pruning transparency, public.

        Operator: "expose why a route got pruned (e.g. '0 signals in 500
        evals over 6h') so you can tell a dead route from a temporarily
        quiet one."

        Serves route_scorer.public_table(), which is the internal table
        with every dollar figure removed — see that method for the audit.
        Route names and basis points are safe to publish; loan sizes are
        position information and are not.
        """
        from modules.route_scorer import get_scorer

        scorer = get_scorer()
        try:
            table = await asyncio.to_thread(scorer.public_table)
            status = scorer.status()
        except Exception as exc:  # noqa: BLE001
            logger.debug("[BotAPI] /api/routes failed: %s", exc)
            return web.json_response({"routes": [], "error": "unavailable"})
        return web.json_response({
            "routes": table,
            "pruning_enabled": status.get("enabled"),
            "routes_pruned": status.get("routes_pruned"),
            "min_active_routes": status.get("min_active_routes"),
            "dead_bps": status.get("dead_bps"),
            "floor_bound": status.get("floor_bound"),
            "probe_every_cycles": status.get("probe_every_cycles"),
            "note": (
                "A pruned route is asleep on a timer, not deleted — it is "
                "re-probed on a fixed cycle and reinstated the moment it "
                "produces. `explain` states the evidence and the observation "
                "window for every route, pruned or not."
            ),
        })

    async def handle_api_engine(request: "web.Request") -> "web.Response":
        """v18.3 — the engine's own instruments, in one payload.

        Operator: "add some more data" — and, in the same breath, "make
        secure just me". Those two travel together: this endpoint carries
        edge-decay measurements, Jito tip state, the trading circuit
        breaker and the drawdown watcher, which is meaningfully more than
        the earlier public payloads exposed.

        Each block is still built by hand rather than dumping the modules'
        full status() dicts, because those are written for /doctor — an
        authenticated, operator-only view — and would carry the signing
        wallet, file paths and threshold config out onto a web page. The
        rule from the public endpoints has not been relaxed just because
        there is a password in front of it now: a password is one
        credential, and a page that leaks a wallet address to whoever
        holds it is still a page that leaks a wallet address.
        """
        out: dict[str, Any] = {}

        try:
            from modules.decay_tracker import get_tracker

            st = get_tracker().status()
            out["decay"] = {
                "recording": st.get("recording"),
                "samples": st.get("samples"),
                "min_samples": st.get("min_samples"),
                "usd_per_sec": st.get("decay_usd_per_sec"),
                "pipeline_median_secs": st.get("pipeline_median_secs"),
                "pipeline_p90_secs": st.get("pipeline_p90_secs"),
                "implied_adder_usd": st.get("implied_adder_usd"),
                "gate_enabled": st.get("gate_enabled"),
            }
        except Exception as exc:  # noqa: BLE001
            logger.debug("[BotAPI] decay block failed: %s", exc)

        try:
            from modules.trading_guard import get_guard

            st = get_guard().status()
            out["guard"] = {
                "state": st.get("state"),
                "trips": st.get("trips"),
                "attempts": st.get("attempts"),
                "successes": st.get("successes"),
                "blocked": st.get("blocked"),
                "reason": (st.get("trip_reason") or "")[:200],
            }
        except Exception as exc:  # noqa: BLE001
            logger.debug("[BotAPI] guard block failed: %s", exc)

        try:
            from modules.jito_tip_engine import get_tip_engine

            st = get_tip_engine().status()
            out["jito"] = {
                "gamma": st.get("gamma"),
                "bundles_sent": st.get("bundles_submitted"),
                "bundles_landed": st.get("bundles_landed"),
                "landing_rate": st.get("landing_rate"),
                "priced_out": st.get("priced_out"),
                "tips_paid_sol": st.get("tip_sol_paid"),
                # COUNT, not the list. The block-engine URLs are not secret,
                # but they are infrastructure detail with no reason to be on
                # a web page — and "4 engines" is the whole of what a reader
                # needs from that field.
                "engines": len(st.get("block_engines") or []),
            }
        except Exception as exc:  # noqa: BLE001
            logger.debug("[BotAPI] jito block failed: %s", exc)

        try:
            from modules.drawdown_alert import get_watcher

            st = get_watcher().status()
            out["drawdown"] = {
                "enabled": st.get("enabled"),
                "halt_on_breach": st.get("halt_on_breach"),
                "thresholds": st.get("thresholds"),
                "latches": st.get("latches"),
            }
        except Exception as exc:  # noqa: BLE001
            logger.debug("[BotAPI] drawdown block failed: %s", exc)

        try:
            from modules.http_guard import get_guard as get_http_guard

            st = get_http_guard().status()
            out["http"] = {
                "served": st.get("served"),
                "throttled_429": st.get("throttled_429"),
                "shed_503": st.get("shed_503"),
                "auth_lockouts": st.get("auth_lockouts"),
                "locked_clients": st.get("locked_clients"),
            }
        except Exception as exc:  # noqa: BLE001
            logger.debug("[BotAPI] http block failed: %s", exc)

        return web.json_response(out)

    async def handle_api_nearmiss(request: "web.Request") -> "web.Response":
        """v18.2 — failure-mode logging, public.

        Operator: "logging near-misses — signals that crossed your
        detection threshold but reverted or got beaten to the block —
        since that tells you if you're losing races, not just whether
        you're finding opportunities."

        Public because it is the honest half of the story. A page showing
        only executed trades reads as either "no losses" or "no activity"
        with no way to tell which; publishing the near-misses alongside is
        what makes the receipts section a claim about completeness rather
        than a selection.
        """
        from modules.near_miss import get_log

        near = get_log()
        try:
            limit = max(1, min(int(request.query.get("limit", 25)), 200))
        except (TypeError, ValueError):
            limit = 25
        try:
            summary = await asyncio.to_thread(near.summary)
            recent = await asyncio.to_thread(near.recent, limit)
        except Exception as exc:  # noqa: BLE001
            logger.debug("[BotAPI] /api/nearmiss failed: %s", exc)
            return web.json_response({"summary": {}, "recent": [], "error": "unavailable"})
        # log_path is a server filesystem path — useful in /doctor, not on
        # a public endpoint, so it is dropped here rather than never
        # collected.
        summary.pop("log_path", None)
        return web.json_response({"summary": summary, "recent": recent})

    async def handle_api_observatory(request: "web.Request") -> "web.Response":
        """v18.5 — the recorded series, not another snapshot.

        Operator's request, in their own framing: a public view of Solana
        tip/MEV conditions that runs continuously, because other bot
        builders want that data and producing it risks no capital.

        Everything here is either published by Jito already or measured
        from public quotes, so there is nothing to withhold — but the
        endpoint-name labels from rpc_latency are deliberately env-var
        names ("SOLANA_RPC_PRIMARY"), never URLs, because the primary RPC
        embeds a provider API key in its path on this deployment. See
        scripts/observe.py, which records them that way for this reason.
        """
        from modules.observatory import viability

        try:
            data = await asyncio.to_thread(viability)
        except Exception as exc:  # noqa: BLE001
            logger.debug("[BotAPI] /api/observatory failed: %s", exc)
            return web.json_response({"rows": 0, "error": "unavailable"})
        return web.json_response(data)

    async def handle_api_lighthouse(request: "web.Request") -> "web.Response":
        """v18.4 — the verdict that ranks the other panels, public.

        Operator: "improve his web its so ugly."

        The page opened with a table, which meant a reader — including the
        operator on a phone — had to assemble the answer from five sections
        before knowing whether anything was wrong. This serves the same
        five-gate judgement /lighthouse gives in Telegram, so the page can
        lead with the conclusion and keep the evidence underneath it.

        `public=True` drops the `action` field. The findings carry shell
        commands naming scripts and env vars, and operator instructions on
        a web page are exactly what this file's payload rule exists to
        prevent. The diagnosis stays — knowing that the pipeline is slow is
        not a credential — only the runbook line is withheld.
        """
        from modules.lighthouse_report import lighthouse

        try:
            data = await asyncio.to_thread(lighthouse, bot, True)
        except Exception as exc:  # noqa: BLE001
            logger.debug("[BotAPI] /api/lighthouse failed: %s", exc)
            return web.json_response({"stages": [], "verdict": None,
                                      "error": "unavailable"})
        return web.json_response(data)

    # v18.2 — modules/http_guard.py. The server now shares an event loop
    # with the scan loop AND faces the public internet, which makes an
    # enthusiastic scraper indistinguishable from an attack in its effect:
    # scan cadence stretches and the bot stops trading. The middleware caps
    # concurrency, buckets per client, and locks out repeated failed
    # authentication against /command. It fails OPEN on internal error —
    # a broken limiter must not take down the page the operator diagnoses
    # everything else from.
    middlewares = []
    try:
        from modules.http_guard import make_middleware

        middlewares.append(make_middleware())
    except Exception as exc:  # noqa: BLE001
        logger.warning("[BotAPI] HTTP guard unavailable, serving unprotected: %s", exc)

    app = web.Application(middlewares=middlewares)
    app.router.add_get("/", handle_index)
    app.router.add_get("/api/pnl", handle_api_pnl)
    app.router.add_get("/api/receipts", handle_api_receipts)
    app.router.add_get("/api/routes", handle_api_routes)
    app.router.add_get("/api/nearmiss", handle_api_nearmiss)
    app.router.add_get("/api/engine", handle_api_engine)
    app.router.add_get("/api/lighthouse", handle_api_lighthouse)
    app.router.add_get("/api/observatory", handle_api_observatory)
    app.router.add_get("/receipts.csv", handle_receipts_csv)
    app.router.add_get("/status", handle_status)
    app.router.add_post("/command", handle_command)
    app.router.add_get("/logs", handle_logs)

    host, port = _bot_api_host(), _bot_api_port()
    runner = web.AppRunner(app)
    await runner.setup()
    site = web.TCPSite(runner, host, port)
    await site.start()
    logger.info("🌐 Bot API listening on %s:%d (/status, /command)", host, port)

    try:
        while getattr(bot, "_running", True):
            await asyncio.sleep(1)
    finally:
        await runner.cleanup()