From d5f36cea2465369da004dc97e12cd364a82704f8 Mon Sep 17 00:00:00 2001
From: devAsmodeus
Date: Sat, 2 May 2026 13:07:34 +0300
Subject: [PATCH 1/5] feat(sha): ctypes libcrypto backend + bench --backends
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds src/hope_hash/sha_native.py: loads libcrypto-3.dll /
libcrypto.so.3 / libcrypto.dylib via ctypes.CDLL, exposes
sha256() / sha256d() / is_available() / BACKEND_NAME via the
EVP API. Falls back to hashlib if libcrypto is not found.
parallel.worker now dispatches to _worker_hashlib_midstate()
(unchanged hot path) or _worker_ctypes() (sha256d per iteration,
no mid-state — honest baseline for benchmark comparison).
start_pool() takes sha_backend kwarg.
bench.run_benchmark() takes sha_backend and print_header.
New run_benchmark_all_backends() + available_backends() drive
the --backends comparison; final line "[bench] result: ctypes
X MH/s (Yx vs hashlib-midstate)" is grep-friendly.
Verified hot path unchanged: 4-worker hashlib-midstate stays at
~3.18 MH/s (same order as v0.5.0). ctypes is ~0.3x because no
mid-state — sognately so.
21 new tests; total 145 → 166.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
src/hope_hash/bench.py | 111 +++++++++++++++++---
src/hope_hash/parallel.py | 102 +++++++++++++++++--
src/hope_hash/sha_native.py | 189 +++++++++++++++++++++++++++++++++++
tests/test_bench_backends.py | 58 +++++++++++
tests/test_sha_native.py | 92 +++++++++++++++++
5 files changed, 530 insertions(+), 22 deletions(-)
create mode 100644 src/hope_hash/sha_native.py
create mode 100644 tests/test_bench_backends.py
create mode 100644 tests/test_sha_native.py
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/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/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/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_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()
From 7993d93567cfb29a245e1576b4f21ce9b866c848 Mon Sep 17 00:00:00 2001
From: devAsmodeus
Date: Sat, 2 May 2026 13:07:48 +0300
Subject: [PATCH 2/5] feat(pool): multi-pool failover with round-robin rotation
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
New module src/hope_hash/pools.py: PoolList holds endpoints,
tracks per-pool failure counts, rotates on threshold, knows
when a full cycle has passed without a single success
(full_cycle_failed). parse_pool_spec() handles host/host:port.
Dedup is case-insensitive on host.
stratum.StratumClient.set_endpoint(host, port) re-targets the
client without recreating it: on_share_result, stop_event,
suggest_diff, username and locks are preserved. This is what
makes failover seamless inside mine().
miner.supervisor_loop now accepts pools: Optional[PoolList] and
stats_provider: Optional[StatsProvider]. On a failed connect we
mark_failed(); after rotation we set_endpoint() and continue.
After full_cycle_failed we apply the existing exponential backoff
once and reset_round(). When connect succeeds we mark_success().
tui.StatsProvider.update_pool(url) lets the supervisor push the
active pool name through the canonical data bus so the TUI shows
the live target.
24 new tests for PoolList: rotation at threshold, wrap-around,
single-pool no-op, mark_success reset, full_cycle_failed,
no-deadlock smoke. Total 166 → 190.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
src/hope_hash/miner.py | 66 +++++++++++++--
src/hope_hash/pools.py | 174 +++++++++++++++++++++++++++++++++++++++
src/hope_hash/stratum.py | 20 +++++
src/hope_hash/tui.py | 9 ++
tests/test_pools.py | 145 ++++++++++++++++++++++++++++++++
5 files changed, 408 insertions(+), 6 deletions(-)
create mode 100644 src/hope_hash/pools.py
create mode 100644 tests/test_pools.py
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/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/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_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()
From f2512faa5de6c43ffddcf8b0491b0d582526e99c Mon Sep 17 00:00:00 2001
From: devAsmodeus
Date: Sat, 2 May 2026 13:08:04 +0300
Subject: [PATCH 3/5] feat(solo): getblocktemplate via JSON-RPC (BIP-22 +
BIP-141)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
New module src/hope_hash/solo.py implementing solo mining
against a local bitcoind:
- BitcoinRPC: minimal stdlib urllib JSON-RPC client. Auth via
cookie file (default for local) or username/password. Cookie
wins if both are provided.
- SoloClient: duck-types StratumClient's public surface
(current_job, job_lock, extranonce1, extranonce2_size,
difficulty, submit, on_share_result, connect,
subscribe_and_authorize, reader_loop, close, host/port/sock)
so mine() runs unchanged. reader_loop polls getblocktemplate
every poll_interval_s and updates current_job on prevhash
change.
- build_coinbase: serializes a coinbase tx with BIP-34 height
push, optional extranonce push, and optional BIP-141 witness
commitment output (OP_RETURN OP_PUSH36 0xaa21a9ed ).
- compute_witness_commitment + parse_default_witness_commitment
handle both directions: building from a witness root, or
extracting the 32-byte hash from bitcoind's pre-built script.
- serialize_block + compute_merkle_root_from_txids round out
the surface needed for submitblock.
scriptPubKey for the coinbase output is OP_RETURN — sognately
unspendable. Real solo would need bech32/base58 decode to a
P2WPKH/P2PKH script; that's noted in CLAUDE.md-style docstring
as a known simplification (chance of finding a block ≈ 0).
35 tests using FakeRPC, including SoloClient.connect/submit
success+reject paths, varint/push/height edge cases, witness
commitment parity vs double_sha256. Total 190 → 225.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
src/hope_hash/solo.py | 637 ++++++++++++++++++++++++++++++++++++++++++
tests/test_solo.py | 373 +++++++++++++++++++++++++
2 files changed, 1010 insertions(+)
create mode 100644 src/hope_hash/solo.py
create mode 100644 tests/test_solo.py
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/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()
From c9f7ab766f676d720f4f6b66d5f85e069e0b19ba Mon Sep 17 00:00:00 2001
From: devAsmodeus
Date: Sat, 2 May 2026 13:08:19 +0300
Subject: [PATCH 4/5] feat(cli): wire --pool, --sha-backend, --solo, --backends
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds CLI flags for the new perf/resilience features:
- --pool HOST:PORT (repeatable) drives PoolList, --rotate-after-failures
configures the threshold. When --pool is provided the legacy default
CKPool is replaced.
- --sha-backend {auto,hashlib,ctypes} (default auto). auto picks
ctypes when libcrypto loads, hashlib otherwise. The choice flows
through mine() → start_pool() → worker.
- --backends turns --benchmark into a comparative run across all
available backends.
- --solo + --rpc-url + (--rpc-cookie | --rpc-user/--rpc-pass) +
--solo-poll-sec switch the supervisor to SoloClient with the
same surface, so mine() works unchanged.
main() splits into three branches: bench, solo, and stratum-pool.
The supervisor thread is constructed with the right kwargs for each
case (pools=PoolList for stratum, plain for solo).
__init__.py bumps __version__ to 0.6.0 and re-exports the new
public symbols (PoolList, SoloClient, BitcoinRPC, build_coinbase,
sha_native module, run_benchmark_all_backends, etc.).
No behavior change for existing flags. All 225 tests still green.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
src/hope_hash/__init__.py | 27 ++++++-
src/hope_hash/cli.py | 144 +++++++++++++++++++++++++++++++++++---
2 files changed, 159 insertions(+), 12 deletions(-)
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/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:
From 660ab26304a32c0ff85271d10cc4d36967297d4d Mon Sep 17 00:00:00 2001
From: devAsmodeus
Date: Sat, 2 May 2026 13:08:31 +0300
Subject: [PATCH 5/5] docs(v0.6.0): changelog, README blurb, ROADMAP ticks,
handoff
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds the v0.6.0 section to CHANGELOG and README, ticks off
multi-pool failover, getblocktemplate solo mode, and the
ctypes SHA-256 wrapper in ROADMAP. Records what shipped, the
file map, new CLI flags, gotchas, and open questions for PR C
in docs/handoff/pr-b-summary.md.
README is intentionally only appended to (the v0.6.0 paragraph) —
the full bilingual rewrite is PR C scope.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
CHANGELOG.md | 48 +++++++++++++
README.md | 13 ++++
ROADMAP.md | 7 +-
docs/handoff/pr-b-summary.md | 134 +++++++++++++++++++++++++++++++++++
4 files changed, 199 insertions(+), 3 deletions(-)
create mode 100644 docs/handoff/pr-b-summary.md
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 тестов остались зелёными.