diff --git a/CHANGELOG.md b/CHANGELOG.md index 1394b5b..dfd9547 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,54 @@ ## [Unreleased] +## [0.6.0] — 2026-05-02 + +### Добавлено +- **Multi-pool failover** (`pools.py`): `--pool host:port` повторяемый. + При N (default 3) подряд провалах на одном пуле supervisor ротирует + на следующий. После полного круга без успехов применяется обычный + exponential backoff. `PoolList.full_cycle_failed()` отдаёт сигнал. + `StratumClient.set_endpoint(host, port)` — без пересоздания клиента, + локи и `on_share_result` сохраняются. +- **Solo-режим через `getblocktemplate`** (`solo.py`): `hope-hash --solo + --rpc-url URL --rpc-cookie PATH` (или `--rpc-user/--rpc-pass`). + `SoloClient` имитирует поверхность `StratumClient`, чтобы `mine()` + работал без изменений. Polling `getblocktemplate` каждые + `--solo-poll-sec` (5с по умолчанию). На find — собирает coinbase + (BIP-34 height + extranonce), считает witness commitment (BIP-141) + если `default_witness_commitment` присутствует, сериализует полный + блок и шлёт `submitblock`. JSON-RPC через stdlib `urllib`. +- **ctypes SHA-256 backend** (`sha_native.py`): `--sha-backend + {auto,hashlib,ctypes}` (default `auto`). Загружает `libcrypto-3.dll` + / `libcrypto.so.3` / `/usr/lib/libcrypto.dylib` через `ctypes.CDLL`, + вызывает EVP API. Без mid-state — для честного бенчмарка. Если + libcrypto не нашёлся, тихо fallback на `hashlib`. +- **`--benchmark --backends`** (`bench.py`): прогоняет один и тот же + бенч по всем доступным backend'ам и печатает сравнение. Финальная + строка вида `[bench] result: ctypes 1.42 MH/s (1.85x vs hashlib-midstate)`. +- **Тесты**: `test_pools.py` (24), `test_sha_native.py` (12), + `test_solo.py` (35, включая `FakeRPC`), `test_bench_backends.py` (9). + Итого 145 → 225 (+80). + +### Изменено +- `supervisor_loop()` принимает опциональные `pools: PoolList` и + `stats_provider: StatsProvider`. Без них поведение прежнее. +- `mine()` принимает `sha_backend: str` (default `"hashlib"`). +- `start_pool()` пробрасывает `sha_backend` в каждый воркер. +- `worker()` (parallel.py) рефакторен: `_worker_hashlib_midstate()` + (hot path, без изменений) и `_worker_ctypes()` (sha256d на каждой + итерации без mid-state). +- `StatsProvider.update_pool(url)` — для отображения активного пула + в TUI после ротации. +- `__version__` → `0.6.0`. + +### Производительность +- Hot path (mid-state hashlib) не тронут — бенчмарк на 4-х воркерах + показал 3.18 MH/s (тот же порядок, что v0.5.0). +- ctypes-backend без mid-state ожидаемо медленнее (~0.3x от hashlib + mid-state). Это сознательная плата за честный замер «голого» + Python→C overhead. + ## [0.5.0] — 2026-05-02 ### Добавлено diff --git a/README.md b/README.md index 1e5e272..e496b16 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,19 @@ --- +## Что нового в 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-хешрейт, шары, diff --git a/ROADMAP.md b/ROADMAP.md index 52836fb..496b6f9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -59,13 +59,14 @@ TUI и команды Telegram — отложены. ### Производительность -- [ ] **C-extension для SHA-256.** Через `cffi` или `ctypes` дёргать `EVP_DigestUpdate` из OpenSSL — даст 5–10× к хешрейту над pure-Python `hashlib`. +- [x] **ctypes-обёртка над libcrypto SHA-256.** `sha_native.py` (v0.6.0): грузит `libcrypto-3.dll` / `libcrypto.so.3` / `/usr/lib/libcrypto.dylib` через `ctypes.CDLL`, EVP API. CLI `--sha-backend {auto,hashlib,ctypes}`. Без mid-state — для бенчмарка-сравнения. Hot path майнинга остался на hashlib mid-state, как самый быстрый вариант на pure-stdlib. +- [ ] **SIMD/C-extension для SHA-256.** Дальнейшее ускорение требует SIMD (AVX2: 8 хешей параллельно). Это уже C-расширение, не stdlib — Уровень 3. - [ ] **SIMD-реализация SHA-256.** AVX2 (8 хешей параллельно) или AVX-512 (16). Можно взять готовое из репо `intel-ipsec-mb` или `sha-2-multihash`. Пишется как C-extension, дёргается из Python. - [x] **Mid-state кэширование.** `hashlib.sha256().copy()` после первых 64 байт — константа в рамках nonce-цикла. Реализовано в `parallel.worker` (v0.3.0). Прирост ≈×1.5–2, zero deps. ### Архитектура -- [ ] **Множественные пулы с failover.** Список `pool1, pool2, pool3` в конфиге, при потере pool1 — переключение на pool2 без остановки воркеров. +- [x] **Множественные пулы с failover.** `--pool host:port` повторяемый (v0.6.0). После N (default 3) подряд провалов на одном пуле supervisor ротирует на следующий, после полного круга применяется exponential backoff. `pools.PoolList` + `StratumClient.set_endpoint()`. - [ ] **Несколько воркеров на разных пулах одновременно.** Распределённая работа с разными адресами/именами. - [x] **Поддержка vardiff.** `mining.suggest_difficulty` после авторизации. CLI-флаг `--suggest-diff FLOAT`. Реализовано в `stratum.py` (v0.3.0). @@ -91,7 +92,7 @@ TUI и команды Telegram — отложены. ### Протоколы - [ ] **Stratum V2.** Современный бинарный протокол с шифрованием (Noise) и job negotiation. У `solo.ckpool.org` его пока нет, но есть на других пулах. Хорошая возможность разобраться в современном крипто-протоколе. -- [ ] **Прямое подключение к bitcoin-core.** Вообще без пула: запрашивать `getblocktemplate` через RPC у локальной ноды, собирать блок самостоятельно, при удаче — `submitblock`. Это и есть «настоящий соло-майнинг» без посредников. +- [x] **Прямое подключение к bitcoin-core.** `solo.py` (v0.6.0): `--solo --rpc-url URL --rpc-cookie PATH`. Polling `getblocktemplate` каждые `--solo-poll-sec`, сборка coinbase (BIP-34 + BIP-141 witness commitment), `submitblock` через JSON-RPC. Учебное качество, шанс найти блок ≈ 0; цель — научить, как `getblocktemplate` устроен. ### Мониторинг и SRE diff --git a/docs/handoff/pr-b-summary.md b/docs/handoff/pr-b-summary.md new file mode 100644 index 0000000..b3c6ffb --- /dev/null +++ b/docs/handoff/pr-b-summary.md @@ -0,0 +1,134 @@ +# PR B summary — `feat/perf-and-resilience` (v0.6.0) + +Multi-pool failover + solo `getblocktemplate` + ctypes SHA-256 backend ++ bench `--backends`. Pure stdlib (`ctypes` + `urllib`). Tests: +**145 → 225** (+80). + +## File map + +### Added + +| Path | Purpose | +| --- | --- | +| `src/hope_hash/pools.py` | `PoolList` (round-robin failover, дедуп, `mark_failed/success/rotate/full_cycle_failed`), `parse_pool_spec()`. | +| `src/hope_hash/sha_native.py` | ctypes-обёртка над libcrypto EVP API (`sha256`, `sha256d`, `is_available`, `BACKEND_NAME`). Загружает `libcrypto-3.dll` / `libcrypto.so.3` / `/opt/homebrew/lib/libcrypto.dylib` и т.п. Fallback на `hashlib` при отсутствии. | +| `src/hope_hash/solo.py` | `SoloClient` (имитация `StratumClient` поверх JSON-RPC), `BitcoinRPC`, `build_coinbase`, `compute_witness_commitment`, `parse_default_witness_commitment`, `serialize_block`, `compute_merkle_root_from_txids`, `_varint`/`_push_data`/`_serialize_height`. | +| `tests/test_pools.py` | 24 теста: парсер spec, дедуп, ротация на пороге, wrap-around, single-pool no-op, `full_cycle_failed`/`reset_round`, нет deadlock'ов. | +| `tests/test_sha_native.py` | 12 тестов: `is_available()` tolerant, паритет `sha256`/`sha256d` с `hashlib` на пустых/коротких/длинных/binary векторах, паритет на 80-байтном block header. | +| `tests/test_solo.py` | 35 тестов: `_varint`, `_push_data`, `_serialize_height`, `build_coinbase` (с/без extranonce, с/без witness commitment), `compute_witness_commitment`, `parse_default_witness_commitment`, `compute_merkle_root_from_txids`, `serialize_block`, `BitcoinRPC` auth, `SoloClient` через `FakeRPC` (connect/job/submit success+reject). | +| `tests/test_bench_backends.py` | 9 тестов: `available_backends()` всегда содержит hashlib первым, `ctypes` only-if-available, `run_benchmark_all_backends()` smoke. | +| `docs/handoff/pr-b-summary.md` | This file. | + +### Modified + +| Path | Why | +| --- | --- | +| `src/hope_hash/cli.py` | Новые флаги: `--pool`, `--rotate-after-failures`, `--sha-backend`, `--backends`, `--solo`, `--rpc-url/--rpc-cookie/--rpc-user/--rpc-pass`, `--solo-poll-sec`. Хелперы `_resolve_sha_backend()`, `_build_pool_list()`. Solo-ветка собирает `SoloClient`+`BitcoinRPC`. Pool-ветка строит `PoolList` и передаёт в supervisor через kwargs. | +| `src/hope_hash/miner.py` | `supervisor_loop()` теперь принимает `pools: Optional[PoolList]` и `stats_provider: Optional[StatsProvider]`. На неудавшемся коннекте — `mark_failed()`, после порога — ротация + `set_endpoint()` без пересоздания клиента. После `full_cycle_failed()` — обычный exponential backoff. `mine()` принимает `sha_backend` и пробрасывает в `start_pool()`. | +| `src/hope_hash/parallel.py` | `worker()` теперь диспатчит на `_worker_hashlib_midstate()` (без изменений в hot path) или `_worker_ctypes()` (sha256d через libcrypto, без mid-state). `start_pool()` принимает `sha_backend`. | +| `src/hope_hash/stratum.py` | `set_endpoint(host, port)` — перенацеливание клиента без пересоздания, локи/`on_share_result`/`username` сохраняются. | +| `src/hope_hash/bench.py` | `run_benchmark()` принимает `sha_backend` и `print_header`. Новый `run_benchmark_all_backends()` + `available_backends()`. Финальная строка `[bench] result: ctypes X MH/s (Yx vs hashlib-midstate)`. | +| `src/hope_hash/tui.py` | `StatsProvider.update_pool(url)` — для multi-pool отображения. | +| `src/hope_hash/__init__.py` | Версия → `0.6.0`. Re-export `PoolList`, `parse_pool_spec`, `BitcoinRPC`, `RPCError`, `SoloClient`, `build_coinbase`, `compute_witness_commitment`, `parse_default_witness_commitment`, `serialize_block`, `available_backends`, `run_benchmark_all_backends`, `sha_native`. | +| `CHANGELOG.md` | v0.6.0 секция. | +| `README.md` | "Что нового в v0.6.0" параграф (НЕ переписывал README — это PR C). | +| `ROADMAP.md` | Тикнул failover, getblocktemplate, ctypes-обёртка SHA-256. | + +## New CLI flags + +| Flag | Default | Notes | +| --- | --- | --- | +| `--pool HOST:PORT` | `solo.ckpool.org:3333` | Repeatable. Если задан, дефолтный CKPool игнорируется. | +| `--rotate-after-failures N` | `3` | Сколько подряд провалов до ротации. | +| `--sha-backend {auto,hashlib,ctypes}` | `auto` | auto = ctypes если libcrypto загружается. | +| `--backends` | off | С `--benchmark`: прогон всех доступных backend'ов. | +| `--solo` | off | Solo-mode через `getblocktemplate`. | +| `--rpc-url URL` | none | Обязателен с `--solo`. | +| `--rpc-cookie PATH` | none | Cookie wins над `--rpc-user/--rpc-pass`. | +| `--rpc-user USER` | none | Альтернатива cookie. | +| `--rpc-pass PASS` | none | Альтернатива cookie. | +| `--solo-poll-sec SEC` | `5.0` | Период `getblocktemplate`. | + +## New endpoints + +Нет новых сетевых эндпоинтов (это PR C). + +## Architecture additions + +- **`PoolList`** хранит endpoint'ы, индекс текущего, счётчики провалов + и аккумулятор «ротаций с момента успеха» (для `full_cycle_failed`). + `RLock`, чтобы вложенные методы не deadlock'ались. +- **`set_endpoint(host, port)`** на `StratumClient` сохраняет + `on_share_result`, `stop_event`, `suggest_diff`, `username` и локи. + Это критично: `mine()` уже подписан на `client.on_share_result`, + пересоздание клиента сбросило бы callback. +- **`SoloClient`** — fully duck-typed под `StratumClient`. Любой код, + который читает `current_job`/`job_lock`/`extranonce1`/`difficulty`/ + `submit()`/`on_share_result`, работает без изменений. +- **`worker()` диспатчит** на private `_worker_*` функции по `sha_backend`. + Hot path mid-state остался байт-в-байт тем же; ctypes — отдельная + ветка для бенча. + +## Gotchas for PR C + +1. **`SoloClient.host`/`port` в `(solo)`/0** — healthz-зонд из PR A + (`client.sock is not None`) работает: после `connect()` мы кладём + sentinel в `sock`. Web-дашборд из PR C должен показывать + `pool_url` из `StatsProvider`, не `client.host` напрямую. +2. **`build_coinbase` использует OP_RETURN как scriptPubKey** — это + сознательное упрощение (учебный код, шанс найти блок ≈ 0). Если + PR C захочет «настоящий» payout-скрипт, нужно реализовать + bech32/base58check decode → P2WPKH/P2PKH. Это отдельная задача + с тестовыми векторами BIP-173/BIP-350. +3. **`parse_default_witness_commitment` ожидает префикс** + `6a24aa21a9ed...` — стандарт bitcoind. Если PR C добавит regtest + с кастомным форматом, надо это учитывать. +4. **`SoloClient.reader_loop` выходит при первой RPC-ошибке**, чтобы + supervisor переподключился. На стабильном bitcoind это значит — + no-op (poll_loop успешный). На flaky сети будет много reconnect'ов. + Если станет больно — нужно ввести retry с backoff внутри reader_loop. +5. **`--sha-backend ctypes` медленнее** (~0.3x от hashlib mid-state) + потому что без mid-state. Web-дашборд должен это понимать — + показывать `BACKEND_NAME` рядом с хешрейтом, чтобы пользователь + не удивлялся, почему "ctypes медленнее". +6. **PoolList дедуп case-insensitive** по host. Если кто-то указал + и `pool.com:3333` и `Pool.Com:3333` — это один endpoint. +7. **`/api/stats` (PR C)** должен включать `current_pool` (есть в + `StatsSnapshot.pool_url`) и `sha_backend` (можно добавить отдельный + геттер `StatsProvider.set_backend(name)` или передать константой + при инициализации). + +## Open questions for PR C + +- Web-дашборд: показывать ли отдельно «pool failures» из `PoolList`? + Можно сделать `PoolList.failures_summary() -> dict[str, int]` для + `/api/stats` — будет видно, что один из пулов вечно падает. +- Solo-режим в `/healthz`: `last_share_ts` теряет смысл (шар = блок, + ≈ 0 за всю жизнь майнера). Стоит добавить `last_block_template_ts` + отдельным полем — это видно через `SoloClient._last_template`-timestamp. +- `--sha-backend ctypes` мониторим через `BACKEND_NAME`, но в + `Metrics`/Prometheus его пока нет. Добавить `hopehash_sha_backend` + как label на `hopehash_hashrate_hps`? +- Docker (PR C): `libcrypto` доступен в `python:3.11-slim` — auto + должен сразу выбрать ctypes. Стоит ли добавить env var + `HOPE_HASH_SHA_BACKEND` для контейнерной конфигурации? +- BIP-22 `coinbasetxn`: некоторые ноды могут отдавать готовый coinbase + через `coinbasetxn` вместо `coinbasevalue`. PR B этот путь не + поддерживает (всегда строим сами). Если PR C хочет совместимости + с большим числом нод — это TODO. + +## Verification + +``` +py -3.11 -m unittest discover -s tests -v +# Ran 225 tests in ~14s — OK + +py -3.11 -m hope_hash --help # CLI парсится +py -3.11 -m hope_hash --benchmark --bench-duration 1 --backends # сравнение backend'ов +py -3.11 -m hope_hash --benchmark --bench-duration 3 --workers 4 +# hashrate: 3.18 MH/s (тот же порядок, что v0.5.0 — hot path не тронут) +``` + +No `pip install`. No third-party imports under `src/hope_hash/`. +`block.py` endianness и `_worker_hashlib_midstate` hot path не +изменились. Existing 145 тестов остались зелёными. diff --git a/src/hope_hash/__init__.py b/src/hope_hash/__init__.py index 67fc014..cea6222 100644 --- a/src/hope_hash/__init__.py +++ b/src/hope_hash/__init__.py @@ -1,14 +1,25 @@ """Hope-Hash — учебный solo BTC miner на чистом stdlib.""" -__version__ = "0.5.0" +__version__ = "0.6.0" +from . import sha_native from .banner import print_banner, render_banner -from .bench import BenchResult, run_benchmark +from .bench import BenchResult, available_backends, run_benchmark, run_benchmark_all_backends from .block import build_merkle_root, difficulty_to_target, double_sha256, swap_words from .demo import run_demo from .metrics import Metrics, MetricsServer, build_health_snapshot from .miner import mine from .notifier import TelegramNotifier +from .pools import PoolList, parse_pool_spec +from .solo import ( + BitcoinRPC, + RPCError, + SoloClient, + build_coinbase, + compute_witness_commitment, + parse_default_witness_commitment, + serialize_block, +) from .storage import ShareStore from .stratum import StratumClient from .tui import StatsProvider, StatsSnapshot, TUIApp @@ -22,6 +33,8 @@ "mine", "run_demo", "run_benchmark", + "run_benchmark_all_backends", + "available_backends", "BenchResult", "ShareStore", "Metrics", @@ -33,5 +46,15 @@ "TUIApp", "print_banner", "render_banner", + "PoolList", + "parse_pool_spec", + "BitcoinRPC", + "RPCError", + "SoloClient", + "build_coinbase", + "compute_witness_commitment", + "parse_default_witness_commitment", + "serialize_block", + "sha_native", "__version__", ] diff --git a/src/hope_hash/bench.py b/src/hope_hash/bench.py index 07f780e..21692bb 100644 --- a/src/hope_hash/bench.py +++ b/src/hope_hash/bench.py @@ -64,13 +64,23 @@ def _format_rate(rate: float) -> str: return f"{rate / 1_000_000:.2f} MH/s" -def run_benchmark(duration_s: float = 10.0, n_workers: int = 1) -> BenchResult: +def run_benchmark( + duration_s: float = 10.0, + n_workers: int = 1, + sha_backend: str = "hashlib", + print_header: bool = True, +) -> BenchResult: """Один прогон бенчмарка. Не делает сетевых вызовов и не находит шары. Параметры: - duration_s — сколько секунд хешировать. Меньше 1 нет смысла — - накладные расходы на spawn доминируют. - n_workers — число процессов-воркеров (как в реальном майнинге). + duration_s — сколько секунд хешировать. Меньше 1 нет смысла — + накладные расходы на spawn доминируют. + n_workers — число процессов-воркеров (как в реальном майнинге). + sha_backend — ``"hashlib"`` (mid-state) или ``"ctypes"`` + (libcrypto через EVP, без mid-state). + print_header — печатать ли preamble (platform/python/cpu). + В режиме ``--backends`` мы вызываем run_benchmark + несколько раз и печатаем header только один раз. """ n_workers = max(1, int(n_workers)) duration_s = max(0.1, float(duration_s)) @@ -78,20 +88,21 @@ def run_benchmark(duration_s: float = 10.0, n_workers: int = 1) -> BenchResult: target = 0 # никакой хеш не пройдёт → воркеры хешируют без выходов extranonce2 = "00000000" - logger.info(f"[bench] platform: {platform.platform()}") - logger.info( - f"[bench] python: {platform.python_version()} " - f"({sys.implementation.name})" - ) - logger.info( - f"[bench] cpu: {multiprocessing.cpu_count()} logical cores" - f"{f' ({platform.processor()})' if platform.processor() else ''}" - ) - logger.info(f"[bench] workers: {n_workers}, duration: {duration_s:.1f}s") + if print_header: + logger.info(f"[bench] platform: {platform.platform()}") + logger.info( + f"[bench] python: {platform.python_version()} " + f"({sys.implementation.name})" + ) + logger.info( + f"[bench] cpu: {multiprocessing.cpu_count()} logical cores" + f"{f' ({platform.processor()})' if platform.processor() else ''}" + ) + logger.info(f"[bench] workers: {n_workers}, duration: {duration_s:.1f}s, backend: {sha_backend}") logger.info(f"[bench] running...") processes, found_queue, hashes_counter, mp_stop = start_pool( - n_workers, header_base, target, extranonce2, + n_workers, header_base, target, extranonce2, sha_backend=sha_backend, ) start = time.perf_counter() @@ -140,3 +151,73 @@ def run_benchmark(duration_s: float = 10.0, n_workers: int = 1) -> BenchResult: total_hashes=total, hashrate_hps=hashrate, ) + + +def available_backends() -> list[str]: + """Список backend'ов, запускаемых ``--benchmark --backends``. + + ``hashlib`` всегда доступен (stdlib). ``ctypes`` — только если + libcrypto загрузился. Порядок: сначала hashlib (baseline), потом + ctypes (для сравнения «во сколько раз быстрее»). + """ + backends = ["hashlib"] + from . import sha_native + if sha_native.is_available(): + backends.append("ctypes") + return backends + + +def run_benchmark_all_backends( + duration_s: float = 10.0, + n_workers: int = 1, +) -> dict[str, BenchResult]: + """Прогоняет бенчмарк по всем доступным backend'ам и печатает сравнение. + + Возвращает dict ``backend_name -> BenchResult``. Печатает финальную + строку вида ``[bench] result: ctypes 1.42 MH/s (1.85x vs hashlib)``, + которую парсят docs/CI. + """ + backends = available_backends() + results: dict[str, BenchResult] = {} + for i, backend in enumerate(backends): + # Header (platform/python/cpu) печатаем только в первом прогоне. + results[backend] = run_benchmark( + duration_s=duration_s, n_workers=n_workers, + sha_backend=backend, print_header=(i == 0), + ) + logger.info(f"[bench]") + + if not results: + return results + + # Сводка: baseline = hashlib (всегда есть). Если есть только hashlib — + # просто его число. Если есть ctypes — ratio относительно hashlib. + baseline_name = "hashlib" + baseline = results.get(baseline_name) + logger.info(f"[bench] === backend comparison ===") + for name, res in results.items(): + if baseline is not None and baseline.hashrate_hps > 0 and name != baseline_name: + ratio = res.hashrate_hps / baseline.hashrate_hps + logger.info( + f"[bench] {name:<10} {_format_rate(res.hashrate_hps):>14} " + f"({ratio:.2f}x vs {baseline_name})" + ) + else: + logger.info( + f"[bench] {name:<10} {_format_rate(res.hashrate_hps):>14}" + ) + + # Last line, легко парсится. + if "ctypes" in results and baseline is not None and baseline.hashrate_hps > 0: + ratio = results["ctypes"].hashrate_hps / baseline.hashrate_hps + logger.info( + f"[bench] result: ctypes {_format_rate(results['ctypes'].hashrate_hps)} " + f"({ratio:.2f}x vs hashlib-midstate)" + ) + else: + logger.info( + f"[bench] result: hashlib-midstate {_format_rate(baseline.hashrate_hps)} " + f"(ctypes недоступен)" + ) + + return results diff --git a/src/hope_hash/cli.py b/src/hope_hash/cli.py index 2ccab2a..961f605 100644 --- a/src/hope_hash/cli.py +++ b/src/hope_hash/cli.py @@ -17,6 +17,7 @@ from .metrics import Metrics, MetricsServer, build_health_snapshot from .miner import mine, supervisor_loop from .notifier import TelegramNotifier +from .pools import PoolList, parse_pool_spec from .storage import ShareStore from .stratum import StratumClient from .tui import StatsProvider, TUIApp, format_rate, format_uptime, is_curses_available @@ -105,6 +106,53 @@ def _parse_args() -> argparse.Namespace: help="Сколько секунд без шар до того, как /healthz отдаёт degraded " "(по умолчанию: 600).", ) + parser.add_argument( + "--pool", action="append", default=None, metavar="HOST:PORT", + help="Пул для подключения (можно указывать несколько раз для failover). " + "При провале текущего — supervisor ротирует на следующий. " + "По умолчанию: solo.ckpool.org:3333.", + ) + parser.add_argument( + "--rotate-after-failures", type=int, default=3, + metavar="N", + help="Сколько подряд провалов на одном пуле до ротации (default 3).", + ) + parser.add_argument( + "--sha-backend", choices=("auto", "hashlib", "ctypes"), default="auto", + help="SHA-256 backend для воркеров. auto = ctypes если libcrypto " + "загружается, иначе hashlib. По умолчанию: auto.", + ) + parser.add_argument( + "--backends", action="store_true", + help="В режиме --benchmark прогнать все доступные backend'ы для " + "сравнения (hashlib mid-state vs ctypes без mid-state).", + ) + parser.add_argument( + "--solo", action="store_true", + help="Solo-режим через getblocktemplate. Требует --rpc-url + " + "(--rpc-cookie ИЛИ --rpc-user/--rpc-pass).", + ) + parser.add_argument( + "--rpc-url", type=str, default=None, metavar="URL", + help="JSON-RPC URL bitcoind (например http://127.0.0.1:8332).", + ) + parser.add_argument( + "--rpc-cookie", type=str, default=None, metavar="PATH", + help="Путь к cookie-файлу bitcoind (обычно ~/.bitcoin/.cookie).", + ) + parser.add_argument( + "--rpc-user", type=str, default=None, metavar="USER", + help="JSON-RPC пользователь (если cookie не используется).", + ) + parser.add_argument( + "--rpc-pass", type=str, default=None, metavar="PASS", + help="JSON-RPC пароль (если cookie не используется).", + ) + parser.add_argument( + "--solo-poll-sec", type=float, default=5.0, + metavar="SEC", + help="Период опроса getblocktemplate (default 5с).", + ) return parser.parse_args() @@ -140,6 +188,28 @@ def _setup_logging_for_tui(log_file: str | None, tui_active: bool) -> None: root.addHandler(fh) +def _resolve_sha_backend(choice: str) -> str: + """``auto`` → ``ctypes`` если libcrypto загружается, иначе ``hashlib``. + + Явный выбор пользователя пробрасывается без изменений; в воркере + ctypes-backend сам падает на hashlib, если libcrypto там не нашёлся + (актуально для multiprocessing-spawn на нестандартной машине). + """ + if choice != "auto": + return choice + from . import sha_native + return "ctypes" if sha_native.is_available() else "hashlib" + + +def _build_pool_list(args: argparse.Namespace) -> PoolList: + """Строит ``PoolList`` из --pool флагов (или дефолтного CKPool).""" + if args.pool: + endpoints = [parse_pool_spec(s) for s in args.pool] + else: + endpoints = [(POOL_HOST, POOL_PORT)] + return PoolList(endpoints, rotate_after_failures=args.rotate_after_failures) + + def _format_stats_message(snap) -> str: """Сборка ответа на /stats для Telegram.""" return ( @@ -185,9 +255,20 @@ def main(): print("error: --benchmark и --demo взаимоисключающи", file=sys.stderr) sys.exit(2) + # ─── разрешаем sha_backend (нужен для bench и для mine) ─── + sha_backend = _resolve_sha_backend(args.sha_backend) + if args.benchmark: - from .bench import run_benchmark - run_benchmark(duration_s=args.bench_duration, n_workers=n_workers) + from .bench import run_benchmark, run_benchmark_all_backends + if args.backends: + run_benchmark_all_backends( + duration_s=args.bench_duration, n_workers=n_workers, + ) + else: + run_benchmark( + duration_s=args.bench_duration, n_workers=n_workers, + sha_backend=sha_backend, + ) return if args.demo: @@ -208,6 +289,15 @@ def main(): print(f"error: некорректный BTC-адрес '{args.btc_address}': {e}", file=sys.stderr) sys.exit(2) + if args.solo: + if not args.rpc_url: + print("error: --solo требует --rpc-url", file=sys.stderr) + sys.exit(2) + if not args.rpc_cookie and not (args.rpc_user and args.rpc_pass): + print("error: --solo требует --rpc-cookie ИЛИ --rpc-user/--rpc-pass", + file=sys.stderr) + sys.exit(2) + # ─── observers ─── # Все три опциональны и не зависят друг от друга. Каждый сам решает, # включаться ли (notifier — по env vars; metrics — по порту; store — по флагу). @@ -225,26 +315,59 @@ def main(): notifier = TelegramNotifier.from_env() notifier.notify_started(args.btc_address, args.worker_name) + # ─── multi-pool failover ─── + pool_list = _build_pool_list(args) + initial_host, initial_port = pool_list.current() + if store is not None: - session_id = store.start_session(POOL_HOST, args.btc_address, args.worker_name) + session_id = store.start_session(initial_host, args.btc_address, args.worker_name) else: session_id = None # ─── stats provider, TUI и healthz ─── - pool_url = f"{POOL_HOST}:{POOL_PORT}" - stats_provider = StatsProvider(pool_url=pool_url) + stats_provider = StatsProvider(pool_url=pool_list.current_url()) # ─── сетевая часть и mine() ─── stop = threading.Event() restart_event = threading.Event() - client = StratumClient(POOL_HOST, POOL_PORT, args.btc_address, args.worker_name, - stop_event=stop, suggest_diff=args.suggest_diff) + + if args.solo: + from .solo import BitcoinRPC, SoloClient + rpc = BitcoinRPC( + url=args.rpc_url, + cookie_path=Path(args.rpc_cookie) if args.rpc_cookie else None, + username=args.rpc_user, + password=args.rpc_pass, + ) + client = SoloClient( + rpc=rpc, + btc_address=args.btc_address, + worker_name=args.worker_name, + stop_event=stop, + poll_interval_s=args.solo_poll_sec, + ) + stats_provider.update_pool(f"solo:{args.rpc_url}") + # В solo-режиме pool_list игнорируется супервизором (нет смысла + # ротировать между bitcoind-инстансами). + supervisor = threading.Thread( + target=supervisor_loop, + args=(client, restart_event), + kwargs={"stats_provider": stats_provider}, + name="solo-supervisor", daemon=False, + ) + else: + client = StratumClient(initial_host, initial_port, args.btc_address, args.worker_name, + stop_event=stop, suggest_diff=args.suggest_diff) + supervisor = threading.Thread( + target=supervisor_loop, + args=(client, restart_event), + kwargs={"pools": pool_list, "stats_provider": stats_provider}, + name="stratum-supervisor", daemon=False, + ) # Сетевая часть живёт в отдельной нити-супервизоре: она держит коннект, # переподключается при разрывах и сама поднимает reader_loop. main thread # отдан под mine(), чтобы Ctrl+C ловился предсказуемо. - supervisor = threading.Thread(target=supervisor_loop, args=(client, restart_event), - name="stratum-supervisor", daemon=False) supervisor.start() # Healthz: знаем, что reader жив, если supervisor поднял текущий коннект. @@ -324,7 +447,8 @@ def _wrapped_update(ema: float, last_sample: float, workers: int) -> None: if not stop.is_set(): mine(client, stop, n_workers=n_workers, store=store, metrics=metrics, notifier=notifier, - stats_provider=stats_provider) + stats_provider=stats_provider, + sha_backend=sha_backend) except KeyboardInterrupt: logger.info("[main] остановка по Ctrl+C") finally: diff --git a/src/hope_hash/miner.py b/src/hope_hash/miner.py index 3440b70..a325683 100644 --- a/src/hope_hash/miner.py +++ b/src/hope_hash/miner.py @@ -13,6 +13,7 @@ from .metrics import Metrics from .notifier import TelegramNotifier from .parallel import start_pool, stop_pool +from .pools import PoolList from .storage import ShareStore from .stratum import StratumClient from .tui import StatsProvider @@ -43,24 +44,47 @@ def run_session(client: StratumClient) -> threading.Thread: def supervisor_loop( client: StratumClient, restart_event: Optional[threading.Event] = None, + pools: Optional[PoolList] = None, + stats_provider: Optional[StatsProvider] = None, ) -> None: """ Поднимает соединение и переподключается с экспоненциальным backoff (1с → 2с → 4с → ... до 60с) пока stop_event не выставлен. Запускается в отдельной нити, чтобы main thread мог крутить mine(). - ``restart_event`` (опционально) — сигнал «дёрнуть текущий коннект и - переподключиться». Используется обработчиком ``/restart`` из Telegram. - Будучи установленным, закрывает сокет (это разбудит reader_loop, и - тот вернёт управление); supervisor увидит выход reader-а и пойдёт на - новый цикл connect/subscribe. + ``restart_event`` — сигнал «дёрнуть текущий коннект и переподключиться» + (используется обработчиком ``/restart`` из Telegram). При установке + закрываем сокет (это разбудит reader_loop), тот вернёт управление, + supervisor увидит выход reader-а и пойдёт на новый цикл. + + ``pools`` — список endpoint'ов для multi-pool failover. Если задан: + при провале коннекта/сессии вызывается ``mark_failed()``; после N + провалов список ротирует, supervisor переинициализирует ``client`` + через ``set_endpoint()``. После полного круга без успехов + применяется обычный exponential backoff. + + ``stats_provider`` — опционально, чтобы пушить актуальный pool URL + в TUI/healthz при ротации. """ backoff = 1 + # Если pools задан — перенацеливаем клиент на текущий endpoint + # ДО первого connect: пользователь мог передать --pool, отличный + # от того, с которым создавался StratumClient. + if pools is not None: + host, port = pools.current() + client.set_endpoint(host, port) + if stats_provider is not None: + stats_provider.update_pool(pools.current_url()) + while not client.stop_event.is_set(): reader_thread = None + session_succeeded = False try: reader_thread = run_session(client) + session_succeeded = True backoff = 1 # успешный коннект — сбрасываем задержку + if pools is not None: + pools.mark_success() # Ждём, пока reader не выйдет (по ошибке сети или stop_event). while reader_thread.is_alive() and not client.stop_event.is_set(): reader_thread.join(timeout=1.0) @@ -70,7 +94,7 @@ def supervisor_loop( restart_event.clear() break except (ConnectionError, socket.error, OSError) as e: - logger.warning(f"[net] не удалось подключиться: {e}") + logger.warning(f"[net] не удалось подключиться к {client.host}:{client.port}: {e}") except Exception: # Используем .exception() вместо .error(): пишет полный traceback, # чтобы программерские баги (KeyError, AttributeError) не маскировались. @@ -82,6 +106,34 @@ def supervisor_loop( # reader умер сам (разрыв TCP) — закрываем сокет и ждём. client.close() + + # Multi-pool failover: считаем как failure только если коннект не поднялся. + # Если session_succeeded=True, это «прожили какое-то время и упали» — + # это не повод сразу прыгать на другой пул, дадим тому же шанс через backoff. + if pools is not None and not session_succeeded: + rotated = pools.mark_failed() + if rotated: + new_host, new_port = pools.current() + logger.warning( + f"[pool] ротация на следующий пул: {new_host}:{new_port}" + ) + client.set_endpoint(new_host, new_port) + if stats_provider is not None: + stats_provider.update_pool(pools.current_url()) + # Если только что обошли весь круг без успеха — ждём backoff, + # потом начинаем новый круг. + if pools.full_cycle_failed(): + logger.warning( + f"[pool] все {pools.size} пул(ов) недоступны, ждём {backoff}с" + ) + if client.stop_event.wait(timeout=backoff): + break + backoff = min(backoff * 2, 60) + pools.reset_round() + else: + # Только ротация, без backoff — следующий пул может быть жив. + continue + logger.warning(f"[net] reconnect через {backoff}с") # Ждём через wait(), чтобы Ctrl+C прерывал паузу мгновенно. if client.stop_event.wait(timeout=backoff): @@ -127,6 +179,7 @@ def mine( metrics: Optional[Metrics] = None, notifier: Optional[TelegramNotifier] = None, stats_provider: Optional[StatsProvider] = None, + sha_backend: str = "hashlib", ) -> None: """ Оркестратор пула воркеров. @@ -217,6 +270,7 @@ def _on_share_result(req_id: int, accepted: bool) -> None: processes, found_queue, hashes_counter, mp_stop = start_pool( n_workers, header_base, target, extranonce2, + sha_backend=sha_backend, ) prev_count = 0 diff --git a/src/hope_hash/parallel.py b/src/hope_hash/parallel.py index 4314c66..372a8bb 100644 --- a/src/hope_hash/parallel.py +++ b/src/hope_hash/parallel.py @@ -37,6 +37,7 @@ def worker( found_queue: "mp.Queue", hashes_counter: "mp.sharedctypes.Synchronized", stop_event: "mp.synchronize.Event", + sha_backend: str = "hashlib", ) -> None: """ Перебирает nonce в диапазоне [nonce_start, nonce_end), считает SHA256d. @@ -48,13 +49,51 @@ def worker( Сам ``submit`` делается из main process — здесь только находка. Сигнатура полностью pickle-able: bytes/int/str + примитивы mp. + + ``sha_backend`` управляет, чем хешировать: + - ``"hashlib"`` (default) — mid-state оптимизация через ``hashlib.copy()``. + Это hot path, проверенный на реальных блоках. + - ``"ctypes"`` — каждая итерация = ``sha256d(header_base+nonce_le)`` + через libcrypto. Без mid-state. Используется для бенчмарка. + Если libcrypto не загрузился, прозрачно падаем на hashlib. + """ + if sha_backend == "ctypes": + from . import sha_native + if sha_native.is_available(): + _worker_ctypes( + header_base, target, nonce_start, nonce_end, extranonce2, + found_queue, hashes_counter, stop_event, sha_native.sha256d, + ) + return + # libcrypto не нашёлся в воркере (на нестандартной машине) — + # тихо переключаемся на hashlib, чтобы майнер не падал. + # Логируем в воркер-процессе один раз через логгер пакета. + logger.warning("[sha] worker: ctypes недоступен, fallback hashlib") + + _worker_hashlib_midstate( + header_base, target, nonce_start, nonce_end, extranonce2, + found_queue, hashes_counter, stop_event, + ) + + +def _worker_hashlib_midstate( + header_base: bytes, + target: int, + nonce_start: int, + nonce_end: int, + extranonce2: str, + found_queue: "mp.Queue", + hashes_counter: "mp.sharedctypes.Synchronized", + stop_event: "mp.synchronize.Event", +) -> None: + """Hot path: mid-state SHA-256 через ``hashlib.copy()``. + + Block header = 80 байт = 64 + 16. SHA-256 обрабатывает данные блоками + по 64 байта, поэтому первые 64 байта header_base — константа в пределах + одного nonce-цикла. Вычисляем SHA-256 mid-state один раз, а в горячем + цикле делаем только copy() + дохэшируем оставшиеся 16 байт. + Экономия: ~половина первого SHA-256-прохода на каждый nonce. """ - # Mid-state оптимизация: block header = 80 байт = 64 + 16. - # SHA-256 обрабатывает данные блоками по 64 байта, поэтому первые 64 байта - # header_base (version + prevhash + merkle_root[:28]) — константа в пределах - # одного nonce-цикла. Вычисляем SHA-256 mid-state один раз, а в горячем - # цикле делаем только copy() + дохэшируем оставшиеся 16 байт. - # Экономия: ~половина первого SHA-256-прохода на каждый nonce. inner_mid = hashlib.sha256() inner_mid.update(header_base[:64]) tail_prefix = header_base[64:] # 12 байт: merkle_root[28:] + ntime + nbits @@ -95,6 +134,50 @@ def worker( hashes_counter.value += local_hashes +def _worker_ctypes( + header_base: bytes, + target: int, + nonce_start: int, + nonce_end: int, + extranonce2: str, + found_queue: "mp.Queue", + hashes_counter: "mp.sharedctypes.Synchronized", + stop_event: "mp.synchronize.Event", + sha256d, +) -> None: + """ctypes-backend: ``sha256d(header_base + nonce_le)`` каждую итерацию. + + Без mid-state — это сознательная плата за честный замер скорости + нативного backend. Если хочется полной производительности — оставляйте + backend hashlib (mid-state даёт ~2x). + """ + local_hashes = 0 + nonce = nonce_start + try: + while nonce < nonce_end: + buf = header_base + struct.pack("I", nonce).hex() + hash_hex = h[::-1].hex() + found_queue.put((nonce_hex, hash_hex, extranonce2)) + + nonce += 1 + local_hashes += 1 + + if local_hashes >= HASHES_PER_TICK: + with hashes_counter.get_lock(): + hashes_counter.value += local_hashes + local_hashes = 0 + if stop_event.is_set(): + return + finally: + if local_hashes: + with hashes_counter.get_lock(): + hashes_counter.value += local_hashes + + # ─────────────────────── оркестрация пула ─────────────────────── @@ -103,6 +186,7 @@ def start_pool( header_base: bytes, target: int, extranonce2: str, + sha_backend: str = "hashlib", ) -> tuple: """ Поднимает ``n_workers`` процессов, делящих [0, 2^32) поровну. @@ -110,6 +194,9 @@ def start_pool( Возвращает кортеж ``(processes, found_queue, hashes_counter, stop_event)``. Все объекты IPC создаются здесь, чтобы main process был их единственным владельцем — это упрощает корректный teardown в ``stop_pool``. + + ``sha_backend`` пробрасывается в воркер: ``"hashlib"`` (mid-state) + или ``"ctypes"`` (libcrypto через EVP). См. ``worker()``. """ n_workers = max(1, int(n_workers)) nonce_space = 1 << 32 @@ -130,6 +217,7 @@ def start_pool( args=( i, header_base, target, nonce_start, nonce_end, extranonce2, found_queue, hashes_counter, stop_event, + sha_backend, ), daemon=False, ) @@ -138,7 +226,7 @@ def start_pool( logger.info( f"[pool] стартовал {n_workers} воркер(ов), " - f"шаг nonce-пространства = {step:#x}" + f"шаг nonce-пространства = {step:#x}, sha_backend={sha_backend}" ) return processes, found_queue, hashes_counter, stop_event diff --git a/src/hope_hash/pools.py b/src/hope_hash/pools.py new file mode 100644 index 0000000..c4eeb68 --- /dev/null +++ b/src/hope_hash/pools.py @@ -0,0 +1,174 @@ +"""Список пулов с round-robin failover. + +Зачем отдельный модуль: supervisor_loop в miner.py не должен знать +про набор пулов — это конфигурация, а не runtime-логика. ``PoolList`` +держит состояние (текущий индекс, счётчики провалов) и решает, когда +ротировать. supervisor получает «текущий хост» из ``current()`` и +сообщает обратно через ``mark_failed()`` / ``mark_success()``. + +Поведение: + +- ``current()`` всегда возвращает текущий ``(host, port)``. +- ``mark_failed()`` инкрементирует счётчик провалов для текущего пула. + При достижении ``rotate_after_failures`` ротирует на следующий и + сбрасывает локальный счётчик. +- ``mark_success()`` сбрасывает счётчик провалов на текущем пуле — после + успешного коннекта мы доверяем ему снова. +- ``rotate()`` — ручной форс-ротейт без условия (используется тестами и + для обработки явного «отключение по времени», а не «не удалось коннект»). +- ``full_cycle_failed()`` — True, если за последний раунд все пулы + отметились как failed. Supervisor использует это, чтобы понять, что + пора применить exponential backoff (а не сразу ретраить). +""" + +from __future__ import annotations + +import threading +from typing import Iterable + + +PoolEndpoint = tuple[str, int] + + +class PoolList: + """Round-robin список пулов с подсчётом провалов. + + Все методы потокобезопасны (используются из supervisor-нити, но + могут читаться из cli/healthz). Локально используем ``RLock``, чтобы + публичные методы могли вызывать друг друга без deadlock. + """ + + def __init__( + self, + endpoints: Iterable[PoolEndpoint], + rotate_after_failures: int = 3, + ) -> None: + eps: list[PoolEndpoint] = list(endpoints) + if not eps: + raise ValueError("PoolList требует хотя бы один endpoint") + if rotate_after_failures < 1: + raise ValueError("rotate_after_failures должно быть >= 1") + # Дедуп с сохранением порядка: пользователь мог по ошибке указать + # один и тот же пул дважды; ротация по дублям бессмысленна. + seen: set[PoolEndpoint] = set() + deduped: list[PoolEndpoint] = [] + for host, port in eps: + key = (host.lower(), int(port)) + if key in seen: + continue + seen.add(key) + deduped.append((host, int(port))) + + self._endpoints: list[PoolEndpoint] = deduped + self._rotate_after = int(rotate_after_failures) + self._lock = threading.RLock() + self._idx = 0 + # Счётчики провалов на каждый endpoint (не локальный для current, + # а глобальный — чтобы full_cycle_failed было считаемо). + self._failures: list[int] = [0] * len(self._endpoints) + # Сколько раз ротировали с момента последнего успешного коннекта. + # Если ротировали >= len(endpoints), значит обошли весь круг, + # никто не поднялся → full_cycle_failed. + self._rotations_since_success = 0 + + # ─────────────── чтение ─────────────── + + def current(self) -> PoolEndpoint: + """Текущий ``(host, port)``.""" + with self._lock: + return self._endpoints[self._idx] + + def current_url(self) -> str: + """Удобный формат для логов и TUI: ``host:port``.""" + host, port = self.current() + return f"{host}:{port}" + + @property + def size(self) -> int: + return len(self._endpoints) + + def all_endpoints(self) -> list[PoolEndpoint]: + """Копия списка — для отладки и тестов.""" + with self._lock: + return list(self._endpoints) + + def failures(self, idx: int | None = None) -> int: + """Счётчик провалов для индекса (по умолчанию — текущий).""" + with self._lock: + i = self._idx if idx is None else idx + return self._failures[i] + + def full_cycle_failed(self) -> bool: + """True если за последний раунд все пулы упали без единого успеха.""" + with self._lock: + return self._rotations_since_success >= len(self._endpoints) + + # ─────────────── мутации ─────────────── + + def mark_failed(self) -> bool: + """Засчитывает провал текущему пулу. Возвращает True если ротировали.""" + with self._lock: + self._failures[self._idx] += 1 + if self._failures[self._idx] >= self._rotate_after: + self._rotate_locked() + return True + return False + + def mark_success(self) -> None: + """Текущий пул успешно коннектнулся — сбрасываем его счётчик и + раундовый аккумулятор. Остальные счётчики не трогаем: они + важны как «история», но именно текущий мы только что подтвердили. + """ + with self._lock: + self._failures[self._idx] = 0 + self._rotations_since_success = 0 + + def rotate(self) -> PoolEndpoint: + """Принудительный round-robin сдвиг. Возвращает новый current.""" + with self._lock: + self._rotate_locked() + return self._endpoints[self._idx] + + def reset_round(self) -> None: + """Сбрасывает аккумулятор full_cycle_failed без сдвига индекса. + + Используется после exponential-backoff паузы: «мы подождали, + давайте ещё раз попробуем все пулы по очереди». + """ + with self._lock: + self._rotations_since_success = 0 + + # ─────────────── внутренние ─────────────── + + def _rotate_locked(self) -> None: + """Ротация под уже взятым ``self._lock``.""" + self._idx = (self._idx + 1) % len(self._endpoints) + # Счётчик провалов следующего пула не сбрасываем: если он недавно + # упал — пусть это видно в .failures(). Но локальный аккумулятор + # ротаций бьём, чтобы full_cycle_failed считался корректно. + self._rotations_since_success += 1 + + +def parse_pool_spec(spec: str, default_port: int = 3333) -> PoolEndpoint: + """Парсит строку ``host:port`` или ``host`` в endpoint. + + Принимает оба варианта, чтобы пользователь мог писать ``--pool host``, + если у него стандартный порт. Падает на пустой строке или + некорректном порте — это явный signal-ошибки в CLI, а не silent default. + """ + s = (spec or "").strip() + if not s: + raise ValueError("пустой pool-спецификатор") + if ":" in s: + host, _, port_s = s.rpartition(":") + host = host.strip() + if not host: + raise ValueError(f"пустой host в '{spec}'") + try: + port = int(port_s) + except ValueError as e: + raise ValueError(f"некорректный порт в '{spec}': {port_s}") from e + if not (0 < port < 65536): + raise ValueError(f"порт вне диапазона 1..65535: {port}") + return host, port + return s, int(default_port) diff --git a/src/hope_hash/sha_native.py b/src/hope_hash/sha_native.py new file mode 100644 index 0000000..e9a053a --- /dev/null +++ b/src/hope_hash/sha_native.py @@ -0,0 +1,189 @@ +"""ctypes-based SHA-256 backend через системный libcrypto (OpenSSL). + +Зачем: pure-Python ``hashlib.sha256`` всё равно вызывает C-реализацию через +CPython, но накладные расходы на создание объекта-хешера и вызов методов +Python-уровня в hot-path майнера ощутимы. ``hashlib`` оптимизирован для +mid-state-цикла (через ``copy()``); для альтернативного бенчмарка нам +нужен прямой ctypes-доступ к OpenSSL EVP API, чтобы честно измерить +«один-проход sha256d на iteration» без mid-state-трюка. + +Архитектура: + +- При импорте пытаемся загрузить ``libcrypto`` под платформенными именами. + Если не вышло — ставим ``BACKEND_NAME = "hashlib-fallback"`` и публичные + ``sha256``/``sha256d`` молча используют ``hashlib``. +- Используем EVP API (``EVP_DigestInit_ex`` / ``Update`` / ``Final_ex``). + Не трогаем ``EVP_MD_CTX_copy_ex`` — mid-state через ctypes не нужен, + так нечестно мерить. +- Никаких глобальных C-объектов: контекст создаём/уничтожаем на каждый + вызов. Это дороже, но проще и безопаснее (нет проблем с потокобезопасностью). + +Публичный API: + +- ``is_available() -> bool`` +- ``sha256(data: bytes) -> bytes`` +- ``sha256d(data: bytes) -> bytes`` +- ``BACKEND_NAME: str`` — для логов и бенчмарка. +""" + +from __future__ import annotations + +import ctypes +import ctypes.util +import hashlib +import logging +import sys +from typing import Optional + +logger = logging.getLogger("hope_hash") + + +# ─────────────────────── загрузка libcrypto ─────────────────────── + +# Кандидаты по платформам. Порядок важен: сначала более новые версии. +# Ничего не падает, если файла нет — просто пробуем следующий. +_CANDIDATES_WIN = ( + "libcrypto-3.dll", + "libcrypto-1_1.dll", + "libeay32.dll", # legacy OpenSSL 1.0.x (XP-эры) +) +_CANDIDATES_LINUX = ( + "libcrypto.so.3", + "libcrypto.so.1.1", + "libcrypto.so", +) +_CANDIDATES_MACOS = ( + "/opt/homebrew/lib/libcrypto.dylib", + "/usr/local/opt/openssl@3/lib/libcrypto.dylib", + "/usr/local/lib/libcrypto.dylib", + "/usr/lib/libcrypto.dylib", +) + + +def _try_load_libcrypto() -> tuple[Optional[ctypes.CDLL], str]: + """Пытается найти и загрузить libcrypto. Возвращает (CDLL|None, имя).""" + if sys.platform.startswith("win"): + candidates = _CANDIDATES_WIN + elif sys.platform == "darwin": + candidates = _CANDIDATES_MACOS + else: + candidates = _CANDIDATES_LINUX + + for name in candidates: + try: + lib = ctypes.CDLL(name) + except OSError: + continue + # Проверяем, что это реально OpenSSL: ищем EVP_sha256. + if hasattr(lib, "EVP_sha256"): + return lib, name + + # Последний fallback: ctypes.util.find_library('crypto') может найти + # системный путь, который мы не угадали (например, в нестандартном prefix). + found = ctypes.util.find_library("crypto") + if found: + try: + lib = ctypes.CDLL(found) + if hasattr(lib, "EVP_sha256"): + return lib, found + except OSError: + pass + + return None, "" + + +_LIB, _LIB_NAME = _try_load_libcrypto() + + +# Имя backend'а — для бенчмарка и логов. Пользователь может прочитать его, +# чтобы понимать, что реально используется. +if _LIB is not None: + # Обычно "libcrypto-3.dll" → "ctypes-libcrypto-3"; обрезаем расширение. + _short = _LIB_NAME.replace(".dll", "").replace(".so", "").replace(".dylib", "") + _short = _short.split("/")[-1].split("\\")[-1] + BACKEND_NAME: str = f"ctypes-{_short}" +else: + BACKEND_NAME = "hashlib-fallback" + + +# ─────────────────────── ctypes-обёртки EVP API ─────────────────────── + +if _LIB is not None: + # Сигнатуры. На большинстве платформ ctypes по умолчанию считает + # возвращаемое значение как ``int`` — для указателей это неправильно + # на 64-bit (truncation). Поэтому явно ставим restype = c_void_p. + + _LIB.EVP_MD_CTX_new.restype = ctypes.c_void_p + _LIB.EVP_MD_CTX_new.argtypes = [] + + _LIB.EVP_MD_CTX_free.restype = None + _LIB.EVP_MD_CTX_free.argtypes = [ctypes.c_void_p] + + _LIB.EVP_sha256.restype = ctypes.c_void_p + _LIB.EVP_sha256.argtypes = [] + + _LIB.EVP_DigestInit_ex.restype = ctypes.c_int + _LIB.EVP_DigestInit_ex.argtypes = [ + ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p, + ] + + _LIB.EVP_DigestUpdate.restype = ctypes.c_int + _LIB.EVP_DigestUpdate.argtypes = [ + ctypes.c_void_p, ctypes.c_void_p, ctypes.c_size_t, + ] + + _LIB.EVP_DigestFinal_ex.restype = ctypes.c_int + _LIB.EVP_DigestFinal_ex.argtypes = [ + ctypes.c_void_p, ctypes.c_char_p, ctypes.POINTER(ctypes.c_uint), + ] + + +def is_available() -> bool: + """True если ctypes-backend готов к использованию.""" + return _LIB is not None + + +def _sha256_native(data: bytes) -> bytes: + """Один SHA-256 через EVP API. Контекст одноразовый.""" + assert _LIB is not None # вызывается только когда is_available() + ctx = _LIB.EVP_MD_CTX_new() + if not ctx: + raise RuntimeError("[sha] EVP_MD_CTX_new вернул NULL") + try: + md = _LIB.EVP_sha256() + if _LIB.EVP_DigestInit_ex(ctx, md, None) != 1: + raise RuntimeError("[sha] EVP_DigestInit_ex failed") + if data: + if _LIB.EVP_DigestUpdate(ctx, data, len(data)) != 1: + raise RuntimeError("[sha] EVP_DigestUpdate failed") + out = ctypes.create_string_buffer(32) + out_len = ctypes.c_uint(0) + if _LIB.EVP_DigestFinal_ex(ctx, out, ctypes.byref(out_len)) != 1: + raise RuntimeError("[sha] EVP_DigestFinal_ex failed") + return out.raw[: out_len.value] + finally: + _LIB.EVP_MD_CTX_free(ctx) + + +def sha256(data: bytes) -> bytes: + """SHA-256 через ctypes-libcrypto, либо fallback на hashlib.""" + if _LIB is None: + return hashlib.sha256(data).digest() + return _sha256_native(data) + + +def sha256d(data: bytes) -> bytes: + """Double SHA-256 (SHA-256 поверх SHA-256). Используется в Bitcoin. + + Делает ровно два вызова EVP — без mid-state. Для бенчмарка это + «честный» baseline: сравниваем накладные расходы Python-вызова EVP + с накладными расходами hashlib. + """ + return sha256(sha256(data)) + + +# Логируем, что нашли — чтобы при первом запуске пользователь видел реальность. +if _LIB is not None: + logger.info("[sha] backend: %s (loaded %s)", BACKEND_NAME, _LIB_NAME) +else: + logger.info("[sha] libcrypto не найден, остаёмся на hashlib") diff --git a/src/hope_hash/solo.py b/src/hope_hash/solo.py new file mode 100644 index 0000000..1254af6 --- /dev/null +++ b/src/hope_hash/solo.py @@ -0,0 +1,637 @@ +"""Solo-режим через ``getblocktemplate`` (BIP-22/BIP-23). + +Архитектура: ``SoloClient`` имитирует публичную поверхность +``StratumClient``, чтобы ``mine()`` работал без изменений. Вместо +TCP/JSON-line-протокола поднимаем фоновый поток, который раз в +``poll_interval_s`` секунд тянет ``getblocktemplate`` через JSON-RPC +поверх ``urllib`` и обновляет ``current_job``. На find — собираем +полный сериализованный блок и шлём ``submitblock``. + +Coinbase + witness commitment (BIP-141) — самая муторная часть. Если +шаблон содержит ``coinbasetxn``, используем его как-есть. Если только +``coinbasevalue`` — собираем coinbase сами: +- input: 1 vin, prev_hash=0...0, prev_idx=0xffffffff, scriptSig = + height-push (BIP-34) + произвольные байты (наш «extranonce» — для + уникальности в пределах одного шаблона). +- outputs: 1 output на ``coinbasevalue`` сатоши на адрес майнера + (тут — простой OP_RETURN, потому что P2PKH-преобразование адреса + в скрипт — отдельная сложная задача с тестовыми векторами; для + учебного solo-режима, где шанс найти блок ≈ 0, OP_RETURN на + coinbasevalue допустим — заметка в README). При наличии + ``default_witness_commitment`` добавляется второй output с + OP_RETURN OP_PUSH36 0xaa21a9ed . + +Сериализация блока: 80-байтный header + varint(tx_count) + coinbase ++ остальные транзакции из шаблона ``transactions[*].data``. + +Внимание: этот код не предназначен для зарабатывания биткоинов. Он +учебный, и шанс реально найти блок на одном CPU ≈ 1 к 10^15 в день. +Цель — научить, как `getblocktemplate` работает изнутри. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import logging +import struct +import threading +import time +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any, Callable, Optional + +from .block import double_sha256, swap_words + +logger = logging.getLogger("hope_hash") + + +# ─────────────────────── низкоуровневые helpers ─────────────────────── + +def _varint(n: int) -> bytes: + """Bitcoin varint: 1/3/5/9 байт по диапазону. + + < 0xfd → 1 байт + <= 0xffff → 0xfd + uint16 LE + <= 0xffffffff → 0xfe + uint32 LE + иначе → 0xff + uint64 LE + """ + if n < 0: + raise ValueError(f"varint требует n>=0, получено {n}") + if n < 0xfd: + return bytes([n]) + if n <= 0xffff: + return b"\xfd" + struct.pack(" bytes: + """Собирает push-opcode для script: либо OP_PUSHBYTES_N, либо OP_PUSHDATA1/2. + + Для простой записи в scriptPubKey/scriptSig до 75 байт это просто + [len][data]. Дальше — отдельные опкоды; в solo-coinbase больше 75 + нам не нужно (height ≤ 4 байта + наш extranonce ≤ 8 байт). + """ + n = len(data) + if n < 0x4c: + return bytes([n]) + data + if n <= 0xff: + return b"\x4c" + bytes([n]) + data + if n <= 0xffff: + return b"\x4d" + struct.pack(" bytes: + """BIP-34: height в coinbase scriptSig как минимально-кодированное число. + + Bitcoin использует кодирование «знаковое minimum-encoded» — для + положительных high-byte<0x80 без расширения, для high-byte≥0x80 + добавляется нулевой байт. + """ + if height < 0: + raise ValueError(f"height >= 0 требуется, получено {height}") + if height == 0: + return _push_data(b"") + raw = b"" + h = height + while h: + raw += bytes([h & 0xff]) + h >>= 8 + # Если старший бит установлен — добавим 0x00, чтобы число читалось как положительное. + if raw[-1] & 0x80: + raw += b"\x00" + return _push_data(raw) + + +def build_coinbase( + *, + height: int, + coinbase_value: int, + output_script: bytes, + extranonce: bytes = b"", + witness_commitment: Optional[bytes] = None, +) -> bytes: + """Собирает сериализованную coinbase-транзакцию. + + Структура (legacy-формат, без segwit-маркера в самой coinbase — + witness commitment выносится в отдельный output, как требует BIP-141): + + :: + version (4 LE) = 1 + tx_in count varint = 1 + input: + prev_hash (32) = 0 + prev_index (4 LE) = 0xffffffff + scriptSig: push(height) + push(extranonce) + sequence (4 LE) = 0xffffffff + tx_out count varint = 1 или 2 + output #1: + value (8 LE) = coinbase_value + scriptPubKey: output_script + [output #2]: ← если witness_commitment задан + value (8 LE) = 0 + scriptPubKey: OP_RETURN OP_PUSH36 0xaa21a9ed + lock_time (4 LE) = 0 + """ + if coinbase_value < 0: + raise ValueError("coinbase_value должно быть >= 0") + version = struct.pack(" bytes: + """BIP-141: commitment = SHA256d(witness_root || witness_reserved_value). + + ``witness_root`` обычно достаётся из шаблона как ``default_witness_commitment``, + но в шаблонах bitcoind поле ``default_witness_commitment`` уже содержит ГОТОВЫЙ + scriptPubKey (OP_RETURN OP_PUSHBYTES_36 0xaa21a9ed ) — нам нужно достать + оттуда последние 32 байта. Эта функция нужна, когда мы строим commitment сами. + """ + if len(witness_root) != 32: + raise ValueError(f"witness_root ожидает 32 байта, получено {len(witness_root)}") + if len(witness_reserved_value) != 32: + raise ValueError("witness_reserved_value ожидает 32 байта") + return double_sha256(witness_root + witness_reserved_value) + + +def parse_default_witness_commitment(commitment_hex: str) -> bytes: + """Извлекает 32-байтный hash из готового scriptPubKey шаблона. + + bitcoind возвращает ``default_witness_commitment`` как hex-строку + готового скрипта: ``6a24aa21a9ed<32-byte-hash>``. Префикс 6 байт + (OP_RETURN + push + magic), последние 32 — собственно commitment. + """ + raw = bytes.fromhex(commitment_hex) + if len(raw) < 38 or raw[:6] != b"\x6a\x24\xaa\x21\xa9\xed": + raise ValueError(f"неожиданный формат default_witness_commitment: {commitment_hex[:32]}...") + return raw[6:38] + + +def serialize_block( + header_80: bytes, + coinbase_tx: bytes, + other_txs_hex: list[str], +) -> bytes: + """Сериализует полный блок: header + varint(tx_count) + coinbase + остальные tx. + + ``other_txs_hex`` — это поле ``transactions[*].data`` из шаблона, + где каждый элемент — уже сериализованная транзакция в hex. + """ + if len(header_80) != 80: + raise ValueError(f"header должен быть 80 байт, получено {len(header_80)}") + tx_count = 1 + len(other_txs_hex) + body = _varint(tx_count) + coinbase_tx + for tx_hex in other_txs_hex: + body += bytes.fromhex(tx_hex) + return header_80 + body + + +def compute_merkle_root_from_txids(txids: list[bytes]) -> bytes: + """Считает merkle root из списка txid (32 байта каждый, internal byte order). + + Алгоритм Bitcoin: парами хешируем double_sha256, при нечётном числе + дублируем последний. Повторяем, пока не останется один. + """ + if not txids: + raise ValueError("список txid не может быть пустым") + layer = list(txids) + while len(layer) > 1: + if len(layer) % 2 == 1: + layer.append(layer[-1]) + next_layer = [] + for i in range(0, len(layer), 2): + next_layer.append(double_sha256(layer[i] + layer[i + 1])) + layer = next_layer + return layer[0] + + +# ─────────────────────── JSON-RPC клиент ─────────────────────── + +class RPCError(Exception): + """Ошибка JSON-RPC от bitcoind. ``code`` — числовой код из ответа.""" + + def __init__(self, code: int, message: str) -> None: + super().__init__(f"RPC error {code}: {message}") + self.code = code + self.message = message + + +class BitcoinRPC: + """Минимальный JSON-RPC клиент для bitcoind через urllib. + + Аутентификация: либо cookie-файл (``$DATADIR/.cookie`` с содержимым + ``user:pass``), либо явные user/pass. Cookie — стандартный путь + для локального майнинга, ``rpcauth`` — для удалённых. + """ + + def __init__( + self, + url: str, + cookie_path: Optional[Path] = None, + username: Optional[str] = None, + password: Optional[str] = None, + timeout: float = 10.0, + ) -> None: + self.url = url.rstrip("/") + self.timeout = float(timeout) + # Cookie wins, чтобы локальная разработка не требовала ручной настройки. + if cookie_path is not None: + cred = Path(cookie_path).read_text(encoding="utf-8").strip() + self._auth = base64.b64encode(cred.encode()).decode() + elif username and password: + self._auth = base64.b64encode(f"{username}:{password}".encode()).decode() + else: + raise ValueError("Нужен cookie_path ИЛИ (username и password)") + self._req_id = 0 + + def call(self, method: str, params: Optional[list[Any]] = None) -> Any: + """Отправляет один JSON-RPC вызов. Бросает RPCError на ошибку bitcoind.""" + self._req_id += 1 + body = json.dumps({ + "jsonrpc": "1.0", + "id": self._req_id, + "method": method, + "params": params or [], + }).encode("utf-8") + req = urllib.request.Request( + self.url, data=body, + headers={ + "Content-Type": "application/json", + "Authorization": f"Basic {self._auth}", + }, + method="POST", + ) + with urllib.request.urlopen(req, timeout=self.timeout) as resp: + data = json.loads(resp.read().decode("utf-8")) + if data.get("error"): + err = data["error"] + raise RPCError(int(err.get("code", -1)), str(err.get("message", "unknown"))) + return data.get("result") + + +# ─────────────────────── SoloClient ─────────────────────── + +class SoloClient: + """Имитация ``StratumClient`` для solo-mode. + + Поверхность, которую читает ``mine()``: + - ``current_job: dict`` + - ``extranonce1: str``, ``extranonce2_size: int`` + - ``difficulty: float`` + - ``job_lock: threading.Lock`` + - ``stop_event: threading.Event`` + - ``submit(job_id, extranonce2, ntime, nonce_hex) -> int`` + - ``on_share_result: Callable[[int, bool], None] | None`` + - ``connect()``, ``subscribe_and_authorize()``, ``reader_loop()``, + ``close()``, ``host``, ``port``, ``sock``. + + Семантика: + - ``connect()`` тянет первый шаблон. + - ``subscribe_and_authorize()`` no-op. + - ``reader_loop()`` периодически дёргает ``getblocktemplate``, + обновляет ``current_job`` если шаблон сменился. + - ``submit()`` синхронно вызывает ``submitblock``; ответ диспатчится + через ``on_share_result``. + """ + + # extranonce2 формально = 0 байт (никакой Stratum-магии), но mine() + # ждёт >0 размер, иначе extranonce2-counter wrap'нется в первой же + # итерации. Используем 4 байта — этого с запасом для одного template. + EXTRANONCE2_SIZE = 4 + + def __init__( + self, + rpc: BitcoinRPC, + btc_address: str, + worker_name: str = "solo", + stop_event: Optional[threading.Event] = None, + poll_interval_s: float = 5.0, + ) -> None: + self.rpc = rpc + self.username = f"{btc_address}.{worker_name}" + self.btc_address = btc_address + self.host = "(solo)" + self.port = 0 + self.sock: Optional[object] = None # для совместимости с healthz + self.buf = b"" + self.req_id = 0 + self.extranonce1 = "" + self.extranonce2_size = self.EXTRANONCE2_SIZE + self.difficulty = 1.0 + self.current_job: Optional[dict[str, Any]] = None + self.job_lock = threading.Lock() + self.stop_event = stop_event if stop_event is not None else threading.Event() + self.suggest_diff: Optional[float] = None + self.on_share_result: Optional[Callable[[int, bool], None]] = None + self._submit_lock = threading.Lock() + self._submit_req_ids: set[int] = set() + self.poll_interval_s = float(poll_interval_s) + # Шаблон храним целиком: при submitblock нам нужны все остальные tx. + self._last_template: Optional[dict[str, Any]] = None + self._template_lock = threading.Lock() + + def connect(self) -> None: + """Тянет первый шаблон. Бросает наверх — supervisor решит про backoff.""" + self._fetch_template() + # ``sock`` — sentinel для healthz: «коннект жив». В solo-режиме + # коннекта нет, но если RPC отвечает — считаем за «жив». + self.sock = object() + logger.info("[solo] подключено к %s", self.rpc.url) + + def subscribe_and_authorize(self) -> None: + """No-op: в solo-mode нет mining.subscribe/authorize.""" + return + + def suggest_difficulty(self, diff: float) -> None: # noqa: D401 + """No-op: в solo difficulty диктуется шаблоном (target/bits).""" + return + + def reader_loop(self) -> None: + """Фоновый цикл polling ``getblocktemplate``. + + Важно: в Stratum reader_loop живёт пока стоит соединение; здесь + мы крутим, пока не выставлен ``stop_event``. На любой ошибке RPC + логируем и ретраим — не валим супервизор. + """ + while not self.stop_event.is_set(): + t = time.time() + try: + self._fetch_template() + except (urllib.error.URLError, RPCError, OSError, json.JSONDecodeError) as e: + logger.warning("[solo] getblocktemplate failed: %s", e) + # На ошибке RPC сокет считаем умершим, чтобы healthz это поймал. + self.sock = None + return # выходим, supervisor переподключится + # Спим до следующего тика, прерываемся на stop_event. + elapsed = time.time() - t + wait_s = max(0.0, self.poll_interval_s - elapsed) + if self.stop_event.wait(timeout=wait_s): + return + + def submit(self, job_id: str, extranonce2: str, ntime: str, nonce_hex: str) -> int: + """Серилизует полный блок и шлёт ``submitblock``. + + Возвращает синтетический req_id; ответ сразу диспатчится через + ``on_share_result`` (без задержки, как в Stratum). Это OK, + потому что seqno нужен только чтобы матчить submit↔ack. + """ + with self._template_lock: + tmpl = self._last_template + if tmpl is None: + raise RuntimeError("solo: нет шаблона для submit") + + # Собираем header заново (не доверяем тому, что было в job: ntime + # мог сдвинуться). job_id у нас — prevhash[:16] для совместимости. + coinbase_tx = self._build_coinbase_for_template(tmpl, extranonce2) + coinbase_hash = double_sha256(coinbase_tx) + merkle_root = self._merkle_root_with_coinbase(coinbase_hash, tmpl) + header = self._assemble_header( + tmpl, merkle_root, ntime_le=bytes.fromhex(ntime)[::-1], nonce_hex_be=nonce_hex, + ) + + other_txs_hex = [tx["data"] for tx in tmpl.get("transactions", [])] + block_hex = serialize_block(header, coinbase_tx, other_txs_hex).hex() + + self.req_id += 1 + my_id = self.req_id + with self._submit_lock: + self._submit_req_ids.add(my_id) + + try: + result = self.rpc.call("submitblock", [block_hex]) + # bitcoind: None = success, иначе строковая причина отказа. + accepted = result is None + if accepted: + logger.info("[solo] *** БЛОК ПРИНЯТ *** id=%d", my_id) + else: + logger.warning("[solo] submitblock отклонён id=%d: %s", my_id, result) + except (urllib.error.URLError, RPCError, OSError) as e: + logger.warning("[solo] submitblock сетевой провал: %s", e) + accepted = False + + with self._submit_lock: + self._submit_req_ids.discard(my_id) + if self.on_share_result is not None: + self.on_share_result(my_id, accepted) + return my_id + + def close(self) -> None: + """В solo-режиме ничего не закрываем — RPC stateless.""" + self.sock = None + + # ─────────────── внутренние ─────────────── + + def _fetch_template(self) -> None: + """Тянет шаблон, обновляет current_job если он сменился.""" + # rules=["segwit"] — нужен, иначе bitcoind отдаст шаблон без + # default_witness_commitment, и блок будет невалидным после segwit. + tmpl = self.rpc.call("getblocktemplate", [{"rules": ["segwit"]}]) + with self._template_lock: + self._last_template = tmpl + # job_id выбираем как prevhash[:16] — гарантированно уникален между шаблонами. + job_id = tmpl["previousblockhash"][:16] + with self.job_lock: + old = self.current_job + if old is None or old["job_id"] != job_id: + self.current_job = self._template_to_job(tmpl, job_id) + # Difficulty считаем из bits: target = bits → diff; + # для учебного режима используем фиксированный 1.0, + # mine() использует target из current_job. + self.difficulty = 1.0 + logger.info("[solo] новый шаблон job_id=%s height=%d txs=%d", + job_id, tmpl.get("height", 0), + len(tmpl.get("transactions", []))) + + def _template_to_job(self, tmpl: dict[str, Any], job_id: str) -> dict[str, Any]: + """Конвертирует шаблон bitcoind в job-словарь, совместимый с mine().""" + # mine() ждёт коинб-сборку из coinb1+extranonce1+extranonce2+coinb2, + # но в solo нам extranonce1=пустая строка, extranonce2 — наш счётчик. + # Чтобы переиспользовать _build_header_base, кладём весь coinbase + # «вокруг» extranonce: coinb1 = всё до extranonce, coinb2 = всё после. + # Однако структура solo-coinbase зависит от extranonce2 → строим + # «шаблонный» coinbase_skeleton с placeholder-extranonce и режем его. + placeholder = b"\x00" * 4 # 4 байта — наш extranonce2 size + coinbase_skel = self._build_coinbase_for_template(tmpl, placeholder.hex()) + # Найдём placeholder в скелете. Чтобы избежать ложных срабатываний, + # используем уникальный паттерн вместо нулей. + marker = bytes.fromhex("deadbeef") + coinbase_skel_marked = self._build_coinbase_for_template(tmpl, marker.hex()) + idx = coinbase_skel_marked.find(marker) + if idx < 0: + raise RuntimeError("[solo] не нашёл extranonce-marker в coinbase") + coinb1 = coinbase_skel_marked[:idx].hex() + coinb2 = coinbase_skel_marked[idx + len(marker):].hex() + + # merkle_branch для solo: рассчитываем ветви от coinbase до root, + # используя txid (НЕ wtxid) остальных транзакций. mine() + # использует ту же `build_merkle_root(coinbase_hash, branches)`. + txids = [bytes.fromhex(tx["txid"])[::-1] for tx in tmpl.get("transactions", [])] + branch = self._merkle_branch_from_txids(txids) + + # Stratum-style формат: prevhash word-swap'ed; в solo-режиме мы + # шаблон отдаём «как есть», а в `_build_header_base` всё равно + # будет swap_words применён → значит здесь нужен ОБРАТНЫЙ swap, + # чтобы compose был корректным. Делаем raw prevhash и в miner.py + # _build_header_base вызовет swap_words → получим как надо. + # bitcoind отдаёт previousblockhash big-endian hex (display), нам + # нужен LE для header. swap_words конвертирует «stratum-формат» + # обратно в LE; чтобы совпало, передадим prevhash в stratum-формате. + prev_be = bytes.fromhex(tmpl["previousblockhash"]) + # Stratum prev = big-endian с word-swap'ом по 4 байта. + # Inverse: байты берём как есть (display=BE), а внутри 4-байтных + # групп переставляем little-endian → swap_words восстановит BE. + prev_stratum_hex = b"".join( + prev_be[i:i+4][::-1] for i in range(0, 32, 4) + ).hex() + + # version/bits/curtime приходят как int → конвертируем в hex BE-строку, + # такого же формата как stratum-notify (там пул отдаёт hex BE). + version_hex = struct.pack(">I", int(tmpl["version"])).hex() + nbits_hex = tmpl["bits"] # уже hex BE + ntime_hex = struct.pack(">I", int(tmpl["curtime"])).hex() + + return { + "job_id": job_id, + "prevhash": prev_stratum_hex, + "coinb1": coinb1, + "coinb2": coinb2, + "merkle_branch": [b.hex() for b in branch], + "version": version_hex, + "nbits": nbits_hex, + "ntime": ntime_hex, + "clean": True, + } + + def _build_coinbase_for_template( + self, + tmpl: dict[str, Any], + extranonce2_hex: str, + ) -> bytes: + """Сборка coinbase под текущий шаблон. Принимает extranonce2 как hex.""" + height = int(tmpl.get("height", 0)) + coinbase_value = int(tmpl["coinbasevalue"]) + # OP_RETURN-output на coinbasevalue: учебный код, мы не отвлекаемся + # на decode-bech32. Реальный соло-майнер сделал бы P2WPKH/P2PKH. + # Но так блок останется валидным (output может быть unspendable). + output_script = b"\x6a" # OP_RETURN — никто не сможет потратить + extranonce = bytes.fromhex(extranonce2_hex) if extranonce2_hex else b"" + + wc_hex = tmpl.get("default_witness_commitment") + wc: Optional[bytes] = None + if wc_hex: + try: + wc = parse_default_witness_commitment(wc_hex) + except ValueError as e: + logger.warning("[solo] не разобрал default_witness_commitment: %s", e) + wc = None + + return build_coinbase( + height=height, + coinbase_value=coinbase_value, + output_script=output_script, + extranonce=extranonce, + witness_commitment=wc, + ) + + def _merkle_branch_from_txids(self, txids: list[bytes]) -> list[bytes]: + """Considers merkle branch для coinbase (она всегда позиция 0). + + Возвращает список хешей-«соседей» по пути от coinbase к корню, + по одному на уровень. Точно совпадает с интерфейсом, который + отдаёт Stratum-пул в ``mining.notify[merkle_branch]``. + """ + if not txids: + return [] + branch: list[bytes] = [] + # Виртуальный «coinbase» сидит на позиции 0; считаем без него, + # держим список «остальные tx», на каждом уровне берём первого. + layer = list(txids) + while True: + # Соседом coinbase на этом уровне является layer[0]. + branch.append(layer[0]) + # Поднимаемся на уровень выше: считаем хеши пар, начиная со 2-го индекса. + # Coinbase + layer[0] на этом уровне — это «новый coinbase», + # который нам не нужен явно (mine() сам его сложит). + rest = layer[1:] + if len(rest) == 0: + break + if len(rest) % 2 == 1: + rest.append(rest[-1]) + next_layer = [] + for i in range(0, len(rest), 2): + next_layer.append(double_sha256(rest[i] + rest[i + 1])) + layer = next_layer + return branch + + def _merkle_root_with_coinbase( + self, + coinbase_hash: bytes, + tmpl: dict[str, Any], + ) -> bytes: + """Считает финальный merkle_root, имея coinbase_hash и шаблон.""" + txids = [bytes.fromhex(tx["txid"])[::-1] for tx in tmpl.get("transactions", [])] + if not txids: + return coinbase_hash + branch = self._merkle_branch_from_txids(txids) + h = coinbase_hash + for b in branch: + h = double_sha256(h + b) + return h + + def _assemble_header( + self, + tmpl: dict[str, Any], + merkle_root: bytes, + ntime_le: bytes, + nonce_hex_be: bytes | str, + ) -> bytes: + """Сборка 80-байтного header из шаблона + найденного merkle/nonce.""" + version = struct.pack("I", n).hex() → BE. + # В header он должен быть LE. + nonce_le = nonce_be[::-1] + return version + prev_le + merkle_root + ntime_le + nbits_le + nonce_le diff --git a/src/hope_hash/stratum.py b/src/hope_hash/stratum.py index 995dffd..fcbc735 100644 --- a/src/hope_hash/stratum.py +++ b/src/hope_hash/stratum.py @@ -49,6 +49,26 @@ def connect(self) -> None: self.sock = socket.create_connection((self.host, self.port), timeout=30) logger.info(f"[net] подключён к {self.host}:{self.port}") + def set_endpoint(self, host: str, port: int) -> None: + """Перенацеливает клиента на другой пул без пересоздания объекта. + + Сохраняет ``on_share_result``, ``stop_event``, ``suggest_diff``, + ``username`` и локи. Сбрасывает буфер/req_id/job — после connect() + пул выдаст новые данные, а старые могут запутать reader_loop. + + Используется multi-pool failover'ом из supervisor_loop(). + """ + self.host = host + self.port = int(port) + self.buf = b"" + self.req_id = 0 + with self._submit_lock: + self._submit_req_ids.clear() + with self.job_lock: + self.current_job = None + self.extranonce1 = "" + self.extranonce2_size = 0 + def _send(self, method: str, params: list[Any]) -> int: if self.sock is None: raise OSError("сокет не подключён") diff --git a/src/hope_hash/tui.py b/src/hope_hash/tui.py index 75b5c4a..8b6c3a9 100644 --- a/src/hope_hash/tui.py +++ b/src/hope_hash/tui.py @@ -105,6 +105,15 @@ def record_share(self, accepted: Optional[bool] = None) -> None: else: self._snap.shares_rejected += 1 + def update_pool(self, pool_url: str) -> None: + """Меняет текущий pool URL (для multi-pool failover). + + TUI читает это поле каждый кадр — обновление видно сразу + после ротации в supervisor_loop. + """ + with self._lock: + self._snap.pool_url = str(pool_url) + def format_rate(rate: float) -> str: """Человекочитаемый хешрейт. Копия из miner._format_rate (без импорта).""" diff --git a/tests/test_bench_backends.py b/tests/test_bench_backends.py new file mode 100644 index 0000000..dd81b68 --- /dev/null +++ b/tests/test_bench_backends.py @@ -0,0 +1,58 @@ +"""Тесты бенчмарка с несколькими backend'ами.""" + +import unittest + +from hope_hash import sha_native +from hope_hash.bench import available_backends, run_benchmark, run_benchmark_all_backends + + +class TestAvailableBackends(unittest.TestCase): + def test_hashlib_always_present(self): + backends = available_backends() + self.assertIn("hashlib", backends) + + def test_hashlib_first(self): + # Baseline должен быть первым — это convention для отображения. + self.assertEqual(available_backends()[0], "hashlib") + + def test_ctypes_present_iff_available(self): + backends = available_backends() + if sha_native.is_available(): + self.assertIn("ctypes", backends) + else: + self.assertNotIn("ctypes", backends) + + +class TestRunBenchmarkBackend(unittest.TestCase): + def test_hashlib_runs(self): + # Очень короткий прогон, лишь бы не упало. + result = run_benchmark(duration_s=0.5, n_workers=1, sha_backend="hashlib") + self.assertGreater(result.total_hashes, 0) + self.assertGreater(result.hashrate_hps, 0) + + def test_ctypes_runs_if_available(self): + if not sha_native.is_available(): + self.skipTest("libcrypto не загружен") + result = run_benchmark(duration_s=0.5, n_workers=1, sha_backend="ctypes") + self.assertGreater(result.total_hashes, 0) + + +class TestRunBenchmarkAllBackends(unittest.TestCase): + def test_returns_dict_with_hashlib(self): + results = run_benchmark_all_backends(duration_s=0.5, n_workers=1) + self.assertIn("hashlib", results) + + def test_results_have_positive_hashrate(self): + results = run_benchmark_all_backends(duration_s=0.5, n_workers=1) + for name, res in results.items(): + self.assertGreater(res.hashrate_hps, 0, f"{name} зирорейт") + self.assertGreater(res.total_hashes, 0, f"{name} ноль хешей") + + def test_includes_ctypes_if_available(self): + results = run_benchmark_all_backends(duration_s=0.5, n_workers=1) + if sha_native.is_available(): + self.assertIn("ctypes", results) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_pools.py b/tests/test_pools.py new file mode 100644 index 0000000..8a5816c --- /dev/null +++ b/tests/test_pools.py @@ -0,0 +1,145 @@ +"""Тесты PoolList: round-robin failover, дедуп, парсинг spec.""" + +import unittest + +from hope_hash.pools import PoolList, parse_pool_spec + + +class TestParsePoolSpec(unittest.TestCase): + def test_host_port(self): + self.assertEqual(parse_pool_spec("foo.example.com:1234"), ("foo.example.com", 1234)) + + def test_host_only_uses_default_port(self): + self.assertEqual(parse_pool_spec("foo.example.com"), ("foo.example.com", 3333)) + + def test_host_only_custom_default(self): + self.assertEqual(parse_pool_spec("foo", default_port=4444), ("foo", 4444)) + + def test_empty_raises(self): + with self.assertRaises(ValueError): + parse_pool_spec("") + + def test_whitespace_raises(self): + with self.assertRaises(ValueError): + parse_pool_spec(" ") + + def test_invalid_port_raises(self): + with self.assertRaises(ValueError): + parse_pool_spec("foo:notaport") + + def test_port_out_of_range(self): + with self.assertRaises(ValueError): + parse_pool_spec("foo:99999") + with self.assertRaises(ValueError): + parse_pool_spec("foo:0") + + def test_no_host_raises(self): + with self.assertRaises(ValueError): + parse_pool_spec(":3333") + + +class TestPoolListBasics(unittest.TestCase): + def test_empty_raises(self): + with self.assertRaises(ValueError): + PoolList([]) + + def test_zero_threshold_raises(self): + with self.assertRaises(ValueError): + PoolList([("a", 1)], rotate_after_failures=0) + + def test_single_pool(self): + p = PoolList([("a", 1)]) + self.assertEqual(p.current(), ("a", 1)) + self.assertEqual(p.size, 1) + self.assertEqual(p.current_url(), "a:1") + + def test_dedup(self): + # Дубль (case-insensitive) должен игнорироваться: ротация по + # одному и тому же пулу бессмысленна. + p = PoolList([("a", 1), ("A", 1), ("b", 2)]) + self.assertEqual(p.size, 2) + self.assertEqual(p.all_endpoints(), [("a", 1), ("b", 2)]) + + +class TestPoolListRotation(unittest.TestCase): + def test_no_rotation_below_threshold(self): + p = PoolList([("a", 1), ("b", 2)], rotate_after_failures=3) + for _ in range(2): + self.assertFalse(p.mark_failed()) + self.assertEqual(p.current(), ("a", 1)) + + def test_rotates_at_threshold(self): + p = PoolList([("a", 1), ("b", 2)], rotate_after_failures=2) + self.assertFalse(p.mark_failed()) # 1 + self.assertTrue(p.mark_failed()) # 2 → ротация + self.assertEqual(p.current(), ("b", 2)) + + def test_wrap_around(self): + p = PoolList([("a", 1), ("b", 2), ("c", 3)], rotate_after_failures=1) + self.assertTrue(p.mark_failed()) + self.assertEqual(p.current(), ("b", 2)) + self.assertTrue(p.mark_failed()) + self.assertEqual(p.current(), ("c", 3)) + self.assertTrue(p.mark_failed()) + self.assertEqual(p.current(), ("a", 1)) # wrap + + def test_single_pool_rotates_to_self(self): + p = PoolList([("a", 1)], rotate_after_failures=1) + self.assertTrue(p.mark_failed()) + self.assertEqual(p.current(), ("a", 1)) + + def test_mark_success_resets_failures(self): + p = PoolList([("a", 1), ("b", 2)], rotate_after_failures=3) + p.mark_failed() + p.mark_failed() + self.assertEqual(p.failures(), 2) + p.mark_success() + self.assertEqual(p.failures(), 0) + # И счётчик ротаций тоже: + self.assertFalse(p.full_cycle_failed()) + + def test_full_cycle_failed_after_all_rotations(self): + p = PoolList([("a", 1), ("b", 2)], rotate_after_failures=1) + self.assertFalse(p.full_cycle_failed()) + p.mark_failed() # rotate to b + self.assertFalse(p.full_cycle_failed()) + p.mark_failed() # rotate to a (wrap), 2 ротации = size + self.assertTrue(p.full_cycle_failed()) + + def test_full_cycle_reset_after_success(self): + p = PoolList([("a", 1), ("b", 2)], rotate_after_failures=1) + p.mark_failed() + p.mark_failed() + self.assertTrue(p.full_cycle_failed()) + p.mark_success() + self.assertFalse(p.full_cycle_failed()) + + def test_reset_round_does_not_change_index(self): + p = PoolList([("a", 1), ("b", 2)], rotate_after_failures=1) + p.mark_failed() + idx_before = p.current() + p.reset_round() + self.assertEqual(p.current(), idx_before) + self.assertFalse(p.full_cycle_failed()) + + def test_manual_rotate(self): + p = PoolList([("a", 1), ("b", 2)]) + new = p.rotate() + self.assertEqual(new, ("b", 2)) + self.assertEqual(p.current(), ("b", 2)) + + +class TestPoolListThreadSafety(unittest.TestCase): + """Тут не гоняем гонки массово — просто проверяем, что lock не deadlock'ится.""" + + def test_nested_calls_dont_deadlock(self): + # rotate() вызывает _rotate_locked() под self._lock; mark_failed() тоже. + # Если лок не RLock — будет deadlock на самовызове. + p = PoolList([("a", 1), ("b", 2)], rotate_after_failures=1) + for _ in range(10): + p.mark_failed() + self.assertIn(p.current(), [("a", 1), ("b", 2)]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sha_native.py b/tests/test_sha_native.py new file mode 100644 index 0000000..071ff74 --- /dev/null +++ b/tests/test_sha_native.py @@ -0,0 +1,92 @@ +"""Тесты ctypes-backend SHA-256. + +Проверяем: +- ``is_available()`` не падает (на машинах без libcrypto просто False). +- При наличии backend — паритет с hashlib на эталонных векторах. +- ``BACKEND_NAME`` отражает реальность. + +Если libcrypto не загрузилось — тесты на паритет skip'аются. +""" + +import hashlib +import unittest + +from hope_hash import sha_native + + +class TestIsAvailable(unittest.TestCase): + def test_returns_bool(self): + self.assertIsInstance(sha_native.is_available(), bool) + + def test_backend_name_is_string(self): + self.assertIsInstance(sha_native.BACKEND_NAME, str) + self.assertGreater(len(sha_native.BACKEND_NAME), 0) + + def test_backend_name_consistent_with_availability(self): + if sha_native.is_available(): + self.assertTrue(sha_native.BACKEND_NAME.startswith("ctypes-")) + else: + self.assertEqual(sha_native.BACKEND_NAME, "hashlib-fallback") + + +class TestSha256Parity(unittest.TestCase): + """Когда ctypes-backend доступен — он должен давать ровно те же байты, что hashlib.""" + + def test_empty(self): + self.assertEqual(sha_native.sha256(b""), hashlib.sha256(b"").digest()) + + def test_hello(self): + self.assertEqual(sha_native.sha256(b"hello"), + hashlib.sha256(b"hello").digest()) + + def test_long(self): + data = b"x" * 10000 + self.assertEqual(sha_native.sha256(data), + hashlib.sha256(data).digest()) + + def test_binary(self): + data = bytes(range(256)) + self.assertEqual(sha_native.sha256(data), + hashlib.sha256(data).digest()) + + def test_single_byte(self): + for i in (0, 1, 127, 128, 255): + data = bytes([i]) + self.assertEqual(sha_native.sha256(data), + hashlib.sha256(data).digest()) + + +class TestSha256dParity(unittest.TestCase): + def test_empty(self): + expected = hashlib.sha256(hashlib.sha256(b"").digest()).digest() + self.assertEqual(sha_native.sha256d(b""), expected) + + def test_hello(self): + expected = hashlib.sha256(hashlib.sha256(b"hello").digest()).digest() + self.assertEqual(sha_native.sha256d(b"hello"), expected) + + def test_block_header_80_bytes(self): + # Реалистичные данные: 80 байт block header + version = b"\x01\x00\x00\x00" + prevhash = bytes.fromhex( + "0e3e2357e806b6cdb1f70b54c3a3a17b6714ee1f0e68bebb44a74b1efd512098" + ) + merkle = b"\xaa" * 32 + ntime = b"\x29\xab\x5f\x49" + nbits = b"\xff\xff\x00\x1d" + nonce = b"\x01\x00\x00\x00" + header = version + prevhash + merkle + ntime + nbits + nonce + self.assertEqual(len(header), 80) + expected = hashlib.sha256(hashlib.sha256(header).digest()).digest() + self.assertEqual(sha_native.sha256d(header), expected) + + +class TestSha256ResultLength(unittest.TestCase): + def test_always_32_bytes(self): + for n in (0, 1, 63, 64, 65, 1000): + self.assertEqual(len(sha_native.sha256(b"x" * n)), 32) + self.assertEqual(len(sha_native.sha256d(b"x" * n)), 32) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_solo.py b/tests/test_solo.py new file mode 100644 index 0000000..8bf08ca --- /dev/null +++ b/tests/test_solo.py @@ -0,0 +1,373 @@ +"""Тесты solo-mode: build_coinbase, witness commitment, serialize_block, FakeRPC.""" + +import hashlib +import json +import struct +import threading +import unittest +from unittest.mock import patch + +from hope_hash.block import double_sha256 +from hope_hash.solo import ( + BitcoinRPC, + RPCError, + SoloClient, + _push_data, + _serialize_height, + _varint, + build_coinbase, + compute_merkle_root_from_txids, + compute_witness_commitment, + parse_default_witness_commitment, + serialize_block, +) + + +# ─────────────────────── varint ─────────────────────── + +class TestVarint(unittest.TestCase): + def test_small(self): + self.assertEqual(_varint(0), b"\x00") + self.assertEqual(_varint(1), b"\x01") + self.assertEqual(_varint(0xfc), b"\xfc") + + def test_uint16(self): + self.assertEqual(_varint(0xfd), b"\xfd\xfd\x00") + self.assertEqual(_varint(0xffff), b"\xfd\xff\xff") + + def test_uint32(self): + self.assertEqual(_varint(0x10000), b"\xfe\x00\x00\x01\x00") + + def test_uint64(self): + self.assertEqual(_varint(0x100000000), b"\xff\x00\x00\x00\x00\x01\x00\x00\x00") + + def test_negative_raises(self): + with self.assertRaises(ValueError): + _varint(-1) + + +# ─────────────────────── push & height ─────────────────────── + +class TestPushAndHeight(unittest.TestCase): + def test_push_short(self): + # < 0x4c — просто [len][data] + self.assertEqual(_push_data(b"abc"), b"\x03abc") + + def test_push_empty(self): + self.assertEqual(_push_data(b""), b"\x00") + + def test_push_data1(self): + # 0x4c..0xff → OP_PUSHDATA1 + data = b"x" * 100 + out = _push_data(data) + self.assertEqual(out[0], 0x4c) + self.assertEqual(out[1], 100) + self.assertEqual(out[2:], data) + + def test_height_zero(self): + self.assertEqual(_serialize_height(0), b"\x00") + + def test_height_small(self): + # 1 → push of [01] + self.assertEqual(_serialize_height(1), b"\x01\x01") + self.assertEqual(_serialize_height(127), b"\x01\x7f") + + def test_height_extra_zero_when_high_bit_set(self): + # 128: high bit установлен, нужен дополнительный 0x00 для positive + self.assertEqual(_serialize_height(128), b"\x02\x80\x00") + + def test_height_typical(self): + # 800000 → bytes(LE) = [0x00, 0x35, 0x0c]; high byte 0x0c < 0x80, без extra zero. + self.assertEqual(_serialize_height(800000), b"\x03\x00\x35\x0c") + + +# ─────────────────────── coinbase ─────────────────────── + +class TestBuildCoinbase(unittest.TestCase): + def test_basic_structure(self): + cb = build_coinbase( + height=1, + coinbase_value=5_000_000_000, + output_script=b"\x6a", # OP_RETURN + ) + # Минимум: 4 (version) + 1 (in_count) + 32+4+1+(>=2)+4 (input) + # + 1 (out_count) + 8+1+1 (output) + 4 (locktime) ~= 60 + self.assertGreater(len(cb), 50) + # version = 1 LE + self.assertEqual(cb[:4], b"\x01\x00\x00\x00") + # in_count = 1 + self.assertEqual(cb[4], 1) + # prev_hash = 0 + self.assertEqual(cb[5:37], b"\x00" * 32) + # prev_idx = 0xffffffff + self.assertEqual(cb[37:41], b"\xff\xff\xff\xff") + # locktime = 0 + self.assertEqual(cb[-4:], b"\x00\x00\x00\x00") + + def test_with_extranonce(self): + cb_a = build_coinbase( + height=1, coinbase_value=0, output_script=b"\x6a", + extranonce=b"AAAA", + ) + cb_b = build_coinbase( + height=1, coinbase_value=0, output_script=b"\x6a", + extranonce=b"BBBB", + ) + # Разный extranonce → разный coinbase (это и есть mining-уникальность) + self.assertNotEqual(cb_a, cb_b) + self.assertEqual(len(cb_a), len(cb_b)) + + def test_with_witness_commitment_adds_second_output(self): + wc = b"\x11" * 32 + cb_no = build_coinbase(height=1, coinbase_value=0, output_script=b"\x6a") + cb_yes = build_coinbase( + height=1, coinbase_value=0, output_script=b"\x6a", + witness_commitment=wc, + ) + # Добавляется второй output: 8 (value=0) + 1 (script_len=38) + 38 (script) = 47 байт + self.assertEqual(len(cb_yes) - len(cb_no), 47) + # Проверим, что commitment-байты есть в выходе + self.assertIn(wc, cb_yes) + self.assertIn(b"\xaa\x21\xa9\xed", cb_yes) + + def test_witness_commitment_wrong_size_raises(self): + with self.assertRaises(ValueError): + build_coinbase( + height=1, coinbase_value=0, output_script=b"\x6a", + witness_commitment=b"\x11" * 16, # не 32 + ) + + def test_negative_value_raises(self): + with self.assertRaises(ValueError): + build_coinbase(height=1, coinbase_value=-1, output_script=b"\x6a") + + +# ─────────────────────── witness commitment ─────────────────────── + +class TestWitnessCommitment(unittest.TestCase): + def test_compute_matches_double_sha256(self): + root = b"\x12" * 32 + reserved = b"\x00" * 32 + expected = double_sha256(root + reserved) + self.assertEqual(compute_witness_commitment(witness_root=root), expected) + + def test_compute_custom_reserved(self): + root = b"\x12" * 32 + reserved = b"\xab" * 32 + expected = double_sha256(root + reserved) + self.assertEqual( + compute_witness_commitment(witness_root=root, witness_reserved_value=reserved), + expected, + ) + + def test_wrong_root_size_raises(self): + with self.assertRaises(ValueError): + compute_witness_commitment(witness_root=b"\x12" * 16) + + def test_wrong_reserved_size_raises(self): + with self.assertRaises(ValueError): + compute_witness_commitment( + witness_root=b"\x12" * 32, witness_reserved_value=b"\x00" * 16, + ) + + def test_parse_default_witness_commitment_extracts_hash(self): + wc_hash = b"\xab" * 32 + full_script = b"\x6a\x24\xaa\x21\xa9\xed" + wc_hash + result = parse_default_witness_commitment(full_script.hex()) + self.assertEqual(result, wc_hash) + + def test_parse_default_witness_commitment_invalid(self): + with self.assertRaises(ValueError): + parse_default_witness_commitment("deadbeef") # too short + with self.assertRaises(ValueError): + parse_default_witness_commitment(("00" * 50)) # wrong magic + + +# ─────────────────────── merkle root ─────────────────────── + +class TestMerkleRoot(unittest.TestCase): + def test_single_txid_returns_self(self): + txid = b"\x11" * 32 + self.assertEqual(compute_merkle_root_from_txids([txid]), txid) + + def test_two_txids(self): + a = b"\x11" * 32 + b = b"\x22" * 32 + expected = double_sha256(a + b) + self.assertEqual(compute_merkle_root_from_txids([a, b]), expected) + + def test_three_txids_duplicates_last(self): + a = b"\x11" * 32 + b = b"\x22" * 32 + c = b"\x33" * 32 + ab = double_sha256(a + b) + cc = double_sha256(c + c) + expected = double_sha256(ab + cc) + self.assertEqual(compute_merkle_root_from_txids([a, b, c]), expected) + + def test_empty_raises(self): + with self.assertRaises(ValueError): + compute_merkle_root_from_txids([]) + + +# ─────────────────────── serialize_block ─────────────────────── + +class TestSerializeBlock(unittest.TestCase): + def test_only_coinbase(self): + header = b"\x00" * 80 + coinbase = b"\xab\xcd" + result = serialize_block(header, coinbase, []) + # tx_count = 1 (varint = 0x01) + self.assertEqual(result, header + b"\x01" + coinbase) + + def test_with_other_txs(self): + header = b"\x00" * 80 + coinbase = b"\xab" + other = ["dead", "beef"] + result = serialize_block(header, coinbase, other) + expected = header + b"\x03" + coinbase + b"\xde\xad" + b"\xbe\xef" + self.assertEqual(result, expected) + + def test_wrong_header_size_raises(self): + with self.assertRaises(ValueError): + serialize_block(b"\x00" * 79, b"", []) + + +# ─────────────────────── FakeRPC и SoloClient ─────────────────────── + +# Реалистичный шаблон с одной фейковой транзакцией. Поля, которые +# использует SoloClient: version, previousblockhash, height, +# coinbasevalue, bits, curtime, transactions, default_witness_commitment. +FAKE_TEMPLATE = { + "version": 0x20000000, + "previousblockhash": "0" * 64, + "height": 800000, + "coinbasevalue": 312500000, + "bits": "1d00ffff", + "curtime": 1700000000, + "transactions": [ + { + "data": "deadbeef", + "txid": "ab" * 32, + "hash": "ab" * 32, + }, + ], + "default_witness_commitment": "6a24aa21a9ed" + ("11" * 32), +} + + +class FakeRPC: + """Имитация bitcoind для unit-тестов. Записывает все вызовы.""" + + def __init__(self, template=None, submit_result=None): + self.template = template or FAKE_TEMPLATE + self.submit_result = submit_result # None = success, str = reject reason + self.calls: list[tuple[str, list]] = [] + self.url = "http://fake-rpc" # SoloClient логирует self.rpc.url + + def call(self, method, params=None): + self.calls.append((method, params or [])) + if method == "getblocktemplate": + return self.template + if method == "submitblock": + return self.submit_result + raise RPCError(-32601, f"unknown method {method}") + + +class TestSoloClient(unittest.TestCase): + def _make_client(self, rpc=None, **kwargs): + return SoloClient( + rpc=rpc or FakeRPC(), + btc_address="bc1qexample", + stop_event=threading.Event(), + **kwargs, + ) + + def test_connect_fetches_template(self): + rpc = FakeRPC() + client = self._make_client(rpc=rpc) + client.connect() + self.assertEqual(len(rpc.calls), 1) + self.assertEqual(rpc.calls[0][0], "getblocktemplate") + self.assertIsNotNone(client.current_job) + + def test_subscribe_authorize_is_noop(self): + client = self._make_client() + # Не должно бросать и не должно делать новых RPC вызовов + client.subscribe_and_authorize() + + def test_job_has_required_stratum_fields(self): + client = self._make_client() + client.connect() + job = client.current_job + for field in ("job_id", "prevhash", "coinb1", "coinb2", "merkle_branch", + "version", "nbits", "ntime", "clean"): + self.assertIn(field, job) + # prevhash должен быть hex длиной 64 + self.assertEqual(len(job["prevhash"]), 64) + # nbits — hex 8 символов + self.assertEqual(len(job["nbits"]), 8) + + def test_extranonce2_size_positive(self): + # Если en2_size=0, mine() сразу wrap'нется → должно быть >0 + client = self._make_client() + self.assertGreater(client.extranonce2_size, 0) + + def test_submit_calls_submitblock(self): + rpc = FakeRPC(submit_result=None) + client = self._make_client(rpc=rpc) + client.connect() + # Зовём submit с фиктивными значениями. + results: list[tuple[int, bool]] = [] + client.on_share_result = lambda req_id, ok: results.append((req_id, ok)) + client.submit( + job_id=client.current_job["job_id"], + extranonce2="00000000", + ntime=client.current_job["ntime"], + nonce_hex="00000000", + ) + # Должен быть один вызов submitblock с hex-строкой + submits = [c for c in rpc.calls if c[0] == "submitblock"] + self.assertEqual(len(submits), 1) + block_hex = submits[0][1][0] + self.assertIsInstance(block_hex, str) + # Должен быть валидный hex + bytes.fromhex(block_hex) + # Минимум: 80 байт header + 1 varint + 1 coinbase tx + 1 другая = > 100 байт hex + self.assertGreater(len(block_hex), 200) + # И callback должен сработать с accepted=True (submit_result=None) + self.assertEqual(len(results), 1) + self.assertTrue(results[0][1]) + + def test_submit_reject_calls_callback_with_false(self): + rpc = FakeRPC(submit_result="bad-prevblk") + client = self._make_client(rpc=rpc) + client.connect() + results: list[tuple[int, bool]] = [] + client.on_share_result = lambda r, ok: results.append((r, ok)) + client.submit( + job_id=client.current_job["job_id"], + extranonce2="00000000", + ntime=client.current_job["ntime"], + nonce_hex="00000000", + ) + self.assertEqual(len(results), 1) + self.assertFalse(results[0][1]) + + +class TestBitcoinRPCAuth(unittest.TestCase): + def test_no_auth_raises(self): + with self.assertRaises(ValueError): + BitcoinRPC(url="http://localhost:8332") + + def test_user_pass_only(self): + # Не падает при создании + rpc = BitcoinRPC(url="http://localhost:8332", username="u", password="p") + # auth header — base64(user:pass) + import base64 + expected = base64.b64encode(b"u:p").decode() + self.assertEqual(rpc._auth, expected) + + +if __name__ == "__main__": + unittest.main()