diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..14a87f9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,56 @@ +# Чем меньше build-context, тем быстрее `docker build` и тем меньше +# финальный образ. Не пускаем внутрь сборки то, что не нужно runtime. + +# Git +.git +.gitignore +.gitattributes +.github + +# Тесты и инструменты разработки +tests +docs +deploy/grafana/*.json.bak +*.md +!README.md +LICENSE +Makefile +ROADMAP.md +CHANGELOG.md +CLAUDE.md +learnings.md + +# Питоновские артефакты +__pycache__ +*.pyc +*.pyo +*.pyd +.pytest_cache +.mypy_cache +.ruff_cache +*.egg-info +build +dist +htmlcov +.coverage + +# Локальные runtime-данные +data +*.db +*.db-journal +*.db-wal +*.db-shm +*.log + +# IDE / OS +.idea +.vscode +.DS_Store +Thumbs.db + +# Compose / Docker meta — образ строится не из них +docker-compose.yml +.dockerignore +Dockerfile +.env +.env.* diff --git a/CHANGELOG.md b/CHANGELOG.md index dfd9547..0feeac8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,68 @@ ## [Unreleased] +## [0.7.0] — 2026-05-02 + +### Добавлено +- **Web-дашборд** (`webui.py`): stdlib `http.server`, без CDN и без + JS-фреймворков. CLI-флаг `--web-port PORT` (0 — выкл, default 0) и + `--web-host HOST` (default `127.0.0.1`). Эндпоинты: + - `GET /` — single-page HTML с inline-SVG sparkline хешрейта (последние + 60 сэмплов), карточками шар/job/pool/uptime/sha-backend и SSE-логом + последних событий. + - `GET /api/stats` — JSON-снапшот (`Cache-Control: no-store`). + Контракт: `hashrate_ema/last/human`, `workers`, `pool_url`/`current_pool`, + `pool_difficulty`, `current_job_id`, `shares_total/accepted/rejected`, + `last_share_ts`, `uptime_s/human`, `sha_backend`, `started_at`, `now`. + - `GET /api/events` — Server-Sent Events: `share_found`, + `share_accepted`, `share_rejected`, `job`, `pool`. Keep-alive + каждые 15с. Чистая отписка при разрыве клиента. + - `GET /healthz` — то же тело, что у metrics-сервера (через тот же + `set_health_provider`). +- **`StatsProvider.subscribe(callback)`** — pub/sub шина для SSE. + `update_job` публикует `job`-event только при реальной смене job_id, + `record_share` — `share_found/accepted/rejected`, `update_pool` — `pool`. + Сломанный подписчик не валит publish: исключения ловятся и логируются. +- **`StatsProvider.set_sha_backend(name)`** — backend-имя видно в + `/api/stats` и в TUI рядом с хешрейтом. +- **Docker** (`Dockerfile`, `docker-compose.yml`, `.dockerignore`): + `python:3.11-slim`, healthcheck через stdlib `urllib` (без `curl`), + `ENTRYPOINT ["hope-hash"]`. Compose поднимает три сервиса + (`miner` + `prometheus` + `grafana`) с volume для SQLite и provisioning + Grafana. Env vars: `BTC_ADDRESS`, `WORKERS`, `HOPE_HASH_TELEGRAM_*`, + `GRAFANA_USER/PASSWORD`. +- **Provisioning Prometheus + Grafana** (`deploy/prometheus/prometheus.yml`, + `deploy/grafana/datasource.yml`, `deploy/grafana/dashboard.yml`): + Prometheus скрейпит `miner:8000/metrics` каждые 15с, Grafana + автоматически подхватывает `hope-hash.json` под папку «Hope-Hash». +- **Двуязычная документация** (`docs/`): + `getting-started.{en,ru}.md` (первый запуск, типичные проблемы), + `deploy.{en,ru}.md` (Docker compose, Telegram, healthcheck, reverse + proxy), `architecture.{en,ru}.md` (протокол, threading, hot path, + observers, BIP-ссылки). +- **README rewrite**: верх — английский, низ — русский, обе половины + с одинаковыми разделами (что / install / run / advanced flags / demo / + benchmark / architecture / realistic expectations / contributing). + Cross-link на `docs/architecture.{en,ru}.md`. +- **Тесты**: `test_webui.py` (17 тестов): publish/subscribe, render_html, + HTML/JSON-эндпоинты, SSE-стрим с реальным сокетом, `/healthz`-fallback, + идемпотентность start/stop. Итого **225 → 242** (+17). + +### Изменено +- `StatsProvider.__init__` теперь принимает `sha_backend` (default + `"hashlib"`). +- `StatsProvider.update_job/record_share/update_pool` публикуют события + через `_publish` — без обратных совместимых ломок (старые потребители + просто не подписываются). +- `cli.main()` стартует `WebUIServer`, если `--web-port > 0`, и регистрирует + тот же health-provider, что и у `MetricsServer`. +- `__version__` → `0.7.0`. + +### Документация +- `ROADMAP.md`: тикнуты web-морда (через stdlib `http.server`, не + FastAPI) и Docker-образ. Добавлен раздел «remaining» с явно + отложенными задачами (Stratum V2, Rust/PyO3, GPU, FastAPI). + ## [0.6.0] — 2026-05-02 ### Добавлено diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b63619b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,48 @@ +# Hope-Hash — учебный solo BTC miner на чистом stdlib. +# +# Слим-Python база: ctypes-backend подхватывает libcrypto-3 из самого образа, +# поэтому никаких apt-get install для openssl-libs не нужно. +# +# Pip install -e . — это сам проект, не сторонняя зависимость (CLAUDE.md +# разрешает; pyproject.toml объявляет dependencies = []). + +FROM python:3.11-slim + +# Не записываем .pyc; разрешаем print/log без буферизации (важно для +# `docker logs -f`, иначе строки висят пока буфер не наполнится). +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +WORKDIR /app + +# Сначала только метаданные — слой с зависимостями кэшируется отдельно +# от исходников (хотя dependencies=[], pip всё равно строит wheel один раз). +COPY pyproject.toml README.md LICENSE ./ +COPY src ./src + +RUN pip install --upgrade pip \ + && pip install -e . + +# Volume для SQLite-журнала шар. Default путь --db hope_hash.db ставим под +# /data, чтобы был один canonical монтируемый location. +VOLUME ["/data"] +WORKDIR /data + +# Прокидываем порты: 8000 — общий для metrics+webui (compose их и пробрасывает), +# 9090 — если кто-то держит --metrics-port отдельно от --web-port. +EXPOSE 8000 9090 + +# Healthcheck без curl: stdlib urllib умеет всё необходимое и уже в образе. +# Проверяем /healthz на metrics-порту 8000 (compose именно туда мапит). +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD python -c "import urllib.request,sys; \ + sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=4).status==200 else 1)" \ + || exit 1 + +ENTRYPOINT ["hope-hash"] + +# Пустой default — пользователь обязательно передаёт BTC-адрес и флаги. +# Compose-файл подставляет их явно через `command:`. +CMD ["--help"] diff --git a/README.md b/README.md index e496b16..ab177ba 100644 --- a/README.md +++ b/README.md @@ -1,331 +1,289 @@ -# Solo BTC Miner — учебный соло-майнер на Python +# Hope-Hash -> Рабочее имя проекта. Финальное название не выбрано — кандидаты см. в конце файла. +[English](#english) · [Русский](#russian) -Минимальный, но настоящий соло-майнер биткоина: подключается к публичному соло-пулу, реализует протокол Stratum V1 с нуля, перебирает SHA-256 в чистом Python и отправляет шары. Без зависимостей. - -Цель проекта — **разобраться, как работает Bitcoin mining изнутри**: protocol, block header, merkle tree, target, double-SHA-256. Это не способ заработать (см. раздел «Реалистичные ожидания»), а образовательный код, который можно пощупать руками и развивать дальше. +![Python](https://img.shields.io/badge/python-3.11%2B-blue) +![License](https://img.shields.io/badge/license-MIT-green) +![Tests](https://img.shields.io/badge/tests-242-brightgreen) +![Stdlib only](https://img.shields.io/badge/deps-stdlib%20only-informational) --- -## Что нового в v0.6.0 - -Perf & resilience: **multi-pool failover** (`--pool host:port` -повторяемый, ротация после N провалов на текущем), **solo-режим -через `getblocktemplate`** (`--solo --rpc-url ... --rpc-cookie ...`, -полная сборка coinbase + witness commitment + `submitblock` через -JSON-RPC), **ctypes SHA-256 backend** (`--sha-backend -{auto,hashlib,ctypes}`, грузит libcrypto через `ctypes.CDLL`, -fallback на hashlib если не нашёлся), **`--benchmark --backends`** — -сравнительный прогон всех доступных backend'ов с финальной строкой -`[bench] result: ctypes X MH/s (Yx vs hashlib-midstate)`. Hot path -не тронут, mid-state hashlib остаётся defaultом для майнинга. - -## Что нового в v0.5.0 - -Ops & UX полировка: `--tui` — curses-дашборд (EMA-хешрейт, шары, -job_id, аптайм; quit на `q`), ASCII-баннер при старте (`--no-banner` -для cron), `/healthz` JSON-эндпоинт на `/metrics`-сервере (200/503 -для k8s liveness), Telegram inbound-команды `/stats`/`/stop`/`/restart` -(opt-in через `HOPE_HASH_TELEGRAM_INBOUND=1`, authz по chat_id), -готовый Grafana-дашборд в `deploy/grafana/hope-hash.json`. Полный -API `mine()` теперь принимает `stats_provider: StatsProvider` — -единая шина данных для TUI и web (web придёт в v0.7.0). - -## Статус: что уже сделано - -- [x] TCP-клиент к `solo.ckpool.org:3333` через стандартную `socket` -- [x] JSON-RPC поверх Stratum V1 -- [x] `mining.subscribe` — получение `extranonce1` и `extranonce2_size` -- [x] `mining.authorize` — аутентификация под BTC-адресом -- [x] Обработка `mining.set_difficulty` (динамическая сложность пула) -- [x] Обработка `mining.notify` (получение свежей работы) -- [x] Сборка coinbase-транзакции (`coinb1 + extranonce1 + extranonce2 + coinb2`) -- [x] Вычисление merkle root через ветки от пула -- [x] Корректная сборка 80-байтного block header (с правильным word-swap для prevhash) -- [x] Цикл перебора `nonce` 0…2³² с double-SHA-256 -- [x] Сравнение хеша с pool target → отправка `mining.submit` при попадании -- [x] Фоновая нить чтения сообщений от пула, защита `current_job` через `Lock` -- [x] Прерывание цикла при получении свежей работы (clean job) -- [x] Печать хешрейта раз в 5 секунд - -**Уровень 0 — стабилизация: завершён.** - -- [x] Reconnect с экспоненциальным backoff (1→2→4→…→60с) -- [x] Обработка `mining.set_extranonce` -- [x] Корректное завершение по Ctrl+C (общий `stop_event`, `client.close()`, `join`) -- [x] `logging` вместо `print` со стандартными уровнями -- [x] 15 юнит-тестов на криптографические функции (`unittest`) -- [x] `src/`-layout, пакет `hope_hash`, `pyproject.toml` (hatchling) -- [x] CI matrix Python 3.11/3.12/3.13 × ubuntu/windows/macos -- [ ] Конфиг через CLI/YAML — перенесено в Уровень 1 - -**Уровень 1 — производительность и наблюдаемость: завершён.** - -- [x] Multiprocessing: N воркеров (default `cpu_count - 1`), флаг `--workers` -- [x] EMA-хешрейт (alpha=0.3, окно 5с) -- [x] SQLite-журнал шаров и сессий (`storage.py`, флаг `--db`) -- [x] Prometheus-метрики на `/metrics` (`metrics.py`, флаг `--metrics-port`) -- [x] Telegram-уведомления (через stdlib urllib, env-конфиг) -- [ ] TUI на `rich` / `curses` — отложено (зависимости либо ограниченная Win-поддержка) -- [ ] Команды Telegram-бота (`/stats`, `/restart`) — отложено - -**Уровень 1.5 — глубокий аудит и UX (v0.3.0):** - -- [x] Mid-state SHA-256 (`hashlib.sha256().copy()` после первых 64 байт) — ≈×1.5–2 хешрейт -- [x] `mining.suggest_difficulty` + CLI флаг `--suggest-diff` (vardiff) -- [x] Demo-режим: `--demo [--demo-diff]` — offline-майнинг без подключения к пулу -- [x] Pre-flight валидация BTC-адреса (bech32/bech32m/Base58Check, mainnet only) -- [x] Prometheus метрики `hopehash_shares_accepted_total` / `_rejected_total` -- [x] Запись шара в SQLite фиксируется только после подтверждения пула (`on_share_result` колбэк) -- [x] `mining.authorize` ответ верифицируется (раньше отказ авторизации игнорировался) -- [x] `time.perf_counter()` вместо `time.time()` для всех относительных интервалов -- [x] `except queue.Empty` вместо bare `except Exception` в горячих циклах - -**Не сделано / известные ограничения:** - -- Нет UI — только консоль через `logging` и `/metrics` через HTTP. -- Только Stratum V1, без Stratum V2. -- C/Rust/SIMD/GPU — Уровни 2–3, ещё впереди. +## English ---- +> A pure-stdlib solo Bitcoin miner you can read in one sitting. +> Zero runtime dependencies. Built to be understood, not profitable. -## Структура +### What is this -``` -. -├── README.md ← этот файл -├── ROADMAP.md ← план развития, расставленный по сложности -├── CHANGELOG.md ← история версий (Keep a Changelog) -├── CLAUDE.md ← правила для AI-ассистента -├── LICENSE ← MIT -├── pyproject.toml ← метаданные + hatchling backend -├── Makefile ← short-cuts: install / test / run / lint -├── .github/workflows/ci.yml ← matrix Python 3.11–3.13 × ubuntu/windows/macos -├── src/hope_hash/ -│ ├── __init__.py ← публичный API + __version__ -│ ├── __main__.py ← `python -m hope_hash` -│ ├── cli.py ← argparse, точка входа, инициализация observers -│ ├── miner.py ← mine() оркестратор + supervisor_loop -│ ├── parallel.py ← multiprocessing воркеры nonce-loop -│ ├── stratum.py ← StratumClient (TCP + JSON-RPC) -│ ├── block.py ← double_sha256, swap_words, target, merkle -│ ├── address.py ← валидация BTC-адресов (bech32/bech32m/Base58Check) -│ ├── demo.py ← offline-майнинг (--demo) -│ ├── bench.py ← бенчмарк хешрейта (--benchmark) -│ ├── storage.py ← SQLite журнал шаров и сессий -│ ├── metrics.py ← Prometheus экспортёр (http.server) -│ ├── notifier.py ← Telegram через urllib -│ ├── _logging.py ← настройка logger("hope_hash") -│ └── py.typed ← PEP 561 marker -└── tests/ - ├── conftest.py ← общие фикстуры (заготовка) - ├── test_block.py ← 22 теста на чистые функции + mid-state - ├── test_storage.py ← 11 тестов на SQLite журнал - ├── test_metrics.py ← 16 тестов на Prometheus экспортёр - ├── test_notifier.py ← 16 тестов на Telegram (через mock) - ├── test_address.py ← 18 тестов на валидацию BTC-адреса - ├── test_stratum.py ← 15 тестов на Stratum-протокол (FakeSocket) - └── test_bench.py ← 3 теста на бенчмарк-режим -``` +Hope-Hash is a tiny but real solo Bitcoin miner written entirely in Python's +standard library. It speaks Stratum V1 over a raw TCP socket, builds the +80-byte block header from scratch (BIP-141 witness commitment included for +solo mode), grinds SHA-256 in pure Python with mid-state caching, and submits +shares the way a real miner does. ---- +The point is education, not income. Your CPU at 2–5 MH/s versus the network +at ~700 EH/s means the lottery odds are roughly 1 in 10^15 per day. The +codebase is small enough that you can step through every protocol message, +every byte of the header, and every endianness flip — see +[`docs/architecture.en.md`](docs/architecture.en.md). + +### Status (v0.7.0) -## Установка и запуск +- Stratum V1 client with multi-pool failover (`--pool` repeatable). +- Solo mode via `getblocktemplate` (`--solo --rpc-url ... --rpc-cookie ...`). +- Multiprocessing nonce search, mid-state SHA-256, optional ctypes libcrypto. +- Curses TUI, web dashboard with SSE, Prometheus `/metrics`, `/healthz`. +- SQLite share journal, Telegram outbound + opt-in inbound commands. +- Docker compose stack (miner + Prometheus + Grafana) with provisioning. +- Bilingual docs (English / Russian) for users, deployers, and contributors. -Никаких runtime-зависимостей, нужен только Python ≥3.11. +### Install + +Python ≥ 3.11. No third-party dependencies. ```bash -# Установка один раз (editable): python -m pip install -e . - -# Запуск любым из способов: -hope-hash [имя_воркера] -python -m hope_hash [имя_воркера] +# Windows: +py -3.11 -m pip install -e . ``` -**Пример:** +### Run + +A mainnet wallet address you control (P2PKH `1...`, P2SH `3...`, bech32 +`bc1q...`, or Taproot `bc1p...`) is mandatory. ```bash hope-hash bc1q5n2x4pvxhq8sxc7ck3uxq8sxc7ck3uxqzfm2py mylaptop +# equivalent: +python -m hope_hash bc1q5n2x4pvxhq8sxc7ck3uxq8sxc7ck3uxqzfm2py mylaptop ``` -**Расширенные опции:** +The address is validated locally (BIP-173 / BIP-350 / Base58Check) before +the first network round-trip — typos fail fast with a precise message. -```bash -hope-hash mylaptop \ - --workers 8 \ # число процессов (default: cpu_count - 1) - --db ./shares.db \ # путь к SQLite (default: hope_hash.db) - --metrics-port 9090 \ # Prometheus /metrics (0 — отключить) - --suggest-diff 0.001 # vardiff: запросить у пула низкую сложность -``` +### Advanced flags + +| Flag | Purpose | +| --- | --- | +| `--workers N` | Worker processes (default `cpu_count - 1`). | +| `--db PATH` | SQLite journal file (`hope_hash.db` by default). `--no-db` disables. | +| `--metrics-port PORT` | Prometheus `/metrics` + `/healthz` (default 9090, 0 = off). | +| `--web-port PORT` | Web dashboard with SSE (default 0 = off). Loopback only by default. | +| `--web-host HOST` | Bind host for the web dashboard (default `127.0.0.1`). | +| `--tui` | Curses dashboard. Quit with `q` / `ESC`. | +| `--pool HOST:PORT` | Repeatable. Round-robin failover after `--rotate-after-failures`. | +| `--solo` | Solo mining via `getblocktemplate`. Requires `--rpc-url` plus auth. | +| `--rpc-cookie PATH` | Cookie auth for bitcoind (overrides `--rpc-user/--rpc-pass`). | +| `--sha-backend {auto,hashlib,ctypes}` | SHA-256 backend; auto picks ctypes if libcrypto loads. | +| `--suggest-diff DIFF` | Send `mining.suggest_difficulty` after authorize (vardiff). | +| `--demo` | Offline mode against a synthetic header. | +| `--benchmark` | Hashrate microbenchmark, no networking. | +| `--no-banner` | Skip the ASCII banner (cron / systemd). | -**Demo-режим (без подключения к пулу):** +Full help: `hope-hash --help`. + +### Demo + +No address, no network, no shares — useful as a smoke test. ```bash -hope-hash --demo # синтетический заголовок, низкая сложность -hope-hash --demo --demo-diff 0.0001 # ещё ниже — найдёт быстрее -hope-hash --demo --workers 4 # сколько процессов перебирают nonce +hope-hash --demo # synthetic header, --demo-diff 0.001 +hope-hash --demo --workers 4 --demo-diff 0.0001 ``` -Demo не нуждается в BTC-адресе и не делает никаких сетевых вызовов — удобно для smoke-теста на машине, где нужно убедиться, что multiprocessing-воркеры стартуют корректно. - -**Бенчмарк-режим:** +### Benchmark ```bash -hope-hash --benchmark # 10 секунд на cpu_count-1 воркерах -hope-hash --benchmark --bench-duration 30 # длиннее = точнее число -hope-hash --benchmark --workers 1 # baseline для одного ядра +hope-hash --benchmark --bench-duration 5 --workers 4 +hope-hash --benchmark --backends # hashlib mid-state vs ctypes ``` -Меряет pure-Python хешрейт без сети и без шар. Полезно как точка отсчёта перед C/Rust/SIMD-оптимизациями (см. ROADMAP уровни 2–3): без числа «до» сравнивать числа «после» бессмысленно. Пример вывода на Intel i7-12700H, 4 воркера, 5 секунд: +Sample output on Intel i7-12700H, 4 workers, mid-state hashlib: ``` -[bench] platform: Windows-10-10.0.26200-SP0 -[bench] python: 3.11.9 (cpython) -[bench] cpu: 16 logical cores (Intel Family 6 Model 151) +[bench] cpu: 16 logical cores [bench] workers: 4, duration: 5.0s -[bench] t= 1.0s hashes= 2,654,208 rate=2.64 MH/s -[bench] t= 2.0s hashes= 5,914,624 rate=2.93 MH/s -[bench] t= 3.0s hashes= 9,093,120 rate=3.01 MH/s [bench] === result === [bench] total hashes: 11,436,032 [bench] wall time: 5.01s [bench] hashrate: 2.28 MH/s -[bench] per-worker: 570.23 KH/s (workers: 4) +[bench] per-worker: 570.23 KH/s ``` -**Валидация BTC-адреса** срабатывает локально перед подключением к пулу. Принимаются только mainnet-адреса: -- `bc1q...` (P2WPKH, P2WSH) — bech32, BIP-173 -- `bc1p...` (Taproot) — bech32m, BIP-350 -- `1...` (P2PKH), `3...` (P2SH) — Base58Check +### Architecture -Неверная контрольная сумма, смешанный регистр, testnet-префикс — отвергаются с конкретным сообщением, без сетевого round-trip. +One process, several threads: a network supervisor reconnects with +exponential backoff; a Stratum reader updates the shared `current_job` +under a `Lock`; the main thread feeds N multiprocessing workers that grind +nonces with a cached mid-state. Optional daemons publish state to TUI, +the web dashboard, Prometheus, Telegram, and `/healthz`. -**Telegram-уведомления (опционально):** задать env vars и просто запустить: +See [`docs/architecture.en.md`](docs/architecture.en.md) for the full +threading model, the hot-path explanation, and the BIP references. -```bash -export HOPE_HASH_TELEGRAM_TOKEN=123456:abcdef-your-bot-token -export HOPE_HASH_TELEGRAM_CHAT_ID=123456789 -hope-hash -``` +### Realistic expectations -**Тесты:** +| Metric | Value | +| --- | --- | +| Hashrate (Python, 1 worker) | 50–200 KH/s | +| Hashrate (4 workers, mid-state) | ~2–3 MH/s | +| Bitcoin network hashrate | ~700 EH/s = 7 × 10²⁰ H/s | +| Your share of the network | ~10⁻¹⁵ | +| Expected blocks per day | 144 | +| Solo block expectation | ~10¹³ days | -```bash -python -m unittest discover -s tests -v # 145 тестов -``` +This is a lottery ticket with cosmically low odds. The interesting part is +the protocol, not the payout — for actual revenue use a pooled miner on +purpose-built hardware. -**Prometheus-метрики, экспортируемые на `/metrics`:** +### Contributing -| Метрика | Тип | Описание | -|---|---|---| -| `hopehash_hashrate_hps` | gauge | EMA-хешрейт в H/s | -| `hopehash_pool_difficulty` | gauge | текущая сложность от пула | -| `hopehash_workers` | gauge | число активных воркеров | -| `hopehash_uptime_seconds` | gauge | время работы майнера | -| `hopehash_shares_total` | counter | всего отправленных шаров | -| `hopehash_shares_accepted_total` | counter | подтверждённых пулом | -| `hopehash_shares_rejected_total` | counter | отклонённых пулом | +- [`CLAUDE.md`](CLAUDE.md) — invariants the agent and humans must respect + (stdlib only, endianness rules, hot-path discipline). +- [`ROADMAP.md`](ROADMAP.md) — feature backlog grouped by difficulty. +- [`docs/getting-started.en.md`](docs/getting-started.en.md) — first-run + walkthrough for users with no Bitcoin background. +- [`docs/deploy.en.md`](docs/deploy.en.md) — Docker compose, Prometheus, + Grafana, Telegram, healthchecks. -BTC-адрес нужен валидный mainnet-адрес (`1...`, `3...`, `bc1q...`, `bc1p...`). Можно завести в любом некастодиальном кошельке — например, **Sparrow**, **Electrum**, **Wasabi**. Имя воркера — произвольная строка. +--- -**Что увидишь:** +## Russian -``` -[net] подключён к solo.ckpool.org:3333 -[stratum] subscribed: extranonce1=ab12cd34, en2_size=4 -[stratum] authorize отправлен для воркера bc1q....mylaptop -[stratum] новая сложность: 1.0 -[stratum] новая работа job_id=4f2 clean=true -[stats] хешрейт ≈ 87 KH/s | pool diff = 1.0 -[stratum] *** ШАР ПРИНЯТ *** (id=3) -... -``` +> Соло-майнер биткоина на чистом stdlib, который можно прочитать за вечер. +> Ноль runtime-зависимостей. Цель — понять, как работает майнинг, а не +> заработать. -`*** ШАР ПРИНЯТ ***` означает, что ты честно работаешь и пул это видит — **это не заработок**. Реальная награда наступит только при `НАЙДЕН ШАР` с хешем ниже **сетевого** target (не пулового), что соответствует найденному блоку. +### Что это ---- +Hope-Hash — крошечный, но настоящий соло-майнер биткоина целиком на +стандартной библиотеке Python. Он говорит на Stratum V1 поверх голого TCP, +собирает 80-байтовый block header руками (включая witness commitment по +BIP-141 для solo-режима), крутит SHA-256 на pure-Python с mid-state +кэшированием и отправляет шары как настоящий майнер. + +Цель — образовательная, не монетарная. CPU на 2–5 MH/s против сети с +~700 EH/s — это лотерея с шансом примерно 1 к 10¹⁵ в день. Кода мало +ровно настолько, чтобы можно было пройти отладчиком каждое сообщение +протокола, каждый байт заголовка и каждый endianness-перевод. Подробности +в [`docs/architecture.ru.md`](docs/architecture.ru.md). + +### Статус (v0.7.0) + +- Stratum V1 клиент с multi-pool failover (`--pool` повторяемый). +- Solo-режим через `getblocktemplate` (`--solo --rpc-url ... --rpc-cookie ...`). +- Multiprocessing-перебор nonce, mid-state SHA-256, опциональный ctypes-libcrypto. +- Curses TUI, web-дашборд с SSE, Prometheus `/metrics`, `/healthz`. +- SQLite-журнал шар, Telegram outbound + opt-in inbound-команды. +- Docker compose стек (miner + Prometheus + Grafana) с provisioning. +- Двуязычная документация (English / Русский) для пользователей, devops и контрибьюторов. -## Архитектура +### Установка +Python ≥ 3.11. Сторонних зависимостей нет. + +```bash +python -m pip install -e . +# Windows: +py -3.11 -m pip install -e . ``` -┌───────────────────────────────────────────┐ -│ solo.ckpool.org:3333 │ -└─────────────────┬─────────────────────────┘ - │ TCP + JSON line-delimited - │ -┌─────────────────▼─────────────────────────┐ -│ StratumClient (main thread) │ -│ • subscribe / authorize │ -│ • держит current_job под Lock │ -└──────┬──────────────────────────┬─────────┘ - │ │ - │ читает входящие │ держит работу - ▼ ▼ -┌─────────────┐ ┌─────────────────┐ -│ reader_loop │ │ mine() loop │ -│ (thread) │ │ (main thread) │ -│ │ │ │ -│ обновляет │ │ 1. coinbase │ -│ current_job │ │ 2. merkle root │ -│ при notify │ │ 3. header base │ -└─────────────┘ │ 4. nonce++ │ - │ 5. SHA256d │ - │ 6. compare │ - │ vs target │ - │ 7. submit ─────┼──> через client - └─────────────────┘ + +### Запуск + +Нужен реальный mainnet-адрес кошелька, который ты контролируешь (P2PKH +`1...`, P2SH `3...`, bech32 `bc1q...` или Taproot `bc1p...`). + +```bash +hope-hash bc1q5n2x4pvxhq8sxc7ck3uxq8sxc7ck3uxqzfm2py mylaptop +# то же самое: +python -m hope_hash bc1q5n2x4pvxhq8sxc7ck3uxq8sxc7ck3uxqzfm2py mylaptop ``` -Один процесс, две нити: одна крутит хеши, вторая слушает пул. Свежий `mining.notify` обновляет `current_job` под локом, цикл хеширования каждые ~16k итераций проверяет, не сменился ли `job_id` — если да, выходит и берёт свежую работу. +Адрес проверяется локально (BIP-173 / BIP-350 / Base58Check) до первого +сетевого round-trip — опечатки отлавливаются с конкретным сообщением. ---- +### Расширенные флаги -## Реалистичные ожидания +| Флаг | Назначение | +| --- | --- | +| `--workers N` | Число воркер-процессов (по умолчанию `cpu_count - 1`). | +| `--db PATH` | SQLite-журнал (`hope_hash.db` по умолчанию). `--no-db` выключает. | +| `--metrics-port PORT` | Prometheus `/metrics` + `/healthz` (default 9090, 0 — выкл). | +| `--web-port PORT` | Web-дашборд с SSE (default 0 — выкл). По умолчанию только loopback. | +| `--web-host HOST` | Bind-хост для web-дашборда (default `127.0.0.1`). | +| `--tui` | Curses-дашборд. Выход на `q` / `ESC`. | +| `--pool HOST:PORT` | Повторяемый. Round-robin failover после `--rotate-after-failures`. | +| `--solo` | Solo-майнинг через `getblocktemplate`. Требует `--rpc-url` + auth. | +| `--rpc-cookie PATH` | Cookie-auth для bitcoind (приоритет над `--rpc-user/--rpc-pass`). | +| `--sha-backend {auto,hashlib,ctypes}` | SHA-256 backend; auto = ctypes если libcrypto загружается. | +| `--suggest-diff DIFF` | Отправляет `mining.suggest_difficulty` после авторизации. | +| `--demo` | Offline-режим с синтетическим заголовком. | +| `--benchmark` | Микробенчмарк хешрейта без сети. | +| `--no-banner` | Без ASCII-баннера (cron / systemd). | -| Метрика | Значение | -|---|---| -| Хешрейт (Python, 1 поток) | 50–200 KH/s | -| Хешрейт всей сети Bitcoin | ~700 EH/s = 7×10²⁰ H/s | -| Доля от сети | ~10⁻¹⁵ | -| Блоков в день | ~144 | -| Ожидание блока соло | ~10¹³ дней | -| Награда при удаче | 3.125 BTC (~$200k) | +Полная справка: `hope-hash --help`. -Это лотерея с космически низкими шансами. Соло-майнинг на CPU имеет смысл только как: -1. **Учебный проект** (понять протокол на пальцах). -2. **Лотерейный билет** (формально шанс не ноль). -3. **База для оптимизации** (можно сравнивать с C/CUDA-версиями). +### Demo -Случаи, когда подобные мини-майнеры **находили** блок за всю историю — единичные, и каждый раз это был громкий новостной повод. +Без адреса, без сети, без шар — удобно для smoke-теста. ---- +```bash +hope-hash --demo # синтетический заголовок, --demo-diff 0.001 +hope-hash --demo --workers 4 --demo-diff 0.0001 +``` + +### Бенчмарк + +```bash +hope-hash --benchmark --bench-duration 5 --workers 4 +hope-hash --benchmark --backends # hashlib mid-state vs ctypes +``` -## Кандидаты на название +Пример вывода на Intel i7-12700H, 4 воркера, mid-state hashlib: + +``` +[bench] cpu: 16 logical cores +[bench] workers: 4, duration: 5.0s +[bench] === result === +[bench] total hashes: 11,436,032 +[bench] wall time: 5.01s +[bench] hashrate: 2.28 MH/s +[bench] per-worker: 570.23 KH/s +``` -Финальное имя проекта не выбрано. Шорт-лист, на котором остановились: +### Архитектура -**Серьёзные:** -- **pyrite** — пирит, «золото дураков», + отсылка к Python (py-) -- **Sisyphus** — Сизиф, вечно катит хеш в гору +Один процесс, несколько нитей: сетевой supervisor переподключается с +экспоненциальным backoff; Stratum-reader обновляет общий `current_job` +под `Lock`; main thread кормит N multiprocessing-воркеров, перебирающих +nonce с кэшированным mid-state. Опциональные демоны публикуют состояние +в TUI, web-дашборд, Prometheus, Telegram и `/healthz`. -**Самоироничные:** -- **CopiumMiner** — `copium` (мем-вещество, чтобы примириться с реальностью) -- **statisticallynever** — про реальный шанс -- **HopeHash** — короткое и грустное +Подробная threading-модель, hot-path и BIP-ссылки — в +[`docs/architecture.ru.md`](docs/architecture.ru.md). -**Технические:** -- **PicoMiner** / **NanoNonce** — про размер -- **bitfly** — битовая муха +### Реалистичные ожидания -Перед выбором проверить: -- занятость на GitHub: `https://github.com/` -- занятость на PyPI: `pip show ` -- свободный домен: `.dev` / `.io` +| Метрика | Значение | +| --- | --- | +| Хешрейт (Python, 1 воркер) | 50–200 KH/s | +| Хешрейт (4 воркера, mid-state) | ~2–3 MH/s | +| Хешрейт сети Bitcoin | ~700 EH/s = 7 × 10²⁰ H/s | +| Доля от сети | ~10⁻¹⁵ | +| Блоков в день | 144 | +| Ожидание блока соло | ~10¹³ дней | ---- +Это лотерейный билет с космически низкими шансами. Интересна не выплата, +а протокол — для реальных доходов нужен пуловый майнер на специальном +железе. -## Дальше +### Контрибьюция -См. **[ROADMAP.md](./ROADMAP.md)** — план развития, разбитый на лёгкое / среднее / сложное и сгруппированный по фичам. +- [`CLAUDE.md`](CLAUDE.md) — инварианты, которые соблюдают и агент, и + люди (только stdlib, правила endianness, дисциплина hot-path). +- [`ROADMAP.md`](ROADMAP.md) — список фич, сгруппированный по сложности. +- [`docs/getting-started.ru.md`](docs/getting-started.ru.md) — первый + запуск для пользователей без bitcoin-опыта. +- [`docs/deploy.ru.md`](docs/deploy.ru.md) — Docker compose, Prometheus, + Grafana, Telegram, healthchecks. diff --git a/ROADMAP.md b/ROADMAP.md index 496b6f9..517f480 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -72,11 +72,17 @@ TUI и команды Telegram — отложены. ### Web-морда -- [ ] **FastAPI-сервис рядом с майнером.** Эндпоинты: - - `GET /stats` — JSON со статистикой - - `POST /restart` — перезапуск нитей - - `GET /` — HTML-страница с дашбордом - - `WS /live` — WebSocket стрим хешей в реальном времени +- [x] **Web-дашборд на stdlib `http.server`** (`webui.py`, v0.7.0): + CLI `--web-port`, single-page HTML без CDN, vanilla JS, inline-SVG + sparkline. Эндпоинты: + - `GET /` — HTML-дашборд с polling `/api/stats` каждые 2с. + - `GET /api/stats` — JSON snapshot (no-store). + - `GET /api/events` — Server-Sent Events (`share_*` / `job` / `pool`). + - `GET /healthz` — то же тело, что у metrics-сервера. + FastAPI / WebSocket вариант не делаем — Stdlib + SSE покрывает + потребности дашборда без новых зависимостей. +- [ ] **POST /restart / POST /stop через web** — остался на потом + (Telegram-команды уже есть). Веб-эндпоинты потребуют CSRF + auth. - [ ] **Конфиг через web-интерфейс**, чтобы не редактировать YAML руками. --- @@ -97,7 +103,7 @@ TUI и команды Telegram — отложены. ### Мониторинг и SRE - [x] **Healthchecks endpoint.** `/healthz` JSON на metrics-сервере (v0.5.0). 200/503, флаг `--healthz-stale-after`. -- [ ] **Docker-образ.** `Dockerfile`, `docker-compose.yml` с volumes для логов и конфигов. +- [x] **Docker-образ.** `Dockerfile` (`python:3.11-slim`, healthcheck через stdlib `urllib`) + `docker-compose.yml` (miner + Prometheus + Grafana, volumes для SQLite и provisioning) + `.dockerignore` (v0.7.0). - [ ] **Helm chart**, если совсем хочется хардкора с k8s на одном Raspberry Pi. --- @@ -116,6 +122,29 @@ TUI и команды Telegram — отложены. --- +## Сознательно отложено (не включено в v0.7.0) + +После трёх PR'ов (ops/UX, perf/resilience, web/docs) вот что **намеренно** +не сделано — каждое требует либо новой зависимости, либо отдельного +крупного эпика: + +- **Stratum V2.** Бинарный протокол с Noise-handshake. Реализация + Noise на stdlib возможна, но нетривиальный sub-project; ждёт + отдельного PR. +- **Rust core через PyO3.** Требует Rust toolchain в build. Ожидаемый + выигрыш ~×100, но это уже не «учебный проект, который можно + установить через `pip install -e .`». +- **GPU (PyOpenCL / cupy).** Большие сторонние зависимости и + привязка к драйверам. Не вписывается в pure-stdlib. +- **FastAPI вместо stdlib `http.server`.** Дала бы swagger / async, но + это +большая зависимость. Текущий webui выдерживает все требования + дашборда без них. +- **Helm chart / k8s manifests.** Compose-стек покрывает все реальные + use-кейсы для проекта такого масштаба. +- **POST `/stop` / `/restart` через web.** Telegram-команды уже + существуют; web-write-эндпоинты потребуют auth + CSRF — отдельный + эпик. + ## Если выбирать одно Если энергии хватит только на один шаг после стабилизации (Уровень 0), то самое осмысленное: diff --git a/deploy/grafana/dashboard.yml b/deploy/grafana/dashboard.yml new file mode 100644 index 0000000..a396171 --- /dev/null +++ b/deploy/grafana/dashboard.yml @@ -0,0 +1,18 @@ +# Provisioning дашбордов: Grafana подцепит все JSON-файлы из +# /var/lib/grafana/dashboards (туда docker-compose.yml монтирует +# директорию ./deploy/grafana, в которой лежит hope-hash.json). + +apiVersion: 1 + +providers: + - name: hope-hash + orgId: 1 + folder: Hope-Hash + type: file + disableDeletion: false + editable: true + updateIntervalSeconds: 30 + allowUiUpdates: true + options: + path: /var/lib/grafana/dashboards + foldersFromFilesStructure: false diff --git a/deploy/grafana/datasource.yml b/deploy/grafana/datasource.yml new file mode 100644 index 0000000..a55ac74 --- /dev/null +++ b/deploy/grafana/datasource.yml @@ -0,0 +1,14 @@ +# Provisioning Prometheus-датасорса для Grafana. +# Имя `Prometheus` совпадает с переменной datasource в hope-hash.json. + +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: true + jsonData: + timeInterval: 15s diff --git a/deploy/prometheus/prometheus.yml b/deploy/prometheus/prometheus.yml new file mode 100644 index 0000000..5e7dd7d --- /dev/null +++ b/deploy/prometheus/prometheus.yml @@ -0,0 +1,16 @@ +# Минимальный Prometheus-конфиг для hope-hash. +# Скрейпит /metrics майнера каждые 15с. Имя сервиса `miner` — из docker-compose.yml. + +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: hope-hash + metrics_path: /metrics + static_configs: + - targets: + - miner:8000 + labels: + service: hope-hash + env: docker diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d944e45 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,94 @@ +# Hope-Hash полный стек: miner + Prometheus + Grafana. +# +# Перед запуском: +# 1. Положить mainnet BTC-адрес в .env как BTC_ADDRESS=bc1q... +# (можно подсунуть через `BTC_ADDRESS=... docker compose up`). +# 2. Опционально: HOPE_HASH_TELEGRAM_TOKEN и HOPE_HASH_TELEGRAM_CHAT_ID +# для Telegram-уведомлений. +# +# docker compose up -d +# open http://localhost:8000 # web-дашборд +# open http://localhost:3000 # Grafana (admin / admin при первом входе) +# open http://localhost:9090 # Prometheus UI +# +# Если порт 8000 занят — поменяйте маппинг здесь и у Prometheus в +# deploy/prometheus/prometheus.yml. + +services: + miner: + build: + context: . + dockerfile: Dockerfile + image: hope-hash:0.7.0 + container_name: hope-hash-miner + restart: unless-stopped + # Single combined HTTP-port: --metrics-port 8000 для /metrics+/healthz, + # --web-port 8001 — отдельный для дашборда. Compose пробрасывает оба. + command: > + ${BTC_ADDRESS:?Set BTC_ADDRESS env var to your mainnet wallet address} + docker + --workers ${WORKERS:-2} + --db /data/hope_hash.db + --metrics-port 8000 + --web-port 8001 + --web-host 0.0.0.0 + --no-banner + --healthz-stale-after 600 + environment: + # Telegram опционален — если не задан, notifier тихо отключается. + HOPE_HASH_TELEGRAM_TOKEN: ${HOPE_HASH_TELEGRAM_TOKEN:-} + HOPE_HASH_TELEGRAM_CHAT_ID: ${HOPE_HASH_TELEGRAM_CHAT_ID:-} + # Включает long-poll для команд /stats/stop/restart. По умолчанию off. + HOPE_HASH_TELEGRAM_INBOUND: ${HOPE_HASH_TELEGRAM_INBOUND:-0} + volumes: + # SQLite-журнал шар хранится снаружи контейнера, чтобы выживать рестарты. + - ./data:/data + ports: + - "8000:8000" # /metrics + /healthz (Prometheus scrape + k8s probe) + - "8001:8001" # web-дашборд (HTML + /api/stats + SSE) + networks: [hope-net] + + prometheus: + image: prom/prometheus:latest + container_name: hope-hash-prometheus + restart: unless-stopped + command: + - "--config.file=/etc/prometheus/prometheus.yml" + - "--storage.tsdb.path=/prometheus" + - "--storage.tsdb.retention.time=30d" + volumes: + - ./deploy/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prom-data:/prometheus + ports: + - "9090:9090" + depends_on: + - miner + networks: [hope-net] + + grafana: + image: grafana/grafana:latest + container_name: hope-hash-grafana + restart: unless-stopped + environment: + GF_SECURITY_ADMIN_USER: ${GRAFANA_USER:-admin} + GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD:-admin} + GF_USERS_ALLOW_SIGN_UP: "false" + volumes: + # Provisioning datasource (Prometheus) и dashboard (hope-hash.json). + - ./deploy/grafana/datasource.yml:/etc/grafana/provisioning/datasources/datasource.yml:ro + - ./deploy/grafana/dashboard.yml:/etc/grafana/provisioning/dashboards/dashboard.yml:ro + - ./deploy/grafana:/var/lib/grafana/dashboards:ro + - grafana-data:/var/lib/grafana + ports: + - "3000:3000" + depends_on: + - prometheus + networks: [hope-net] + +networks: + hope-net: + driver: bridge + +volumes: + prom-data: + grafana-data: diff --git a/docs/architecture.en.md b/docs/architecture.en.md new file mode 100644 index 0000000..8361498 --- /dev/null +++ b/docs/architecture.en.md @@ -0,0 +1,142 @@ +# Architecture + +Hope-Hash is intentionally small. This document is a map of every file and +every thread, plus the rationale for the choices that look weird at first +sight. + +## Protocol summary + +Stratum V1 (line-delimited JSON over TCP) for pool mode; Bitcoin Core +JSON-RPC `getblocktemplate` / `submitblock` (BIP-22 / BIP-23) for solo +mode. Both produce the same internal `current_job` dict, so `mine()` does +not care where the work came from. + +Block header layout (80 bytes, little-endian unless noted): + +``` +4 version (LE) +32 prev_hash (word-swapped, see block.swap_words) +32 merkle_root (raw double-SHA-256 output) +4 ntime (LE) +4 nbits (LE) +4 nonce (LE, the only field we vary) +``` + +The version / ntime / nbits flips and the prev_hash word-swap are not +cosmetic — they were verified against real mainnet blocks. Do not "clean +them up": see `CLAUDE.md` invariants. + +## Threading model + +One process plus, optionally, N multiprocessing worker children. In the +main process: + +| Thread | Owner | Lifetime | What it does | +| --- | --- | --- | --- | +| main | `cli.main` | the whole run | parses args, runs `mine()` (the hot orchestration loop), handles `Ctrl+C`. | +| `stratum-supervisor` / `solo-supervisor` | `miner.supervisor_loop` | the whole run | reconnect with exponential backoff; rotate pools on failure; honour `restart_event`. | +| `stratum-reader` | `StratumClient.reader_loop` | per-connection | read JSON lines, dispatch `mining.notify` / `set_difficulty` / submit replies. **Not** a daemon — we want to join cleanly on Ctrl+C. | +| `hope_hash-tui` | `tui.TUIApp` | optional | curses dashboard, daemon. | +| `hope_hash-metrics-NNNN` | `metrics.MetricsServer` | optional | Prometheus `/metrics` and `/healthz`, daemon. | +| `hope_hash-webui-NNNN` | `webui.WebUIServer` | optional | HTML / `/api/stats` / `/api/events`, daemon. | +| `telegram-out` / `telegram-in` | `notifier.TelegramNotifier` | optional | outbound queue worker + (opt-in) long-poll for inbound commands. | + +Worker children come from `multiprocessing` (spawn-safe) and run the +nonce loop in `parallel.worker`. + +## Shared state + +- `client.current_job` — guarded by `client.job_lock`. Reader writes, + miner reads. The miner re-checks the job ID every ~16k hashes so a + fresh `mining.notify` interrupts the nonce loop fast. +- `client.stop_event` — the universal kill switch. Any thread that sees + it set must wind down: the supervisor stops reconnecting, `mine()` + returns, `reader_loop` exits. +- `StatsProvider` — the canonical data bus for outside observers (TUI, + `/healthz`, `/metrics`, web). All access goes through a `threading.Lock`. + `subscribe()` adds a callback for SSE events. + +## File map + +| File | Role | +| --- | --- | +| `block.py` | Pure functions: `double_sha256`, `swap_words`, `difficulty_to_target`, `build_merkle_root`. No side effects, fully unit-tested. | +| `address.py` | Mainnet BTC address validation: BIP-173 (bech32), BIP-350 (bech32m), Base58Check. | +| `stratum.py` | `StratumClient` (TCP + JSON-RPC). `set_endpoint()` for multi-pool failover. | +| `pools.py` | `PoolList` round-robin failover, deduplication, `mark_failed/success`. | +| `solo.py` | `SoloClient` (duck-typed `StratumClient`), `BitcoinRPC`, coinbase + witness commitment builders. | +| `parallel.py` | Worker dispatch (`hashlib` mid-state vs `ctypes` libcrypto), `start_pool`, `stop_pool`. | +| `sha_native.py` | Optional `ctypes` wrapper around libcrypto (`libcrypto-3.dll` / `.so.3` / `.dylib`). | +| `miner.py` | `mine()` orchestrator, `supervisor_loop`, `_build_header_base`. | +| `bench.py` | Microbenchmark, `--backends` matrix runner. | +| `demo.py` | Offline mode against a synthetic header. | +| `storage.py` | SQLite share / session journal (WAL mode). | +| `metrics.py` | Prometheus exporter and `/healthz`. | +| `notifier.py` | Telegram outbound + opt-in inbound long-poll. | +| `tui.py` | `StatsProvider`, `StatsSnapshot`, curses `TUIApp`, formatters. | +| `webui.py` | Stdlib `http.server` dashboard: HTML, `/api/stats`, `/api/events` (SSE), `/healthz`. | +| `banner.py` | ASCII banner. | +| `cli.py` | Argparse, observer wiring, lifecycle. | + +## Hot path + +`parallel._worker_hashlib_midstate`. The 80-byte header is `64 + 16` — +SHA-256 absorbs in 64-byte blocks, so the first block is constant within +a nonce loop. We `hashlib.sha256().copy()` after the first absorb and +only feed the trailing 16 bytes per nonce. Empirically ~1.5× speedup +versus naïve double-SHA-256. + +Anything in this loop that allocates or branches kills the hashrate. +Benchmark every change with `--benchmark --bench-duration 10` against +the previous commit. + +## Observers + +All optional, all driven by either CLI flags or env vars. None of them +mutates miner state — they only read `StatsProvider` and write to their +own sinks (HTTP, SQLite, network): + +- `--db PATH` → `ShareStore` (SQLite, WAL). +- `--metrics-port N` → `MetricsServer` (`/metrics`, `/healthz`). +- `--web-port N` → `WebUIServer` (HTML, `/api/stats`, `/api/events`). +- `--tui` → `TUIApp`. +- `HOPE_HASH_TELEGRAM_*` → `TelegramNotifier`. + +## Performance notes + +Pure-Python SHA-256 ceiling on a modern CPU is ~0.5–1 MH/s per worker. +Mid-state caching brings it close to the upper bound. The `ctypes` +backend pays a Python→C overhead that exceeds the SHA-256 cost itself, +so it is slower than mid-state hashlib for mining; it exists for the +benchmark matrix and for honest comparison with future C / Rust +extensions. + +## ctypes backend trade-off + +`sha_native.py` tries `libcrypto-3.dll` / `libcrypto.so.3` / +`/opt/homebrew/lib/libcrypto.dylib` etc. via `ctypes.CDLL`. If none load, +the backend silently falls back to `hashlib`. We do not allow arbitrary +load paths from environment input — the search list is hardcoded. + +## Solo mode caveats + +`SoloClient` polls `getblocktemplate` and assembles the coinbase with +BIP-34 height push and (when bitcoind exposes +`default_witness_commitment`) a BIP-141 witness commitment. The payout +script is a placeholder OP_RETURN — finding a real block needs a proper +P2WPKH / P2PKH script. This is intentional educational scope; see the +PR-B handoff for the rationale. + +## References + +- BIP-22 / BIP-23 — `getblocktemplate`. +- BIP-34 — coinbase height in scriptSig. +- BIP-141 — segwit / witness commitment. +- BIP-173 / BIP-350 — bech32 / bech32m. +- Stratum V1 — see [Braiins's reference](https://braiins.com/stratum-v1/docs). + +## See also + +- [`architecture.ru.md`](architecture.ru.md) — Russian version. +- [`getting-started.en.md`](getting-started.en.md) — first run. +- [`deploy.en.md`](deploy.en.md) — Docker, Prometheus, Grafana. diff --git a/docs/architecture.ru.md b/docs/architecture.ru.md new file mode 100644 index 0000000..8852632 --- /dev/null +++ b/docs/architecture.ru.md @@ -0,0 +1,139 @@ +# Архитектура + +Hope-Hash намеренно маленький. Этот документ — карта файлов и нитей плюс +обоснования решений, которые сначала выглядят странно. + +## Краткое описание протокола + +Stratum V1 (line-delimited JSON поверх TCP) для pool-режима; Bitcoin Core +JSON-RPC `getblocktemplate` / `submitblock` (BIP-22 / BIP-23) для +solo-режима. Оба производят одинаковый внутренний `current_job` dict, +поэтому `mine()` всё равно, откуда пришла работа. + +Layout block header (80 байт, little-endian кроме отмеченного): + +``` +4 version (LE) +32 prev_hash (word-swap, см. block.swap_words) +32 merkle_root (raw double-SHA-256 output) +4 ntime (LE) +4 nbits (LE) +4 nonce (LE, единственное поле, которое мы перебираем) +``` + +Перевороты version/ntime/nbits и word-swap для prev_hash — не косметика, +они проверены против реальных mainnet-блоков. Не «причёсывать»: см. +инварианты в `CLAUDE.md`. + +## Threading-модель + +Один процесс плюс опционально N multiprocessing-worker детей. В главном +процессе: + +| Нить | Владелец | Жизненный цикл | Что делает | +| --- | --- | --- | --- | +| main | `cli.main` | весь run | парсит argparse, крутит `mine()`, ловит `Ctrl+C`. | +| `stratum-supervisor` / `solo-supervisor` | `miner.supervisor_loop` | весь run | reconnect с экспоненциальным backoff; ротация пулов на фейле; `restart_event`. | +| `stratum-reader` | `StratumClient.reader_loop` | per-connection | читает JSON-строки, диспатчит `mining.notify` / `set_difficulty` / submit-ответы. **Не** daemon — хотим чисто join'нуть на Ctrl+C. | +| `hope_hash-tui` | `tui.TUIApp` | опц. | curses-дашборд, daemon. | +| `hope_hash-metrics-NNNN` | `metrics.MetricsServer` | опц. | Prometheus `/metrics` и `/healthz`, daemon. | +| `hope_hash-webui-NNNN` | `webui.WebUIServer` | опц. | HTML / `/api/stats` / `/api/events`, daemon. | +| `telegram-out` / `telegram-in` | `notifier.TelegramNotifier` | опц. | outbound queue worker + (opt-in) long-poll для inbound-команд. | + +Воркер-дети — `multiprocessing` (spawn-safe), nonce-цикл в +`parallel.worker`. + +## Общее состояние + +- `client.current_job` — под `client.job_lock`. Reader пишет, miner + читает. Miner перепроверяет job ID каждые ~16k хешей, чтобы свежий + `mining.notify` быстро рвал nonce-цикл. +- `client.stop_event` — универсальный kill switch. Любая нить, + увидевшая его, должна сворачиваться: supervisor перестаёт + переподключаться, `mine()` возвращается, `reader_loop` выходит. +- `StatsProvider` — каноничная шина данных для внешних наблюдателей + (TUI, `/healthz`, `/metrics`, web). Доступ через `threading.Lock`. + `subscribe()` подписывает callback на SSE-события. + +## Карта файлов + +| Файл | Роль | +| --- | --- | +| `block.py` | Чистые функции: `double_sha256`, `swap_words`, `difficulty_to_target`, `build_merkle_root`. Без сайд-эффектов, под тестами. | +| `address.py` | Валидация mainnet BTC-адресов: BIP-173 (bech32), BIP-350 (bech32m), Base58Check. | +| `stratum.py` | `StratumClient` (TCP + JSON-RPC). `set_endpoint()` для multi-pool failover. | +| `pools.py` | `PoolList` round-robin failover, дедуп, `mark_failed/success`. | +| `solo.py` | `SoloClient` (duck-typed `StratumClient`), `BitcoinRPC`, билдеры coinbase + witness commitment. | +| `parallel.py` | Диспатч воркеров (`hashlib` mid-state vs `ctypes` libcrypto), `start_pool`, `stop_pool`. | +| `sha_native.py` | Опциональный `ctypes`-обёртка над libcrypto (`libcrypto-3.dll` / `.so.3` / `.dylib`). | +| `miner.py` | `mine()` оркестратор, `supervisor_loop`, `_build_header_base`. | +| `bench.py` | Микробенчмарк, `--backends` matrix runner. | +| `demo.py` | Offline-режим против синтетического заголовка. | +| `storage.py` | SQLite share/session журнал (WAL). | +| `metrics.py` | Prometheus-экспортёр и `/healthz`. | +| `notifier.py` | Telegram outbound + opt-in inbound long-poll. | +| `tui.py` | `StatsProvider`, `StatsSnapshot`, curses `TUIApp`, форматтеры. | +| `webui.py` | Stdlib `http.server` дашборд: HTML, `/api/stats`, `/api/events` (SSE), `/healthz`. | +| `banner.py` | ASCII-баннер. | +| `cli.py` | Argparse, разводка observers, lifecycle. | + +## Hot path + +`parallel._worker_hashlib_midstate`. 80-байтовый header — это `64 + 16`, +SHA-256 absorbs 64-байтовыми блоками, поэтому первый блок — константа в +рамках nonce-цикла. Делаем `hashlib.sha256().copy()` после первого +absorb и кормим только финальные 16 байт на каждый nonce. На практике +~×1.5 ускорение по сравнению с наивным double-SHA-256. + +Любая аллокация или ветвление внутри этого цикла убивает хешрейт. +Меряй каждое изменение через `--benchmark --bench-duration 10` против +предыдущего коммита. + +## Observers + +Все опциональны, все включаются CLI-флагами или env vars. Никто из них +не мутирует state майнера — только читает `StatsProvider` и пишет в +свой sink (HTTP, SQLite, сеть): + +- `--db PATH` → `ShareStore` (SQLite, WAL). +- `--metrics-port N` → `MetricsServer` (`/metrics`, `/healthz`). +- `--web-port N` → `WebUIServer` (HTML, `/api/stats`, `/api/events`). +- `--tui` → `TUIApp`. +- `HOPE_HASH_TELEGRAM_*` → `TelegramNotifier`. + +## Заметки про производительность + +Потолок pure-Python SHA-256 на современном CPU — ~0.5–1 MH/s на воркер. +Mid-state-кэш доводит до этого потолка. ctypes-backend платит overhead +Python→C, превышающий саму стоимость SHA-256, поэтому он **медленнее** +mid-state hashlib для майнинга; он существует для бенчмарк-матрицы и +честного сравнения с будущими C/Rust расширениями. + +## Trade-off ctypes-backend + +`sha_native.py` пробует `libcrypto-3.dll` / `libcrypto.so.3` / +`/opt/homebrew/lib/libcrypto.dylib` через `ctypes.CDLL`. Если ни один не +загрузился — тихо fallback на `hashlib`. Произвольные load-пути из env +не разрешаем — список зашит в коде. + +## Solo-режим caveats + +`SoloClient` поллит `getblocktemplate` и собирает coinbase с push'ом +BIP-34 height и (когда bitcoind отдаёт `default_witness_commitment`) +BIP-141 witness commitment. Payout-script — заглушка OP_RETURN; для +реального блока нужен настоящий P2WPKH/P2PKH. Это сознательная +учебная граница; обоснование — в handoff PR-B. + +## Ссылки + +- BIP-22 / BIP-23 — `getblocktemplate`. +- BIP-34 — height в coinbase scriptSig. +- BIP-141 — segwit / witness commitment. +- BIP-173 / BIP-350 — bech32 / bech32m. +- Stratum V1 — [референс от Braiins](https://braiins.com/stratum-v1/docs). + +## См. также + +- [`architecture.en.md`](architecture.en.md) — английская версия. +- [`getting-started.ru.md`](getting-started.ru.md) — первый запуск. +- [`deploy.ru.md`](deploy.ru.md) — Docker, Prometheus, Grafana. diff --git a/docs/deploy.en.md b/docs/deploy.en.md new file mode 100644 index 0000000..4887582 --- /dev/null +++ b/docs/deploy.en.md @@ -0,0 +1,109 @@ +# Deploy: Docker compose, Prometheus, Grafana, Telegram + +This guide covers the long-running setup: a containerised miner with +Prometheus scraping its metrics, Grafana visualising them, Telegram +notifications, and a `/healthz` endpoint for uptime monitoring. + +## 1. Prerequisites + +- Docker 24+ and Docker Compose v2 (`docker compose version`). +- A mainnet BTC address you control. +- Optional: a Telegram bot token (`@BotFather`) and your numeric chat ID. + +## 2. Start the stack + +From the repo root: + +```bash +export BTC_ADDRESS="bc1q...your_real_address..." +export WORKERS=2 # adjust to your CPU +docker compose up -d +docker compose logs -f miner +``` + +Three containers come up: + +- `hope-hash-miner` — the miner itself, exposing `8000` (Prometheus + `/metrics` and `/healthz`) and `8001` (web dashboard). +- `hope-hash-prometheus` — scrapes `miner:8000/metrics` every 15s. +- `hope-hash-grafana` — provisioned with the Prometheus datasource and + the bundled `hope-hash.json` dashboard. + +## 3. Open the dashboards + +- **Web dashboard** — . Live hashrate sparkline, + share counters, current pool, current job ID, SSE event stream. +- **Grafana** — . Default login `admin / admin` + (override via `GRAFANA_USER` / `GRAFANA_PASSWORD` env vars). The + Hope-Hash dashboard is preloaded under the "Hope-Hash" folder. +- **Prometheus** — . Useful for ad-hoc PromQL + (`rate(hopehash_shares_total[5m])` etc.). +- **Healthcheck** — `curl http://localhost:8000/healthz`. Returns + HTTP 200 when the reader thread is alive and the EMA hashrate is fresh, + 503 otherwise. Suitable for k8s liveness / uptime monitoring. + +## 4. Telegram notifications + +Create a bot with [@BotFather](https://t.me/BotFather), grab the token, +write to your bot once so it sees your chat, then find your chat ID via +/getUpdates>. + +Add to `.env` (or pass directly): + +``` +HOPE_HASH_TELEGRAM_TOKEN=123456:abcdef-your-bot-token +HOPE_HASH_TELEGRAM_CHAT_ID=123456789 +HOPE_HASH_TELEGRAM_INBOUND=1 # opt-in: enable /stats /stop /restart commands +``` + +Restart the stack: `docker compose up -d --force-recreate miner`. + +You will get notifications on start / stop / share-accepted / disconnect. +With `HOPE_HASH_TELEGRAM_INBOUND=1` the bot also accepts `/stats`, +`/stop`, `/restart`, and `/help` — only from your `chat_id`, others are +dropped. + +## 5. Persistence + +The compose file mounts `./data:/data` for the SQLite share journal +(`hope_hash.db`) and uses named volumes for Prometheus and Grafana state. +Container restarts keep history; `docker compose down -v` wipes volumes. + +## 6. Adapting for a single host without compose + +```bash +docker build -t hope-hash:0.7.0 . +docker run -d --name hope-hash \ + -e HOPE_HASH_TELEGRAM_TOKEN=... \ + -e HOPE_HASH_TELEGRAM_CHAT_ID=... \ + -p 8000:8000 -p 8001:8001 \ + -v $(pwd)/data:/data \ + hope-hash:0.7.0 \ + bc1q...your_address... docker --workers 2 \ + --metrics-port 8000 --web-port 8001 --web-host 0.0.0.0 --no-banner +``` + +## 7. Behind a reverse proxy + +The web dashboard has no auth on purpose — it is bound to loopback by +default and assumes a reverse proxy in front of it for any non-local +deployment. Example nginx snippet: + +``` +location /hope-hash/ { + auth_basic "hope-hash"; + auth_basic_user_file /etc/nginx/htpasswd; + proxy_pass http://127.0.0.1:8001/; + # SSE needs unbuffered + long timeouts. + proxy_buffering off; + proxy_read_timeout 1h; +} +``` + +## 8. See also + +- [`getting-started.en.md`](getting-started.en.md) — first run on bare + metal, before Docker. +- [`architecture.en.md`](architecture.en.md) — what the threads do, where + the metrics come from. +- [`deploy.ru.md`](deploy.ru.md) — Russian version. diff --git a/docs/deploy.ru.md b/docs/deploy.ru.md new file mode 100644 index 0000000..875f6d7 --- /dev/null +++ b/docs/deploy.ru.md @@ -0,0 +1,108 @@ +# Деплой: Docker compose, Prometheus, Grafana, Telegram + +Это про долгий запуск: контейнеризованный майнер, Prometheus снимает с +него метрики, Grafana их рисует, Telegram присылает уведомления, а +`/healthz` обслуживает мониторинг аптайма. + +## 1. Что нужно + +- Docker 24+ и Docker Compose v2 (`docker compose version`). +- Mainnet BTC-адрес, который ты контролируешь. +- Опционально: токен Telegram-бота (`@BotFather`) и численный chat_id. + +## 2. Поднять стек + +Из корня репозитория: + +```bash +export BTC_ADDRESS="bc1q...твой_реальный_адрес..." +export WORKERS=2 # подбери под свой CPU +docker compose up -d +docker compose logs -f miner +``` + +Поднимется три контейнера: + +- `hope-hash-miner` — сам майнер, открывает `8000` (Prometheus + `/metrics` и `/healthz`) и `8001` (web-дашборд). +- `hope-hash-prometheus` — скрейпит `miner:8000/metrics` каждые 15с. +- `hope-hash-grafana` — с провиженным Prometheus-датасорсом и готовым + дашбордом `hope-hash.json`. + +## 3. Открыть дашборды + +- **Web-дашборд** — . Live-sparkline хешрейта, + счётчики шар, текущий пул, текущий job ID, SSE-стрим событий. +- **Grafana** — . Логин по умолчанию + `admin / admin` (переопредели через env `GRAFANA_USER` / + `GRAFANA_PASSWORD`). Дашборд Hope-Hash уже загружен в папку «Hope-Hash». +- **Prometheus** — . Удобно для ad-hoc PromQL + (`rate(hopehash_shares_total[5m])` и т.д.). +- **Healthcheck** — `curl http://localhost:8000/healthz`. HTTP 200 если + reader-нить жива и EMA-хешрейт свежий, 503 иначе. Подходит для k8s + liveness и uptime-мониторинга. + +## 4. Telegram-уведомления + +Создай бота через [@BotFather](https://t.me/BotFather), забери токен, +напиши боту хотя бы одно сообщение, чтобы он увидел чат, потом узнай +chat_id через /getUpdates>. + +Добавь в `.env` (или передай напрямую): + +``` +HOPE_HASH_TELEGRAM_TOKEN=123456:abcdef-your-bot-token +HOPE_HASH_TELEGRAM_CHAT_ID=123456789 +HOPE_HASH_TELEGRAM_INBOUND=1 # opt-in: включает /stats /stop /restart +``` + +Перезапусти стек: `docker compose up -d --force-recreate miner`. + +Получишь уведомления на старт / стоп / share-accepted / disconnect. При +`HOPE_HASH_TELEGRAM_INBOUND=1` бот принимает команды `/stats`, `/stop`, +`/restart`, `/help` — только от твоего `chat_id`, остальные игнорируются. + +## 5. Постоянство данных + +Compose монтирует `./data:/data` для SQLite-журнала шар (`hope_hash.db`) +и использует named volumes для Prometheus и Grafana. Рестарты +контейнеров сохраняют историю; `docker compose down -v` стирает volume'ы. + +## 6. Без compose, на одном хосте + +```bash +docker build -t hope-hash:0.7.0 . +docker run -d --name hope-hash \ + -e HOPE_HASH_TELEGRAM_TOKEN=... \ + -e HOPE_HASH_TELEGRAM_CHAT_ID=... \ + -p 8000:8000 -p 8001:8001 \ + -v $(pwd)/data:/data \ + hope-hash:0.7.0 \ + bc1q...твой_адрес... docker --workers 2 \ + --metrics-port 8000 --web-port 8001 --web-host 0.0.0.0 --no-banner +``` + +## 7. За reverse-proxy + +У web-дашборда нет встроенной авторизации — он по умолчанию слушает +только loopback и расчёт на reverse-proxy с auth для любого внешнего +выставления. Пример nginx: + +``` +location /hope-hash/ { + auth_basic "hope-hash"; + auth_basic_user_file /etc/nginx/htpasswd; + proxy_pass http://127.0.0.1:8001/; + # SSE требует выключенный буферинг и длинные таймауты. + proxy_buffering off; + proxy_read_timeout 1h; +} +``` + +## 8. См. также + +- [`getting-started.ru.md`](getting-started.ru.md) — первый запуск на + голом железе, до Docker. +- [`architecture.ru.md`](architecture.ru.md) — что делают нити, откуда + берутся метрики. +- [`deploy.en.md`](deploy.en.md) — английская версия. diff --git a/docs/getting-started.en.md b/docs/getting-started.en.md new file mode 100644 index 0000000..7683aa8 --- /dev/null +++ b/docs/getting-started.en.md @@ -0,0 +1,97 @@ +# Getting started + +This guide is for someone who has never run a Bitcoin miner before. We will +install Python, get a wallet address you control, run the miner once, and +then read the logs together. No prior crypto background is assumed. + +## 1. Install Python 3.11+ + +Hope-Hash only uses the standard library, so Python is the only thing you +need. + +- **Windows.** Open the Microsoft Store and install "Python 3.11" (or + download `python-3.11.x-amd64.exe` from python.org). Tick "Add Python to + PATH" in the installer. Confirm with `py -3.11 --version`. +- **macOS.** `brew install python@3.11`. Confirm with `python3.11 --version`. +- **Linux.** Use your package manager (`apt install python3.11`, + `dnf install python3.11`, etc.) or [pyenv](https://github.com/pyenv/pyenv). + +## 2. Install Hope-Hash + +```bash +git clone https://github.com/devAsmodeus/Hope-Hash.git +cd Hope-Hash +python -m pip install -e . +``` + +The `-e .` installs the project in editable mode — you can hack on the code +and the next run will pick up the changes. + +## 3. Get a mainnet BTC address + +You need a wallet address you control. The miner sends any (extremely +unlikely) reward to that address. Pick one: + +- **Easiest.** Install [Sparrow Wallet](https://sparrow-wallet.com/), + [Electrum](https://electrum.org/), or [BlueWallet](https://bluewallet.io/), + generate a new wallet, copy the receive address. +- **Hardware-backed.** Trezor / Ledger work the same — they expose a + receive address. + +Mainnet only. Hope-Hash refuses testnet / regtest addresses. Acceptable +formats: + +- `1...` (P2PKH) +- `3...` (P2SH) +- `bc1q...` (bech32 / P2WPKH / P2WSH) +- `bc1p...` (bech32m / Taproot) + +## 4. First run + +```bash +hope-hash bc1q5n2x4pvxhq8sxc7ck3uxq8sxc7ck3uxqzfm2py mylaptop +``` + +The first argument is your address; the second is a free-form worker name. +Once the connection is up you should see something like: + +``` +[net] connected to solo.ckpool.org:3333 +[stratum] subscribed: extranonce1=ab12cd34, en2_size=4 +[stratum] authorize sent for worker bc1q....mylaptop +[stratum] new difficulty: 1.0 +[stratum] new job job_id=4f2 clean=true +[stats] hashrate ≈ 87 KH/s | pool diff = 1.0 +[stratum] *** SHARE ACCEPTED *** (id=3) +``` + +`SHARE ACCEPTED` means the pool sees your work. It does **not** mean you +earned anything; that only happens when a hash drops below the network +target (a real block, expectation: ~10¹³ days). The point is to watch the +protocol live. + +Stop with `Ctrl+C`. The supervisor closes the socket cleanly and joins all +threads. + +## 5. Common pitfalls + +- **No internet / firewall.** `solo.ckpool.org:3333` must be reachable. On + corporate networks port 3333 is sometimes blocked — try a phone hotspot + to confirm. +- **Wrong address format.** The validator prints the exact reason: bad + checksum, mixed case, testnet prefix. Fix the address, retry. +- **`pip install -e .` fails on Windows with "Microsoft Visual C++ + required".** Hope-Hash itself has no C extensions; this usually means an + ancient pip. Update with `py -3.11 -m pip install -U pip`. +- **`--tui` looks empty on Windows.** Stock CPython on Windows ships + without `curses`. Either install `windows-curses` separately or skip + `--tui` (the miner works without it). + +## 6. Next + +- [`deploy.en.md`](deploy.en.md) — Docker compose, Prometheus, Grafana, + Telegram, healthchecks for a long-running setup. +- [`architecture.en.md`](architecture.en.md) — protocol, threading model, + hot path, performance notes. +- Read [`getting-started.ru.md`](getting-started.ru.md) if you prefer + Russian. diff --git a/docs/getting-started.ru.md b/docs/getting-started.ru.md new file mode 100644 index 0000000..83b9d3c --- /dev/null +++ b/docs/getting-started.ru.md @@ -0,0 +1,94 @@ +# С чего начать + +Это руководство для тех, кто никогда не запускал майнер биткоина. Поставим +Python, заведём адрес кошелька, запустим майнер один раз и разберём логи. +Никаких предварительных знаний по крипте не требуется. + +## 1. Поставить Python 3.11+ + +Hope-Hash использует только стандартную библиотеку, так что Python — это +всё, что нужно. + +- **Windows.** В Microsoft Store найти «Python 3.11» или скачать + `python-3.11.x-amd64.exe` с python.org. В инсталляторе поставить галку + «Add Python to PATH». Проверить: `py -3.11 --version`. +- **macOS.** `brew install python@3.11`. Проверить: `python3.11 --version`. +- **Linux.** Через пакетный менеджер (`apt install python3.11`, + `dnf install python3.11`) или через [pyenv](https://github.com/pyenv/pyenv). + +## 2. Установить Hope-Hash + +```bash +git clone https://github.com/devAsmodeus/Hope-Hash.git +cd Hope-Hash +python -m pip install -e . +``` + +`-e .` ставит проект в editable-режиме — можно править код, и следующий +запуск подхватит изменения. + +## 3. Завести mainnet BTC-адрес + +Нужен адрес кошелька, который ты контролируешь. Майнер отправит туда +награду, если найдёт блок (шанс примерно нулевой, но формально путь должен +быть). Варианты: + +- **Самый простой.** Поставить [Sparrow Wallet](https://sparrow-wallet.com/), + [Electrum](https://electrum.org/) или [BlueWallet](https://bluewallet.io/), + создать новый кошелёк, скопировать receive-адрес. +- **Аппаратный.** Trezor / Ledger работают так же — отдают receive-адрес. + +Только mainnet. Hope-Hash отказывает testnet- и regtest-адресам. +Допустимые форматы: + +- `1...` (P2PKH) +- `3...` (P2SH) +- `bc1q...` (bech32 / P2WPKH / P2WSH) +- `bc1p...` (bech32m / Taproot) + +## 4. Первый запуск + +```bash +hope-hash bc1q5n2x4pvxhq8sxc7ck3uxq8sxc7ck3uxqzfm2py mylaptop +``` + +Первый аргумент — твой адрес, второй — произвольное имя воркера. Когда +коннект поднимется, увидишь что-то такое: + +``` +[net] подключён к solo.ckpool.org:3333 +[stratum] subscribed: extranonce1=ab12cd34, en2_size=4 +[stratum] authorize отправлен для воркера bc1q....mylaptop +[stratum] новая сложность: 1.0 +[stratum] новая работа job_id=4f2 clean=true +[stats] хешрейт ≈ 87 KH/s | pool diff = 1.0 +[stratum] *** ШАР ПРИНЯТ *** (id=3) +``` + +`ШАР ПРИНЯТ` означает, что пул видит твою работу. Это **не** заработок; +заработок наступит только когда хеш упадёт ниже сетевого target (реальный +блок, ожидание ~10¹³ дней). Смысл — посмотреть протокол вживую. + +Останов — `Ctrl+C`. Supervisor чисто закрывает сокет и джойнит все нити. + +## 5. Типичные проблемы + +- **Нет интернета или фаервол.** `solo.ckpool.org:3333` должен быть + достижим. В корпоративных сетях порт 3333 часто закрыт — проверь через + мобильный hotspot. +- **Неверный формат адреса.** Валидатор печатает конкретную причину: + плохая checksum, смешанный регистр, testnet-префикс. Исправь — повтори. +- **`pip install -e .` падает на Windows с «Microsoft Visual C++ + required».** В Hope-Hash нет C-расширений; обычно это древний pip. + Обнови: `py -3.11 -m pip install -U pip`. +- **`--tui` показывает пустой экран на Windows.** Стандартный CPython на + Windows идёт без `curses`. Либо отдельно поставить `windows-curses`, + либо просто не использовать `--tui` (майнер работает и без него). + +## 6. Дальше + +- [`deploy.ru.md`](deploy.ru.md) — Docker compose, Prometheus, Grafana, + Telegram, healthchecks для долгого запуска. +- [`architecture.ru.md`](architecture.ru.md) — протокол, threading-модель, + hot path, заметки про производительность. +- Если предпочитаешь английский — [`getting-started.en.md`](getting-started.en.md). diff --git a/docs/handoff/final-review.md b/docs/handoff/final-review.md new file mode 100644 index 0000000..8bf6081 --- /dev/null +++ b/docs/handoff/final-review.md @@ -0,0 +1,168 @@ +# Final review — three-PR stack (PRs #6, #7, #8) + +**Date:** 2026-05-02 +**Branch under review:** `feat/web-and-docs` (tip: `021ffa1`) +**Stack:** PR #6 → #7 → #8, all draft, stacked off `main`. +**Tests:** 242 passing, ~17s wall, no flakes. +**Reviewers:** code, security, docs/UX, test coverage (4 parallel agents). + +Detailed reports: +- [`review-code.md`](./review-code.md) +- [`review-security.md`](./review-security.md) +- [`review-docs.md`](./review-docs.md) +- [`review-tests.md`](./review-tests.md) + +--- + +## Verdict + +**Mergeable after one BLOCKER fix and four small SHOULD-FIX items.** +Stack is well-structured, stdlib-only, hot path untouched, observers cleanly +decoupled, bilingual docs are real (not machine-translated), zero HIGH security +findings, 242 tests passing. + +The one blocking bug is in `solo.py` prev-hash byte-order — masked by the +all-zero `FAKE_TEMPLATE` prevhash in `test_solo.py`, so against a real +`bitcoind` solo mode would silently mine invalid blocks. + +## Blocker (fix before merge) + +### B1 — `solo.py:514-521` prev-hash word-swap is a no-op + +`_template_to_job` builds `prev_stratum_hex` such that `swap_words` in +`_build_header_base` cancels out, leaving the mined header with `prev_be` +display order instead of internal little-endian `prev_be[::-1]`. Submit-time +serializer in `_assemble_header` independently does the right reversal, so +hashing-time and submit-time headers disagree. + +**Hidden by:** `FAKE_TEMPLATE.previousblockhash = "0" * 64` (fixed point under +any byte permutation). No real-bitcoind integration test catches it. + +**Fix sketch** (from code-reviewer): +```python +internal = bytes.fromhex(tmpl["previousblockhash"])[::-1] +prev_stratum_hex = b"".join(internal[i:i+4][::-1] + for i in range(0, 32, 4)).hex() +``` + +**Test gap to close together:** add a `test_solo.py` case with a real +non-symmetric mainnet block hash and assert `_build_header_base` produces +exactly `prev_display[::-1]`. + +## Should-fix (recommend before merge, all small) + +### S1 — `docker-compose.yml:10` comment misnames web-dashboard port +Comment points at `:8000` (which is `/metrics`+`/healthz`); web dashboard is +on `:8001`. First-run users hit the Prometheus exposition page. (docs review) + +### S2 — `docker run` example in `deploy.{en,ru}.md` §6 has confusing positional +The literal `docker` is the worker_name positional, but reads as a subcommand. +Rename to `mybox` and add a comment. (docs review) + +### S3 — `CHANGELOG.md` link footer stale +v0.3.0–v0.7.0 are unlinked; `[Unreleased]` still compares against v0.2.0. +Markdown silently broken. (docs review) + +### S4 — README advanced-flags table omits `--log-file` +Flag is real (`cli.py:108`), used in TUI workflow. Missing in EN and RU +halves. (docs review) + +## Security MEDIUMs (not blocking the documented use-case, fix before any non-localhost deploy) + +### M-1 — `/api/events` SSE has no concurrent-connection cap +Per-connection `queue.Queue(maxsize=256)` + thread; `ThreadingHTTPServer` +spawns one thread per request; loopback default mitigates, but +`docker-compose.yml` flips bind to `0.0.0.0`. **Fix:** `max_subscribers` cap ++ HTTP 503 on overflow. + +### M-2 — `BitcoinRPC` cookie file read is unbounded and unguarded +`Path(cookie_path).read_text()` with no size cap and no `try/except`. +Footgun: `--rpc-cookie /tmp` OOMs the miner. **Fix:** stat → size limit 4 KiB +→ `OSError` → friendly CLI error. + +### M-3 — ctypes loads `libcrypto-3.dll` by bare name on Windows +DLL search order includes app dir → writable cwd hijack possible. **Fix:** +`ctypes.WinDLL(..., winmode=LOAD_LIBRARY_SEARCH_SYSTEM32)` or +`os.add_dll_directory()` with a known-good path. + +### M-4 — Docker compose binds to `0.0.0.0` + ships Grafana `admin/admin` +`ports: "8001:8001"` and `"3000:3000"` publish broadly; Docker bypasses +host firewall via `iptables`. **Fix:** prefix with `127.0.0.1:`; make +`GRAFANA_PASSWORD` mandatory via `${GRAFANA_PASSWORD:?...}` idiom. + +## Test gaps (no blockers, but high-value to add next iteration) + +1. **Mid-state ↔ ctypes parity sentinel** — guards against silent endianness + regression in `_worker_ctypes` vs `_worker_hashlib_midstate`. Hardware- + independent, cheap. +2. **`SoloClient.reader_loop` RPC failure path** — uncovered. +3. **`SoloClient` without `default_witness_commitment`** (regtest/non-segwit + templates). +4. **`cli._build_pool_list` and `_resolve_sha_backend`** — pure helpers, zero + tests today. Add `tests/test_cli_helpers.py`. +5. **`webui._serve_events` cleanup on subscriber drop** — verify + `_subscribers` list returns to baseline length after disconnect. +6. **`StatsProvider.publish_event` under concurrent subscribe/unsubscribe** — + intentional design (snapshot-then-call) deserves a smoke test. + +## Non-blocking code concerns + +- `cli.py:460-466` — `stats_provider.update_hashrate` monkey-patch. Lift + `last_hashrate_ts` into `StatsSnapshot` and drop the wrapper. Already flagged + in PR-A handoff as an open question. +- `notifier.py:307` — dead boolean clause; intent is probably + `if item is None or self._stop_event.is_set():`. +- `solo.py:489-499` — `deadbeef` extranonce marker has 2⁻³² collision risk. + Use 16-byte `os.urandom`. +- `solo.py:407-452` — `submit()` runs synchronously in mine thread; OK for + learning code, document in `architecture.{en,ru}.md`. +- `webui.py:331-336` — `repr(exc)` leaks into healthz HTTP body; loopback + default mitigates. +- `parallel.py:275-276` — pre-existing bare `except Exception: pass` on queue + cleanup. Out of stack scope. + +## Docs nice-to-haves + +- N1: Validate `--solo` argument tuple before BTC address validation. +- N2: argparse help text is Russian-only; bilingual everywhere else. +- N3: `architecture.{en,ru}.md` doesn't note that telegram inbound requires + `HOPE_HASH_TELEGRAM_INBOUND=1` opt-in. +- N6/N7: `deploy.{en,ru}.md` §3 incorrectly says "503 otherwise" — actual + is 200 for `degraded`, 503 only for `down`. +- N10: `Dockerfile` `EXPOSE` lists 8000+9090 but compose uses 8000+8001. +- N11: `architecture.{en,ru}.md` file-map omits `_logging.py`/`__main__.py`. + +## Praise (worth keeping in mind for future agents) + +- **`StatsProvider` as canonical bus** — clean decoupling of mine() from TUI, + web, healthz, Prometheus. Pub/sub added in PR C without touching call sites. +- **`PoolList`** — `mark_failed` returns rotation flag, `full_cycle_failed` + distinguishes flap from outage, `RLock` prevents the obvious deadlock and + there's a test pinning the invariant. +- **ctypes loader hygiene** — `c_void_p` restypes (catches 64-bit truncation), + `EVP_sha256` symbol probe (catches wrong DLL), `try/finally` around + `EVP_MD_CTX_free` (no leak/UAF). +- **`render_html` is fully static** — no user input echoed in, no CDN, no + external scripts. XSS-safe by construction. +- **`test_notifier_timing.py`** — exemplary regression sentinel for the + submit-vs-ack semantic that already bit the project once. +- **`test_solo.py` (38 tests)** — well-spent on the most error-prone module; + byte-level assertions on `_varint`, `_push_data`, `_serialize_height`, + witness commitment parse/compute. +- **Bilingual docs** — EN/RU mirror line-for-line; Russian reads as + written-by-a-human, technical terms stay English by convention. +- **Defaults are conservative** — `--web-host 127.0.0.1`, `--web-port 0`, + `HOPE_HASH_TELEGRAM_INBOUND=0` — exactly the right posture. + +## Recommended merge order + +1. Land **B1** fix + the matching prev-hash test against PR #7 branch. +2. (Optional now / can defer) land **S1–S4** doc/comment fixes against PR #8 + branch. +3. (Defer to a follow-up PR) **M-1–M-4** security MEDIUMs and the six + test-coverage additions. +4. Merge PR #6 → main, then rebase #7 onto main + merge, then rebase #8 onto + main + merge. + +Total fix budget for blocking + should-fix: ~30 minutes of engineering work +plus tests. The four MEDIUMs and the test gaps are a clean follow-up PR. diff --git a/docs/handoff/pr-c-summary.md b/docs/handoff/pr-c-summary.md new file mode 100644 index 0000000..736924f --- /dev/null +++ b/docs/handoff/pr-c-summary.md @@ -0,0 +1,182 @@ +# PR C summary — `feat/web-and-docs` (v0.7.0) + +Web-дашборд (stdlib `http.server` + SSE), Docker-стек, провижининг +Prometheus/Grafana, двуязычная документация, README rewrite. Pure stdlib, +ноль новых рантайм-зависимостей. Тесты: **225 → 242** (+17). + +## File map + +### Added + +| Path | Purpose | +| --- | --- | +| `src/hope_hash/webui.py` | `WebUIServer` (stdlib `http.server`), `render_html()`, обработчики `/`, `/api/stats`, `/api/events` (SSE), `/healthz`. Daemon-нить, идемпотентные start/stop. | +| `tests/test_webui.py` | 17 тестов: pub/sub `StatsProvider`, render_html, HTML/JSON-эндпоинты, SSE-стрим с реальным сокетом, healthz fallback, lifecycle. | +| `Dockerfile` | `python:3.11-slim`, `pip install -e .` самого проекта, healthcheck через stdlib `urllib`, `ENTRYPOINT ["hope-hash"]`. | +| `docker-compose.yml` | Три сервиса (`miner` + `prometheus` + `grafana`) с volumes (`./data`, named `prom-data`/`grafana-data`), env vars документированы в комментариях. | +| `.dockerignore` | Исключает `.git`, `tests`, `docs`, `*.db`, `__pycache__`, IDE-файлы, чтобы build-context был минимальным. | +| `deploy/prometheus/prometheus.yml` | Минимальный scrape config: `miner:8000/metrics` каждые 15с. | +| `deploy/grafana/datasource.yml` | Provisioning Prometheus-датасорса (`url: http://prometheus:9090`). | +| `deploy/grafana/dashboard.yml` | Provisioning дашбордов из `/var/lib/grafana/dashboards` → подхватывает `hope-hash.json` из PR A. | +| `docs/getting-started.en.md` | Первый запуск для пользователя без bitcoin-опыта (Python install → BTC адрес → `hope-hash` → чтение логов → типичные пробемы). | +| `docs/getting-started.ru.md` | То же, по-русски. Не machine-translated, переписано естественно. | +| `docs/deploy.en.md` | Compose walkthrough, Telegram setup, healthcheck, reverse-proxy snippet. | +| `docs/deploy.ru.md` | Русская версия, тот же набор разделов. | +| `docs/architecture.en.md` | Протокол, threading-таблица, file map, hot path, observers, ctypes trade-off, solo caveats, BIP-ссылки. | +| `docs/architecture.ru.md` | Русская версия, mirror. | +| `docs/handoff/pr-c-summary.md` | Этот файл. | + +### Modified + +| Path | Why | +| --- | --- | +| `src/hope_hash/tui.py` | `StatsProvider` теперь принимает `sha_backend`, имеет `subscribe()/publish_event()/_publish()`, `update_job/record_share/update_pool` пушат события. Добавлен property `sha_backend` и `set_sha_backend()`. | +| `src/hope_hash/cli.py` | Новые флаги `--web-port` и `--web-host`. `_health_provider` вынесен наружу из `if metrics_server` — теперь тот же health-провайдер шарится между metrics и webui. WebUIServer стартует и стопится в lifecycle. `StatsProvider` инициализируется с `sha_backend`. | +| `src/hope_hash/__init__.py` | Bump `__version__` → `0.7.0`. Re-export `WebUIServer`, `render_html`. | +| `README.md` | Полный rewrite: EN-half сверху, RU-half снизу, оба зеркалят одинаковые разделы. Cross-link на `docs/`. | +| `CHANGELOG.md` | Секция `[0.7.0]` с детальным списком добавлений и изменений. | +| `ROADMAP.md` | Тикнуты web-морда (через stdlib, не FastAPI) и Docker. Добавлен раздел «Сознательно отложено» (Stratum V2, Rust/PyO3, GPU, FastAPI, Helm). | + +## New CLI flags + +| Flag | Default | Notes | +| --- | --- | --- | +| `--web-port PORT` | `0` (off) | Web-дашборд (HTML + `/api/stats` + SSE `/api/events` + `/healthz`). | +| `--web-host HOST` | `127.0.0.1` | Bind-хост. По умолчанию loopback — наружу только за reverse-proxy. | + +## New endpoints + +| Endpoint | Content | Notes | +| --- | --- | --- | +| `GET /` | text/html | Single-page dashboard, vanilla JS, inline-SVG sparkline, polls `/api/stats` каждые 2с. | +| `GET /api/stats` | application/json | Snapshot: hashrate, pool, sha_backend, current_job_id, shares_*, uptime. `Cache-Control: no-store`. | +| `GET /api/events` | text/event-stream | SSE: `share_found`, `share_accepted`, `share_rejected`, `job`, `pool`. Keep-alive каждые 15с. | +| `GET /healthz` | application/json | Те же тело и коды, что у `MetricsServer.set_health_provider`. | + +## New env vars + +Никаких новых обязательных env vars. Compose читает существующие +(`HOPE_HASH_TELEGRAM_TOKEN/CHAT_ID/INBOUND`, `BTC_ADDRESS`, `WORKERS`, +`GRAFANA_USER/PASSWORD`). + +## Architecture additions + +- `StatsProvider.subscribe(callback) -> unsubscribe()` — pub/sub шина для + SSE. Колбэк вызывается синхронно из публикующей нити, поэтому + обработчики обязаны быть моментальными (в webui это `queue.put_nowait`). + Сломанный подписчик не валит publish: исключения ловятся и логируются. +- `StatsProvider.publish_event(event_type, payload)` — публичный alias + на `_publish` для прямого вызова из miner кода (на будущее). +- `update_job` публикует `job`-event только при реальной смене job_id — + это защищает SSE от шторма событий на каждом пересчёте хешрейта. +- `WebUIServer` зеркалит API `MetricsServer`: `start()`, `stop()`, + `set_health_provider()`. Health-провайдер — однослотовый mutable + container (тот же приём, что в `metrics._make_handler`). + +## Gotchas + +1. **SSE нужен HTTP/1.1.** В webui handler выставляет + `protocol_version = "HTTP/1.1"`. Если кто-то унаследует handler и + откатит на 1.0 — chunked transfer перестанет работать. +2. **`X-Accel-Buffering: no`** обязателен для nginx, иначе SSE копится + в буфере ответа. Уже выставлен. +3. **Порт 8000 vs 9090.** В compose-стеке metrics+healthz на 8000, + webui на 8001 — то, что Prometheus и Grafana ожидают увидеть. + Healthcheck Dockerfile тоже бьёт в 8000. Если меняешь — синхронизируй. +4. **Web-host default `127.0.0.1`.** В compose явно ставим + `--web-host 0.0.0.0`, иначе порт-маппинг не достанется до контейнера. +5. **Healthcheck без `curl`.** Slim-образ `python:3.11-slim` не имеет + `curl`, и ставить его ради healthcheck — лишние ~10MB. Используем + stdlib `urllib`. Команда написана как `python -c "..."`, переносов + строк не имеет. +6. **`StatsProvider` обратной совместимости.** Старый аргумент + `pool_url` остался; новый `sha_backend` — keyword. Все ныне + существующие вызовы `StatsProvider(pool_url=...)` работают без + изменений. +7. **Подписчики держатся вечно.** Если веб-клиент отвалился, мы + снимаем его подписку в `finally`. Но если callback пользователя + удерживает ссылку — leak. В нашем коде только webui подписывается, + и он всегда `unsubscribe()` в `finally`. + +## Open questions for future work + +- **POST /restart / POST /stop через web.** Telegram уже умеет; для web + потребуется минимум CSRF-токен + Basic-auth. Не делаем без явной + просьбы. +- **WebSocket вместо SSE.** SSE простой, односторонний, чисто работает + через прокси. WebSocket даст двунаправленность, но потребует stdlib + `wsproto`-equivalent (нет встроенного). Не делаем. +- **Authz для `/api/stats`.** Снапшот не содержит секретов (адрес виден + в логах compose, токены — в env), но при выставлении наружу + reverse-proxy с Basic-auth обязателен. Документация в `deploy.{en,ru}.md`. +- **CSP-заголовок для `/`.** HTML inline JS — нужно `script-src + 'self' 'unsafe-inline'`. Сейчас не выставляется. Для интранет-деплоя + не критично; для публичного — добавить. +- **Метрика `webui_active_streams`.** Чтобы видеть, сколько + SSE-клиентов подключено. Сейчас не считается. + +## Verification + +```bash +py -3.11 -m unittest discover -s tests -v +# Ran 242 tests in ~19s — OK + +py -3.11 -m hope_hash --help +py -3.11 -m hope_hash --benchmark --bench-duration 1 --workers 1 +py -3.11 -m hope_hash --demo --workers 2 + +# Smoke-тест web-дашборда (требует валидный BTC-адрес и Internet к solo.ckpool.org): +# py -3.11 -m hope_hash bc1q...your_address... mylaptop --web-port 8001 & +# curl -s http://127.0.0.1:8001/api/stats | jq . +# curl -N http://127.0.0.1:8001/api/events # SSE +# open http://127.0.0.1:8001/ # HTML + +# Docker (опционально): +# docker build -t hope-hash:0.7.0 . +# BTC_ADDRESS=bc1q... docker compose up -d +# open http://localhost:8001 +``` + +No `pip install` of third-party deps. No changes to `block.py` endianness +или `parallel._worker_hashlib_midstate` hot path. Существующие 225 тестов +зелёные, новые 17 покрывают webui. + +## Review checklist (для review-панели) + +- [ ] `webui.py` — нет ли утечек `subscribe()` без `unsubscribe()` (только в + `_serve_events`, защищено `try/finally`). +- [ ] `webui.py` — `_serve_events` обрабатывает `BrokenPipeError`, + `ConnectionResetError`, `OSError` при `wfile.write`/`flush`. +- [ ] `webui.py` — `_SSE_QUEUE_MAX` (256) и keepalive 15с — разумные + defaults; флуд событий не валит mine-thread (`put_nowait` → + drop с warning, не блок). +- [ ] `webui.py` HTML — нет ` + + +""" + + +# ─────────────────── HTTP handler ─────────────────── + + +def _make_handler( + provider: StatsProvider, + health_provider_ref: list[Optional[HealthProvider]], +) -> type[BaseHTTPRequestHandler]: + """Фабрика handler-класса с замыканиями на provider/health. + + Тот же приём, что в ``metrics._make_handler``: всё через замыкания, + health-провайдер — однослотовый mutable container, чтобы можно было + подменить после ``start()``. + """ + + class _Handler(BaseHTTPRequestHandler): + # Server-Sent Events требует HTTP/1.1 для chunked transfer. + protocol_version = "HTTP/1.1" + + def do_GET(self) -> None: # noqa: N802 — имя задано базовым классом + if self.path == "/" or self.path == "/index.html": + self._serve_html() + return + if self.path == "/api/stats": + self._serve_stats() + return + if self.path == "/api/events": + self._serve_events() + return + if self.path == "/healthz": + self._serve_healthz(health_provider_ref[0]) + return + self.send_error(404) + + # ─── individual endpoints ─── + + def _serve_html(self) -> None: + body = _HTML_PAGE.encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + def _serve_stats(self) -> None: + payload = _stats_payload(provider) + body = json.dumps(payload).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + def _serve_healthz(self, hp: Optional[HealthProvider]) -> None: + if hp is None: + payload: dict[str, Any] = { + "status": "down", + "reason": "no health provider registered", + } + http_status = 503 + else: + try: + payload = hp() or {} + except Exception as exc: # noqa: BLE001 — пользовательский callable + payload = {"status": "down", "reason": f"provider error: {exc}"} + http_status = 503 + else: + status = payload.get("status", "down") + http_status = 503 if status == "down" else 200 + body = json.dumps(payload).encode("utf-8") + self.send_response(http_status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _serve_events(self) -> None: + """SSE-стрим: подписываемся на provider, гоняем события в сокет. + + Завершается, когда: + - клиент закрыл коннект (BrokenPipeError/ConnectionResetError); + - сервер останавливается (provider.stop_event эквивалент: + мы держим короткие таймауты и проверяем connection alive). + """ + self.send_response(200) + self.send_header("Content-Type", "text/event-stream; charset=utf-8") + self.send_header("Cache-Control", "no-store") + # Полностью отключаем nginx/прокси-буферизацию, иначе события + # копятся пока не наберётся 4кб и UI «зависает». + self.send_header("X-Accel-Buffering", "no") + self.send_header("Connection", "keep-alive") + self.end_headers() + + ev_queue: queue.Queue[tuple[str, dict[str, Any]]] = queue.Queue( + maxsize=_SSE_QUEUE_MAX + ) + + def _on_event(event_type: str, payload: dict[str, Any]) -> None: + # Никогда не блокируем producer'а: если очередь полная, + # дропаем событие и логируем (лучше потерять одно + # событие, чем повесить mine-thread). + try: + ev_queue.put_nowait((event_type, payload)) + except queue.Full: + logger.warning("[webui] SSE queue full, dropping event %s", event_type) + + unsubscribe = provider.subscribe(_on_event) + try: + last_keepalive = time.time() + while True: + try: + event_type, payload = ev_queue.get(timeout=1.0) + except queue.Empty: + # Нет событий — возможно, время для keepalive. + if time.time() - last_keepalive >= _SSE_KEEPALIVE_S: + try: + self.wfile.write(b": keepalive\n\n") + self.wfile.flush() + except (BrokenPipeError, ConnectionResetError, OSError): + break + last_keepalive = time.time() + continue + try: + data = json.dumps(payload, default=str) + msg = f"event: {event_type}\ndata: {data}\n\n".encode("utf-8") + self.wfile.write(msg) + self.wfile.flush() + last_keepalive = time.time() + except (BrokenPipeError, ConnectionResetError, OSError): + # Клиент ушёл — выходим без шумного traceback. + break + finally: + unsubscribe() + + # ─── housekeeping ─── + + def log_message(self, format: str, *args: object) -> None: # noqa: A002 + # Тот же приём, что в metrics: единый канал — logger "hope_hash", + # засорять stderr каждым GET / не нужно. + return + + return _Handler + + +# ─────────────────── server lifecycle ─────────────────── + + +class WebUIServer: + """HTTP-дашборд на отдельной нити. Старт/стоп идемпотентны. + + Использование:: + + provider = StatsProvider(...) + server = WebUIServer(provider, port=8080) + server.start() + # ... майнер работает ... + server.stop() + """ + + def __init__( + self, + provider: StatsProvider, + host: str = "127.0.0.1", + port: int = 8080, + ) -> None: + self.provider = provider + self.host = host + self.port = int(port) + self._server: ThreadingHTTPServer | None = None + self._thread: threading.Thread | None = None + self._lifecycle_lock = threading.Lock() + self._health_ref: list[Optional[HealthProvider]] = [None] + + def set_health_provider(self, hp: Optional[HealthProvider]) -> None: + """Регистрирует health-провайдера (тот же контракт, что в ``MetricsServer``).""" + self._health_ref[0] = hp + + def start(self) -> None: + with self._lifecycle_lock: + if self._server is not None: + return + handler_cls = _make_handler(self.provider, self._health_ref) + self._server = ThreadingHTTPServer((self.host, self.port), handler_cls) + self._thread = threading.Thread( + target=self._server.serve_forever, + name=f"hope_hash-webui-{self.port}", + daemon=True, + ) + self._thread.start() + logger.info("[webui] дашборд на %s", self.url) + + def stop(self, timeout: float = 2.0) -> None: + with self._lifecycle_lock: + server = self._server + thread = self._thread + self._server = None + self._thread = None + if server is not None: + server.shutdown() + server.server_close() + if thread is not None: + thread.join(timeout=timeout) + if server is not None: + logger.info("[webui] дашборд остановлен") + + @property + def url(self) -> str: + return f"http://{self.host}:{self.port}/" + + +# Безопасный alias на случай, если кто-то ожидает функцию-фабрику HTML +# (например, для предзаполнения страницы в шаблоне). HTML — статика, но +# делаем доступным как функцию для тестов и переиспользования. +def render_html() -> str: + """Возвращает HTML-страницу дашборда (статика, без подстановок).""" + return _HTML_PAGE + + +# Экранируем на всякий случай, если когда-нибудь добавим динамическую +# подстановку. Сейчас не используется, но импортируется в тестах. +def _escape(value: Any) -> str: + return html.escape(str(value), quote=True) diff --git a/tests/test_webui.py b/tests/test_webui.py new file mode 100644 index 0000000..f447b52 --- /dev/null +++ b/tests/test_webui.py @@ -0,0 +1,272 @@ +"""Тесты web-дашборда: HTML, /api/stats, /api/events, /healthz, lifecycle.""" + +from __future__ import annotations + +import http.client +import json +import socket +import threading +import time +import unittest +from typing import Any + +from hope_hash.tui import StatsProvider +from hope_hash.webui import WebUIServer, render_html + + +def _free_port() -> int: + """Биндим эфемерный порт, освобождаем — отдаём номер. + + Между release и bind есть гонка, но для unittest на одной машине это + приемлемо. Если CI станет флаки — добавим retry-обёртку. + """ + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def _http_get(host: str, port: int, path: str, timeout: float = 3.0): + conn = http.client.HTTPConnection(host, port, timeout=timeout) + conn.request("GET", path) + resp = conn.getresponse() + body = resp.read() + headers = dict(resp.getheaders()) + status = resp.status + conn.close() + return status, headers, body + + +class TestStatsProviderEvents(unittest.TestCase): + """publish/subscribe — отдельно от HTTP, без сети.""" + + def test_subscribe_and_publish(self): + provider = StatsProvider() + captured: list[tuple[str, dict[str, Any]]] = [] + unsub = provider.subscribe(lambda t, p: captured.append((t, p))) + try: + provider.record_share(accepted=None) # share_found + provider.record_share(accepted=True) # share_accepted + provider.record_share(accepted=False) # share_rejected + provider.update_job("abc123", 4.0) # job + provider.update_pool("pool.example:3333") # pool + finally: + unsub() + types = [t for t, _ in captured] + self.assertIn("share_found", types) + self.assertIn("share_accepted", types) + self.assertIn("share_rejected", types) + self.assertIn("job", types) + self.assertIn("pool", types) + + def test_unsubscribe_idempotent(self): + provider = StatsProvider() + unsub = provider.subscribe(lambda t, p: None) + unsub() + unsub() # второй вызов не должен падать + + def test_subscriber_exception_does_not_break_publish(self): + provider = StatsProvider() + ok_calls: list[str] = [] + + def bad(t: str, p: dict) -> None: + raise RuntimeError("boom") + + def good(t: str, p: dict) -> None: + ok_calls.append(t) + + provider.subscribe(bad) + provider.subscribe(good) + provider.publish_event("x", {"k": 1}) + self.assertEqual(ok_calls, ["x"]) + + def test_job_not_published_when_unchanged(self): + provider = StatsProvider() + events: list[str] = [] + provider.subscribe(lambda t, p: events.append(t)) + provider.update_job("same", 1.0) + provider.update_job("same", 1.0) + provider.update_job("same", 1.0) + # Только один job-event на реальную смену. + self.assertEqual(events.count("job"), 1) + + def test_sha_backend_default_and_setter(self): + provider = StatsProvider() + self.assertEqual(provider.sha_backend, "hashlib") + provider.set_sha_backend("ctypes") + self.assertEqual(provider.sha_backend, "ctypes") + + +class TestRenderHtml(unittest.TestCase): + def test_html_contains_expected_strings(self): + html_text = render_html() + self.assertIn("hope-hash", html_text) + self.assertIn("hashrate", html_text) + self.assertIn("/api/stats", html_text) + self.assertIn("/api/events", html_text) + # Ни одной CDN-ссылки и ни одного