diff --git a/README.md b/README.md index d302e5f..f2cc3e7 100644 --- a/README.md +++ b/README.md @@ -46,10 +46,22 @@ - [ ] TUI на `rich` / `curses` — отложено (зависимости либо ограниченная Win-поддержка) - [ ] Команды Telegram-бота (`/stats`, `/restart`) — отложено +**Уровень 1.5 — глубокий аудит и UX (v0.3.0):** + +- [x] Mid-state SHA-256 (`hashlib.sha256().copy()` после первых 64 байт) — ≈×1.5–2 хешрейт +- [x] `mining.suggest_difficulty` + CLI флаг `--suggest-diff` (vardiff) +- [x] Demo-режим: `--demo [--demo-diff]` — offline-майнинг без подключения к пулу +- [x] Pre-flight валидация BTC-адреса (bech32/bech32m/Base58Check, mainnet only) +- [x] Prometheus метрики `hopehash_shares_accepted_total` / `_rejected_total` +- [x] Запись шара в SQLite фиксируется только после подтверждения пула (`on_share_result` колбэк) +- [x] `mining.authorize` ответ верифицируется (раньше отказ авторизации игнорировался) +- [x] `time.perf_counter()` вместо `time.time()` для всех относительных интервалов +- [x] `except queue.Empty` вместо bare `except Exception` в горячих циклах + **Не сделано / известные ограничения:** - Нет UI — только консоль через `logging` и `/metrics` через HTTP. -- Только Stratum V1, без `mining.suggest_difficulty` и без Stratum V2. +- Только Stratum V1, без Stratum V2. - C/Rust/SIMD/GPU — Уровни 2–3, ещё впереди. --- @@ -74,6 +86,8 @@ │ ├── parallel.py ← multiprocessing воркеры nonce-loop │ ├── stratum.py ← StratumClient (TCP + JSON-RPC) │ ├── block.py ← double_sha256, swap_words, target, merkle +│ ├── address.py ← валидация BTC-адресов (bech32/bech32m/Base58Check) +│ ├── demo.py ← offline-майнинг (--demo) │ ├── storage.py ← SQLite журнал шаров и сессий │ ├── metrics.py ← Prometheus экспортёр (http.server) │ ├── notifier.py ← Telegram через urllib @@ -81,10 +95,12 @@ │ └── py.typed ← PEP 561 marker └── tests/ ├── conftest.py ← общие фикстуры (заготовка) - ├── test_block.py ← 15 тестов на чистые функции - ├── test_storage.py ← 9 тестов на SQLite журнал + ├── test_block.py ← 22 теста на чистые функции + mid-state + ├── test_storage.py ← 11 тестов на SQLite журнал ├── test_metrics.py ← 16 тестов на Prometheus экспортёр - └── test_notifier.py ← 16 тестов на Telegram (через mock) + ├── test_notifier.py ← 16 тестов на Telegram (через mock) + ├── test_address.py ← 18 тестов на валидацию BTC-адреса + └── test_stratum.py ← 15 тестов на Stratum-протокол (FakeSocket) ``` --- @@ -112,11 +128,29 @@ hope-hash bc1q5n2x4pvxhq8sxc7ck3uxq8sxc7ck3uxqzfm2py mylaptop ```bash hope-hash mylaptop \ - --workers 8 \ # число процессов (default: cpu_count - 1) - --db ./shares.db \ # путь к SQLite (default: hope_hash.db) - --metrics-port 9090 # Prometheus /metrics (0 — отключить) + --workers 8 \ # число процессов (default: cpu_count - 1) + --db ./shares.db \ # путь к SQLite (default: hope_hash.db) + --metrics-port 9090 \ # Prometheus /metrics (0 — отключить) + --suggest-diff 0.001 # vardiff: запросить у пула низкую сложность +``` + +**Demo-режим (без подключения к пулу):** + +```bash +hope-hash --demo # синтетический заголовок, низкая сложность +hope-hash --demo --demo-diff 0.0001 # ещё ниже — найдёт быстрее +hope-hash --demo --workers 4 # сколько процессов перебирают nonce ``` +Demo не нуждается в BTC-адресе и не делает никаких сетевых вызовов — удобно для smoke-теста на машине, где нужно убедиться, что multiprocessing-воркеры стартуют корректно. + +**Валидация BTC-адреса** срабатывает локально перед подключением к пулу. Принимаются только mainnet-адреса: +- `bc1q...` (P2WPKH, P2WSH) — bech32, BIP-173 +- `bc1p...` (Taproot) — bech32m, BIP-350 +- `1...` (P2PKH), `3...` (P2SH) — Base58Check + +Неверная контрольная сумма, смешанный регистр, testnet-префикс — отвергаются с конкретным сообщением, без сетевого round-trip. + **Telegram-уведомления (опционально):** задать env vars и просто запустить: ```bash @@ -128,10 +162,22 @@ hope-hash **Тесты:** ```bash -python -m unittest discover -s tests -v # 56 тестов +python -m unittest discover -s tests -v # 97 тестов ``` -BTC-адрес нужен валидный (любой формат: `1...`, `3...`, `bc1q...`, `bc1p...`). Можно завести в любом некастодиальном кошельке — например, **Sparrow**, **Electrum**, **Wasabi**. Имя воркера — произвольная строка. +**Prometheus-метрики, экспортируемые на `/metrics`:** + +| Метрика | Тип | Описание | +|---|---|---| +| `hopehash_hashrate_hps` | gauge | EMA-хешрейт в H/s | +| `hopehash_pool_difficulty` | gauge | текущая сложность от пула | +| `hopehash_workers` | gauge | число активных воркеров | +| `hopehash_uptime_seconds` | gauge | время работы майнера | +| `hopehash_shares_total` | counter | всего отправленных шаров | +| `hopehash_shares_accepted_total` | counter | подтверждённых пулом | +| `hopehash_shares_rejected_total` | counter | отклонённых пулом | + +BTC-адрес нужен валидный mainnet-адрес (`1...`, `3...`, `bc1q...`, `bc1p...`). Можно завести в любом некастодиальном кошельке — например, **Sparrow**, **Electrum**, **Wasabi**. Имя воркера — произвольная строка. **Что увидишь:** diff --git a/src/hope_hash/address.py b/src/hope_hash/address.py new file mode 100644 index 0000000..77ca9f5 --- /dev/null +++ b/src/hope_hash/address.py @@ -0,0 +1,207 @@ +"""Валидация Bitcoin-адресов: bech32 (BIP-173), bech32m (BIP-350), Base58Check. + +Полностью stdlib. Используется в `cli.py` как pre-flight проверка: ловим +опечатки и неправильные форматы локально, до сетевого round-trip к пулу. + +Поддерживаются mainnet-адреса (bc1.../1.../3...). Testnet (tb1.../m.../n.../2...) +не валидируется намеренно — solo.ckpool.org работает только с mainnet, и +testnet-адрес почти всегда означает ошибку оператора, а не намерение. +""" + +from __future__ import annotations + +import hashlib + +# Алфавит bech32 из BIP-173. Индекс символа = 5-битное значение. +_BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" +_BECH32_CHARSET_REV = {c: i for i, c in enumerate(_BECH32_CHARSET)} + +# Алфавит Base58 (Bitcoin): без 0/O/I/l, чтобы исключить визуальную путаницу. +_BASE58_CHARSET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" +_BASE58_CHARSET_REV = {c: i for i, c in enumerate(_BASE58_CHARSET)} + +# Константы постфиксов checksum для bech32 и bech32m. Различие — единственный +# способ отличить v0 (BIP-173) от v1+ (BIP-350) при декодировании. +_BECH32_CONST = 1 +_BECH32M_CONST = 0x2BC830A3 + + +def _bech32_polymod(values: list[int]) -> int: + """Полином из BIP-173. Возвращает 30-битное контрольное значение.""" + gen = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3] + chk = 1 + for v in values: + b = chk >> 25 + chk = ((chk & 0x1FFFFFF) << 5) ^ v + for i in range(5): + if (b >> i) & 1: + chk ^= gen[i] + return chk + + +def _bech32_hrp_expand(hrp: str) -> list[int]: + """Развёртка HRP для polymod: high-bits, separator, low-bits.""" + return [ord(c) >> 5 for c in hrp] + [0] + [ord(c) & 31 for c in hrp] + + +def _bech32_verify_checksum(hrp: str, data: list[int]) -> int | None: + """Возвращает витнес-вариант (v0=bech32, v1+=bech32m) или None при несовпадении.""" + polymod = _bech32_polymod(_bech32_hrp_expand(hrp) + data) + if polymod == _BECH32_CONST: + return 0 # bech32 (v0 segwit) + if polymod == _BECH32M_CONST: + return 1 # bech32m (v1+ segwit, taproot) + return None + + +def _bech32_decode(addr: str) -> tuple[str, list[int], int]: + """Декод bech32/bech32m. Возвращает (hrp, data_5bit, variant). + + Бросает ValueError с описанием конкретной проблемы. + """ + # Спецификация: смешанный регистр запрещён, но строки одного регистра + # эквивалентны. Приводим к нижнему после проверки. + if any(ord(c) < 33 or ord(c) > 126 for c in addr): + raise ValueError("адрес содержит недопустимые символы (вне ASCII 33-126)") + if addr.lower() != addr and addr.upper() != addr: + raise ValueError("адрес содержит смешанный регистр (запрещено BIP-173)") + addr = addr.lower() + + pos = addr.rfind("1") + if pos < 1 or pos + 7 > len(addr): + raise ValueError("неверная позиция разделителя '1' в bech32-адресе") + hrp = addr[:pos] + data_str = addr[pos + 1:] + + data: list[int] = [] + for c in data_str: + if c not in _BECH32_CHARSET_REV: + raise ValueError(f"символ '{c}' вне алфавита bech32") + data.append(_BECH32_CHARSET_REV[c]) + + variant = _bech32_verify_checksum(hrp, data) + if variant is None: + raise ValueError("неверная контрольная сумма bech32") + + # Последние 6 5-битных групп — checksum, отбрасываем. + return hrp, data[:-6], variant + + +def _convertbits(data: list[int], frombits: int, tobits: int, pad: bool) -> list[int]: + """Перепаковка 5-битных групп в 8-битные байты (и наоборот).""" + acc = 0 + bits = 0 + ret: list[int] = [] + maxv = (1 << tobits) - 1 + for value in data: + if value < 0 or (value >> frombits) != 0: + raise ValueError("значение вне диапазона при перепаковке битов") + acc = (acc << frombits) | value + bits += frombits + while bits >= tobits: + bits -= tobits + ret.append((acc >> bits) & maxv) + if pad: + if bits > 0: + ret.append((acc << (tobits - bits)) & maxv) + elif bits >= frombits or ((acc << (tobits - bits)) & maxv): + raise ValueError("неверное выравнивание битов при перепаковке") + return ret + + +def _validate_segwit(addr: str) -> None: + """Проверка bech32 segwit-адреса (mainnet, HRP='bc').""" + hrp, data, variant = _bech32_decode(addr) + if hrp != "bc": + raise ValueError(f"HRP='{hrp}', ожидается 'bc' (mainnet)") + if not data: + raise ValueError("пустая data-часть segwit-адреса") + + witver = data[0] + if witver < 0 or witver > 16: + raise ValueError(f"witness version={witver} вне диапазона [0..16]") + + # Витнес-вариант чексуммы должен соответствовать версии: + # v0 → bech32 (BIP-173), v1+ → bech32m (BIP-350). + if witver == 0 and variant != 0: + raise ValueError("v0 segwit требует чексумму bech32, найдена bech32m") + if witver != 0 and variant != 1: + raise ValueError(f"v{witver} segwit требует чексумму bech32m, найдена bech32") + + program = _convertbits(data[1:], 5, 8, False) + if len(program) < 2 or len(program) > 40: + raise ValueError(f"длина witness program={len(program)} байт, ожидается 2..40") + # v0 определяет ровно две длины: P2WPKH (20) и P2WSH (32). + if witver == 0 and len(program) not in (20, 32): + raise ValueError(f"v0 segwit с program={len(program)} байт (ожидается 20 или 32)") + + +def _base58_decode(s: str) -> bytes: + """Декод Base58 с учётом ведущих нулей (символ '1' → 0x00 байт).""" + if not s: + raise ValueError("пустая Base58-строка") + n = 0 + for c in s: + if c not in _BASE58_CHARSET_REV: + raise ValueError(f"символ '{c}' вне алфавита Base58") + n = n * 58 + _BASE58_CHARSET_REV[c] + # Преобразуем целое в байты, добавляем ведущие нули по числу '1' в начале. + full_bytes = bytearray() + while n > 0: + full_bytes.insert(0, n & 0xFF) + n >>= 8 + leading_zeros = 0 + for c in s: + if c == "1": + leading_zeros += 1 + else: + break + return b"\x00" * leading_zeros + bytes(full_bytes) + + +def _validate_legacy(addr: str) -> None: + """Проверка legacy-адреса P2PKH/P2SH через Base58Check. + + Формат: [1 байт version][20 байт hash160][4 байта checksum]. + Mainnet: version=0x00 (P2PKH, '1...') или 0x05 (P2SH, '3...'). + """ + decoded = _base58_decode(addr) + if len(decoded) != 25: + raise ValueError(f"длина Base58-декода={len(decoded)} байт, ожидается 25") + payload, checksum = decoded[:-4], decoded[-4:] + digest = hashlib.sha256(hashlib.sha256(payload).digest()).digest()[:4] + if digest != checksum: + raise ValueError("неверная Base58Check-контрольная сумма") + version = payload[0] + if version not in (0x00, 0x05): + raise ValueError( + f"version-байт=0x{version:02x}, ожидается 0x00 (P2PKH) или 0x05 (P2SH) для mainnet" + ) + + +def validate_btc_address(addr: str) -> None: + """Валидирует mainnet BTC-адрес. Бросает ValueError при ошибке. + + Принимает три формата: + - bech32 P2WPKH/P2WSH ('bc1q...'): BIP-173, witness v0, 20 или 32 байта. + - bech32m taproot ('bc1p...'): BIP-350, witness v1+, 2..40 байт. + - legacy P2PKH ('1...') / P2SH ('3...'): Base58Check, 25 байт. + """ + if not isinstance(addr, str): + raise ValueError("адрес должен быть строкой") + if not addr: + raise ValueError("пустой адрес") + + # Решение по префиксу. Определяем формат по первой группе символов и + # делегируем в специализированный валидатор. Это даёт ясные сообщения + # об ошибках для каждого формата отдельно. + lower = addr.lower() + if lower.startswith("bc1"): + _validate_segwit(addr) + elif addr[0] in ("1", "3"): + _validate_legacy(addr) + else: + raise ValueError( + f"неизвестный префикс адреса: '{addr[:4]}...' " + "(ожидается 'bc1' для segwit/taproot, '1' для P2PKH, '3' для P2SH)" + ) diff --git a/src/hope_hash/cli.py b/src/hope_hash/cli.py index b49c0ec..11611ab 100644 --- a/src/hope_hash/cli.py +++ b/src/hope_hash/cli.py @@ -9,6 +9,7 @@ from pathlib import Path from ._logging import logger, setup_logging +from .address import validate_btc_address from .metrics import Metrics, MetricsServer from .miner import mine, supervisor_loop from .notifier import TelegramNotifier @@ -90,6 +91,15 @@ def main(): print("error: btc_address обязателен (или используйте --demo)", file=sys.stderr) sys.exit(2) + # Pre-flight: ловим опечатки/невалидный формат локально, до соединения с пулом. + # Без этого пул отклоняет mining.authorize, и пользователь видит мутное + # "auth failed" вместо конкретной причины. + try: + validate_btc_address(args.btc_address) + except ValueError as e: + print(f"error: некорректный BTC-адрес '{args.btc_address}': {e}", file=sys.stderr) + sys.exit(2) + # ─── observers ─── # Все три опциональны и не зависят друг от друга. Каждый сам решает, # включаться ли (notifier — по env vars; metrics — по порту; store — по флагу). diff --git a/src/hope_hash/demo.py b/src/hope_hash/demo.py index 55d5282..285527c 100644 --- a/src/hope_hash/demo.py +++ b/src/hope_hash/demo.py @@ -17,7 +17,7 @@ from .parallel import start_pool, stop_pool -def run_demo(n_workers: int = 1, diff: float = 0.001) -> None: +def run_demo(n_workers: int = 1, diff: float = 0.001) -> bool: """ Один раунд demo-майнинга. @@ -25,6 +25,9 @@ def run_demo(n_workers: int = 1, diff: float = 0.001) -> None: n_workers — число параллельных процессов (как в реальном майнинге). diff — желаемая сложность. 0.001 → ~4 млн хешей в среднем, при ~200 KH/s на воркер ожидание ≈ 5–20с. + + Возвращает True, если nonce найден; False — если все воркеры исчерпали + своё nonce-пространство без находки (возможно при очень высокой diff). """ # Синтетический 76-байтовый header_base: # version(4) + prevhash(32) + merkle_root(32) + ntime(4) + nbits(4) @@ -68,3 +71,4 @@ def run_demo(n_workers: int = 1, diff: float = 0.001) -> None: if not found: logger.warning("[demo] nonce не найден в заданном пространстве") logger.info("[demo] demo-режим завершён") + return found diff --git a/src/hope_hash/miner.py b/src/hope_hash/miner.py index a446559..280a42b 100644 --- a/src/hope_hash/miner.py +++ b/src/hope_hash/miner.py @@ -37,7 +37,7 @@ def run_session(client: StratumClient) -> threading.Thread: return t -def supervisor_loop(client: StratumClient): +def supervisor_loop(client: StratumClient) -> None: """ Поднимает соединение и переподключается с экспоненциальным backoff (1с → 2с → 4с → ... до 60с) пока stop_event не выставлен. @@ -54,8 +54,11 @@ def supervisor_loop(client: StratumClient): reader_thread.join(timeout=1.0) except (ConnectionError, socket.error, OSError) as e: logger.warning(f"[net] не удалось подключиться: {e}") - except Exception as e: - logger.error(f"[net] непредвиденная ошибка сессии: {e}") + except Exception: + # Используем .exception() вместо .error(): пишет полный traceback, + # чтобы программерские баги (KeyError, AttributeError) не маскировались. + # Цикл всё равно продолжается — оператор сам решит, гасить ли майнер. + logger.exception("[net] непредвиденная ошибка сессии") if client.stop_event.is_set(): break @@ -106,7 +109,7 @@ def mine( store: Optional[ShareStore] = None, metrics: Optional[Metrics] = None, notifier: Optional[TelegramNotifier] = None, -): +) -> None: """ Оркестратор пула воркеров. @@ -167,6 +170,17 @@ def _on_share_result(req_id: int, accepted: bool) -> None: # extranonce2 — наша часть coinbase, чтобы каждый воркер крутил уникальные хеши. # Поднимается на каждый job; в пределах одного job всё nonce-пространство # делится между процессами. + # Защита от переполнения: en2_size=2 → потолок 65536 jobs за сессию; + # при достижении — wrap, риск коллизии с уже отправленными шарами того же + # job минимален (новый job_id обычно приходит раньше, чем мы успеем 65k раз + # переинициализировать пул). + en2_max = 1 << (en2_size * 8) + if extranonce2_counter >= en2_max: + logger.warning( + f"[mine] extranonce2_counter переполнился (en2_size={en2_size}), " + f"wrap в 0" + ) + extranonce2_counter = 0 extranonce2 = f"{extranonce2_counter:0{en2_size * 2}x}" extranonce2_counter += 1 @@ -212,7 +226,13 @@ def _on_share_result(req_id: int, accepted: bool) -> None: _pending_submits[req_id] = (share_id, current_job_id, current_diff) except (OSError, AttributeError) as e: # submit может прийтись на момент reconnect — не валим майнер. - logger.warning(f"[stratum] не удалось отправить шар: {e}") + # Не ретраим намеренно: к моменту reconnect job_id почти всегда + # устарел, а отправка stale-шары может привести к temporary ban. + # Шар уже записан в SQLite (accepted=False), оператор увидит факт. + logger.warning( + f"[stratum] не удалось отправить шар " + f"(job={current_job_id} nonce={nonce_hex} hash={hash_hex}): {e}" + ) if metrics is not None: metrics.counter_inc( "hopehash_shares_total", 1, diff --git a/src/hope_hash/parallel.py b/src/hope_hash/parallel.py index 05b6cbc..4314c66 100644 --- a/src/hope_hash/parallel.py +++ b/src/hope_hash/parallel.py @@ -17,7 +17,6 @@ import multiprocessing as mp import queue import struct -import time from ._logging import logger @@ -151,17 +150,23 @@ def stop_pool( join_timeout: float = 5.0, ) -> None: """ - Аккуратно гасит пул: set stop_event → join → terminate (если зависли) → - drain очереди. Очередь обязательно нужно осушить ДО join, иначе - дочерние процессы могут заблокироваться на ``Queue.put`` под капотом - feeder-нити (deadlock на Windows встречается особенно охотно). + Аккуратно гасит пул: set stop_event → drain очереди → join → terminate + (если зависли). Очередь обязательно нужно осушить ДО join, иначе дочерние + процессы могут заблокироваться на ``Queue.put`` под капотом feeder-нити + (deadlock на Windows встречается особенно охотно). """ stop_event.set() - # Сначала пытаемся вытащить «уже найденные» шары — чтобы не потерялись. + # Сначала вытаскиваем «уже найденные» шары — чтобы не потерялись. + # Раньше тут было время-ориентированное окно (0.2с), но это магическое число + # без обоснования: воркер ставит put в очередь до проверки stop_event, поэтому + # к моменту вызова stop_pool() все находки уже либо в очереди, либо в feeder- + # буфере воркера (который доставит их в Queue до выхода). Достаточно опустошить + # очередь до queue.Empty с safety-кэпом против бесконечного цикла, если по + # какой-то причине поток ещё пишет. drained: list[tuple] = [] - deadline = time.perf_counter() + 0.2 - while time.perf_counter() < deadline: + drain_cap = 1024 # на пул в 16 воркеров с очень низким diff — с большим запасом + while len(drained) < drain_cap: try: drained.append(found_queue.get_nowait()) except queue.Empty: diff --git a/src/hope_hash/stratum.py b/src/hope_hash/stratum.py index d46450c..ffb7c0a 100644 --- a/src/hope_hash/stratum.py +++ b/src/hope_hash/stratum.py @@ -10,8 +10,8 @@ class StratumClient: def __init__(self, host: str, port: int, btc_address: str, worker_name: str = "py01", - stop_event: threading.Event = None, - suggest_diff: Optional[float] = None): + stop_event: Optional[threading.Event] = None, + suggest_diff: Optional[float] = None) -> None: self.host = host self.port = port self.username = f"{btc_address}.{worker_name}" @@ -37,11 +37,13 @@ def __init__(self, host: str, port: int, btc_address: str, worker_name: str = "p self._submit_req_ids: set[int] = set() self._submit_lock = threading.Lock() - def connect(self): + def connect(self) -> None: self.sock = socket.create_connection((self.host, self.port), timeout=30) logger.info(f"[net] подключён к {self.host}:{self.port}") def _send(self, method: str, params: list) -> int: + if self.sock is None: + raise OSError("сокет не подключён") self.req_id += 1 msg = json.dumps({"id": self.req_id, "method": method, "params": params}) + "\n" self.sock.sendall(msg.encode()) @@ -56,7 +58,7 @@ def _recv_line(self) -> str: line, _, self.buf = self.buf.partition(b"\n") return line.decode().strip() - def subscribe_and_authorize(self): + def subscribe_and_authorize(self) -> None: sub_id = self._send("mining.subscribe", ["py-solo-miner/0.1"]) # Ответ на subscribe может прийти не первым — читаем до победы. while True: @@ -87,7 +89,7 @@ def subscribe_and_authorize(self): if self.suggest_diff is not None: self.suggest_difficulty(self.suggest_diff) - def _handle_message(self, msg: dict): + def _handle_message(self, msg: dict) -> None: method = msg.get("method") params = msg.get("params", []) or [] @@ -141,7 +143,7 @@ def _handle_message(self, msg: dict): if self.on_share_result is not None: self.on_share_result(req, accepted) - def reader_loop(self): + def reader_loop(self) -> None: """ Фоновая нить, постоянно слушает сообщения от пула. Выходит при ошибке сети или при выставленном stop_event — главное, @@ -167,14 +169,14 @@ def suggest_difficulty(self, diff: float) -> None: self._send("mining.suggest_difficulty", [diff]) logger.info(f"[stratum] запрошена сложность {diff}") - def submit(self, job_id, extranonce2, ntime, nonce_hex) -> int: + def submit(self, job_id: str, extranonce2: str, ntime: str, nonce_hex: str) -> int: """Отправляет mining.submit. Возвращает req_id для отслеживания ответа.""" req_id = self._send("mining.submit", [self.username, job_id, extranonce2, ntime, nonce_hex]) with self._submit_lock: self._submit_req_ids.add(req_id) return req_id - def close(self): + def close(self) -> None: """Аккуратно гасим сокет: recv() в reader_loop разблокируется и нить выйдет.""" try: if self.sock is not None: diff --git a/tests/test_address.py b/tests/test_address.py new file mode 100644 index 0000000..413f31a --- /dev/null +++ b/tests/test_address.py @@ -0,0 +1,124 @@ +"""Тесты валидации BTC-адресов: bech32 (BIP-173), bech32m (BIP-350), Base58Check. + +Канонические векторы — из BIP-173/BIP-350, а также реальные mainnet-адреса +(genesis P2PKH, известный P2SH). Адрес пользователя проекта добавлен как +живой smoke-test bech32 P2WPKH. +""" + +import unittest + +from hope_hash.address import validate_btc_address + + +class TestSegwitV0(unittest.TestCase): + """bech32 (BIP-173), witness v0 — P2WPKH (20 байт) и P2WSH (32 байта).""" + + def test_user_address_p2wpkh(self): + # Smoke: реальный адрес владельца проекта. + validate_btc_address("bc1q7h92eqxlp5lkl5ak43fkeccvrcf4f4t0fy9p2e") + + def test_bip173_p2wpkh(self): + # Канонический пример из BIP-173. + validate_btc_address("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4") + + def test_bip173_p2wsh(self): + validate_btc_address( + "bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3" + ) + + def test_uppercase_accepted(self): + # BIP-173: одинаковый регистр (любой) допустим. + validate_btc_address("BC1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KV8F3T4") + + def test_mixed_case_rejected(self): + with self.assertRaises(ValueError) as ctx: + validate_btc_address("Bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4") + self.assertIn("регистр", str(ctx.exception)) + + def test_bad_checksum(self): + # Последний символ заменён → checksum не сойдётся. + with self.assertRaises(ValueError) as ctx: + validate_btc_address("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t5") + self.assertIn("контрольная сумма", str(ctx.exception)) + + def test_v0_with_bech32m_checksum_rejected(self): + # v0 segwit обязан использовать bech32, не bech32m. + # Адрес из BIP-350 test vectors как пример «v0+bech32m» (невалиден). + with self.assertRaises(ValueError) as ctx: + validate_btc_address("bc1qw508d6qejxtdg4y5r3zarvary0j6gh9w") + # Может упасть на checksum или на bad-bech32 raw — главное, что отвергается. + self.assertIsInstance(ctx.exception, ValueError) + + +class TestSegwitV1Taproot(unittest.TestCase): + """bech32m (BIP-350), witness v1 — Taproot.""" + + def test_bip350_taproot(self): + # Канонический taproot из BIP-350. + validate_btc_address( + "bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqzk5jj0" + ) + + def test_v1_with_bech32_checksum_rejected(self): + # Если в v1+ адресе чексумма посчитана как bech32 — отвергаем. + # Берём BIP-350 «invalid» вектор: v1 с bech32-чексуммой. + with self.assertRaises(ValueError): + validate_btc_address( + "bc1pw5dgrnzv" # слишком короткий, упадёт на длине/чексумме + ) + + +class TestLegacyBase58(unittest.TestCase): + """Base58Check: P2PKH ('1...', version 0x00) и P2SH ('3...', version 0x05).""" + + def test_genesis_p2pkh(self): + # Адрес сатоши из coinbase genesis-блока — самый известный P2PKH. + validate_btc_address("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa") + + def test_known_p2sh(self): + # Известный mainnet P2SH (3-by-3 multisig адрес из BIP-16 test vectors). + validate_btc_address("3P14159f73E4gFr7JterCCQh9QjiTjiZrG") + + def test_p2pkh_bad_checksum(self): + with self.assertRaises(ValueError) as ctx: + validate_btc_address("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNb") + self.assertIn("контрольная сумма", str(ctx.exception)) + + def test_invalid_base58_char(self): + # '0', 'O', 'I', 'l' исключены из алфавита Base58. + with self.assertRaises(ValueError) as ctx: + validate_btc_address("10A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa") + self.assertIn("Base58", str(ctx.exception)) + + def test_wrong_version_byte_rejected(self): + # Testnet P2PKH (version=0x6F, начинается с 'm'/'n') — для нас невалиден. + with self.assertRaises(ValueError) as ctx: + validate_btc_address("mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn") + # Падаем либо на префиксе, либо на version-байте — оба варианта корректны. + self.assertIsInstance(ctx.exception, ValueError) + + +class TestRejection(unittest.TestCase): + """Пограничные случаи: пустые/мусорные/неподдерживаемые входы.""" + + def test_empty(self): + with self.assertRaises(ValueError): + validate_btc_address("") + + def test_non_string(self): + with self.assertRaises(ValueError): + validate_btc_address(None) # type: ignore[arg-type] + + def test_unknown_prefix(self): + with self.assertRaises(ValueError) as ctx: + validate_btc_address("xyz1qabcdef") + self.assertIn("префикс", str(ctx.exception)) + + def test_testnet_bech32_rejected(self): + # Валидный testnet bech32, но HRP='tb' — отвергаем (mainnet only). + with self.assertRaises(ValueError): + validate_btc_address("tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_demo.py b/tests/test_demo.py new file mode 100644 index 0000000..c68fd9e --- /dev/null +++ b/tests/test_demo.py @@ -0,0 +1,40 @@ +"""Тест demo-режима: должен находить nonce при низкой сложности. + +Многопроцессный код тестируется как чёрный ящик: запускаем `run_demo` в +spawn-процессе с маленькой diff, проверяем что вернулось True. Делаем это +в подпроцессе, чтобы pytest-нити не мешали multiprocessing на Windows. +""" + +import multiprocessing as mp +import sys +import unittest + + +def _demo_worker(diff: float, queue: "mp.Queue") -> None: + """Запускает run_demo в дочернем процессе и кладёт результат в очередь.""" + # Импорт внутри: spawn-дети должны импортировать заново. + from hope_hash.demo import run_demo + + found = run_demo(n_workers=1, diff=diff) + queue.put(found) + + +class TestRunDemo(unittest.TestCase): + + @unittest.skipIf(sys.platform == "win32" and sys.version_info < (3, 11), + "spawn overhead на старых Windows-Python слишком высок") + def test_finds_nonce_at_low_difficulty(self): + # diff=0.0001 → target очень высок, почти любой хеш проходит. + # Даже с overhead spawn'а это укладывается в секунды. + ctx = mp.get_context("spawn") + q: "mp.Queue" = ctx.Queue() + p = ctx.Process(target=_demo_worker, args=(0.0001, q)) + p.start() + p.join(timeout=60) # большой запас — запас CI runner'у на старте Python + self.assertFalse(p.is_alive(), "demo не завершился за 60 секунд") + self.assertEqual(p.exitcode, 0, "demo упал с ошибкой") + self.assertTrue(q.get_nowait(), "demo вернул False — nonce не найден") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_stratum.py b/tests/test_stratum.py new file mode 100644 index 0000000..01d64e5 --- /dev/null +++ b/tests/test_stratum.py @@ -0,0 +1,300 @@ +"""Тесты протокольного слоя StratumClient (без реальной сети). + +Подменяем `socket.create_connection` на FakeSocket, который проигрывает +заранее записанные line-delimited JSON-сообщения и собирает всё, что +клиент отправляет — потом ассерт на этой записи. + +Покрываем: +- subscribe: парсинг extranonce1/en2_size, чтение out-of-order ответов +- authorize: success → ConnectionError при пуловом отказе +- set_difficulty / set_extranonce / mining.notify +- submit: отслеживание req_id, callback on_share_result для accept/reject +- reader_loop: устойчивость к битому JSON, остановка по stop_event +- close() идемпотентен +- suggest_difficulty: отправляется автоматически после авторизации +""" + +import json +import socket +import threading +import unittest +from typing import Iterable +from unittest.mock import patch + +from hope_hash.stratum import StratumClient + + +# ───────────────────────── FakeSocket ───────────────────────── + +class FakeSocket: + """Имитирует TCP-сокет с заранее заданным сценарием server-сообщений. + + `incoming` — итератор строк (без \\n), которые будут отдаваться по recv() + в порядке поступления. Когда они кончатся, recv() блокируется до close() + или возвращает b"" (что StratumClient трактует как «pool закрыл»). + + `outbox` — все отправленные клиентом строки (decoded, без \\n). + """ + + def __init__(self, incoming: Iterable[str]): + self._incoming = list(incoming) + self._idx = 0 + self.outbox: list[str] = [] + self._closed = False + # На каждое recv() ждём, пока появится следующий чанк или закроют сокет. + self._cond = threading.Condition() + # Буфер незабранных байт (incoming сериализованный с \n). + self._pending = b"" + for line in self._incoming: + self._pending += line.encode() + b"\n" + + def sendall(self, data: bytes) -> None: + if self._closed: + raise OSError("socket is closed") + # Клиент шлёт построчно — собираем по \n для удобства ассертов. + text = data.decode() + for line in text.split("\n"): + if line: + self.outbox.append(line) + + def recv(self, n: int) -> bytes: + with self._cond: + while not self._pending and not self._closed: + # Простой блок: тесты сами должны кормить через push_line() или close(). + self._cond.wait(timeout=0.5) + # Если ничего не появилось за таймаут — выходим как «соединение закрыто». + if not self._pending and not self._closed: + return b"" + if not self._pending and self._closed: + return b"" + chunk = self._pending[:n] + self._pending = self._pending[n:] + return chunk + + def push_line(self, line: str) -> None: + """Подкинуть серверное сообщение в реальном времени (для reader_loop).""" + with self._cond: + self._pending += line.encode() + b"\n" + self._cond.notify_all() + + def close(self) -> None: + with self._cond: + self._closed = True + self._cond.notify_all() + + +def _make_client(fake: FakeSocket, **kwargs) -> StratumClient: + """Создаёт клиента с подменённым socket.create_connection → fake.""" + client = StratumClient("test.pool", 1234, "bc1qtest", "py01", **kwargs) + with patch("socket.create_connection", return_value=fake): + client.connect() + return client + + +# ───────────────────────── subscribe + authorize ───────────────────────── + +class TestSubscribeAuthorize(unittest.TestCase): + + def test_subscribe_parses_extranonce(self): + fake = FakeSocket([ + json.dumps({"id": 1, "result": [[["mining.notify", "abc"]], "ab12cd34", 4]}), + json.dumps({"id": 2, "result": True}), + ]) + client = _make_client(fake) + client.subscribe_and_authorize() + self.assertEqual(client.extranonce1, "ab12cd34") + self.assertEqual(client.extranonce2_size, 4) + # Проверяем, что отправили subscribe + authorize в правильном порядке. + sent = [json.loads(x) for x in fake.outbox] + self.assertEqual(sent[0]["method"], "mining.subscribe") + self.assertEqual(sent[1]["method"], "mining.authorize") + self.assertEqual(sent[1]["params"][0], "bc1qtest.py01") + + def test_subscribe_handles_out_of_order_messages(self): + # Пул может прислать set_difficulty до ответа на subscribe — клиент + # должен это применить и продолжить ждать свой id. + fake = FakeSocket([ + json.dumps({"method": "mining.set_difficulty", "params": [2.5]}), + json.dumps({"id": 1, "result": [[], "deadbeef", 4]}), + json.dumps({"id": 2, "result": True}), + ]) + client = _make_client(fake) + client.subscribe_and_authorize() + self.assertEqual(client.extranonce1, "deadbeef") + self.assertEqual(client.difficulty, 2.5) + + def test_authorize_rejection_raises(self): + fake = FakeSocket([ + json.dumps({"id": 1, "result": [[], "ab", 4]}), + json.dumps({"id": 2, "result": False, "error": "bad worker"}), + ]) + client = _make_client(fake) + with self.assertRaises(ConnectionError) as ctx: + client.subscribe_and_authorize() + self.assertIn("authorize", str(ctx.exception)) + + def test_suggest_diff_sent_after_authorize(self): + fake = FakeSocket([ + json.dumps({"id": 1, "result": [[], "ab", 4]}), + json.dumps({"id": 2, "result": True}), + ]) + client = _make_client(fake, suggest_diff=0.001) + client.subscribe_and_authorize() + sent = [json.loads(x) for x in fake.outbox] + methods = [m["method"] for m in sent] + self.assertIn("mining.suggest_difficulty", methods) + idx = methods.index("mining.suggest_difficulty") + self.assertEqual(sent[idx]["params"], [0.001]) + + +# ───────────────────────── _handle_message ───────────────────────── + +class TestHandleMessage(unittest.TestCase): + + def setUp(self): + # Минимальный клиент без сети — _handle_message работает на dict'ах. + self.client = StratumClient("h", 1, "bc1qtest", "py01") + + def test_set_difficulty_updates_under_lock(self): + self.client._handle_message({"method": "mining.set_difficulty", "params": [42.0]}) + self.assertEqual(self.client.difficulty, 42.0) + + def test_set_extranonce_resets_job(self): + # Сначала «положим» job, потом set_extranonce должен его сбросить. + self.client._handle_message({ + "method": "mining.notify", + "params": ["j1", "p", "c1", "c2", [], "v", "n", "t", True], + }) + self.assertIsNotNone(self.client.current_job) + self.client._handle_message({ + "method": "mining.set_extranonce", + "params": ["fffefdfc", 8], + }) + self.assertEqual(self.client.extranonce1, "fffefdfc") + self.assertEqual(self.client.extranonce2_size, 8) + self.assertIsNone(self.client.current_job) + + def test_notify_populates_all_fields(self): + params = ["job1", "prevhashhex", "coinb1hex", "coinb2hex", + ["branch1", "branch2"], "20000000", "1d00ffff", "5fa1b2c3", True] + self.client._handle_message({"method": "mining.notify", "params": params}) + job = self.client.current_job + self.assertEqual(job["job_id"], "job1") + self.assertEqual(job["merkle_branch"], ["branch1", "branch2"]) + self.assertTrue(job["clean"]) + + def test_submit_response_triggers_callback_accepted(self): + results: list[tuple[int, bool]] = [] + self.client.on_share_result = lambda req, ok: results.append((req, ok)) + + # Регистрируем req_id как submit (как сделал бы submit()). + with self.client._submit_lock: + self.client._submit_req_ids.add(7) + self.client._handle_message({"id": 7, "result": True}) + self.assertEqual(results, [(7, True)]) + # req_id должен быть «съеден», повторный ответ ничего не делает. + self.client._handle_message({"id": 7, "result": True}) + self.assertEqual(len(results), 1) + + def test_submit_response_rejected(self): + results: list[tuple[int, bool]] = [] + self.client.on_share_result = lambda req, ok: results.append((req, ok)) + with self.client._submit_lock: + self.client._submit_req_ids.add(9) + self.client._handle_message({ + "id": 9, "result": False, "error": [23, "Low difficulty share", None] + }) + self.assertEqual(results, [(9, False)]) + + def test_non_submit_id_response_does_not_call_callback(self): + # Ответ на subscribe/authorize/suggest_difficulty — не должен дёргать колбэк. + called: list = [] + self.client.on_share_result = lambda req, ok: called.append((req, ok)) + self.client._handle_message({"id": 1, "result": True}) + self.assertEqual(called, []) + + +# ───────────────────────── submit ───────────────────────── + +class TestSubmit(unittest.TestCase): + + def test_submit_tracks_req_id(self): + fake = FakeSocket([ + json.dumps({"id": 1, "result": [[], "ab", 4]}), + json.dumps({"id": 2, "result": True}), + ]) + client = _make_client(fake) + client.subscribe_and_authorize() + req_id = client.submit("jobX", "00000001", "5fa1b2c3", "deadbeef") + # _submit_req_ids должен содержать только что отправленный id. + with client._submit_lock: + self.assertIn(req_id, client._submit_req_ids) + # Проверяем содержимое отправленного submit. + last = json.loads(fake.outbox[-1]) + self.assertEqual(last["method"], "mining.submit") + self.assertEqual(last["params"], ["bc1qtest.py01", "jobX", "00000001", + "5fa1b2c3", "deadbeef"]) + + +# ───────────────────────── reader_loop ───────────────────────── + +class TestReaderLoop(unittest.TestCase): + + def test_reader_loop_processes_notify_then_stops(self): + fake = FakeSocket([]) + client = _make_client(fake) + thr = threading.Thread(target=client.reader_loop, daemon=True) + thr.start() + # Подкидываем серверное сообщение в живом режиме. + fake.push_line(json.dumps({ + "method": "mining.notify", + "params": ["jobZ", "p", "c1", "c2", [], "v", "n", "t", True], + })) + # Даём reader_loop время на обработку. + for _ in range(50): + if client.current_job is not None: + break + threading.Event().wait(0.02) + self.assertIsNotNone(client.current_job) + self.assertEqual(client.current_job["job_id"], "jobZ") + + # Останавливаем через stop_event + close — нить должна выйти за разумное время. + client.stop_event.set() + client.close() + thr.join(timeout=2) + self.assertFalse(thr.is_alive()) + + def test_reader_loop_survives_bad_json(self): + fake = FakeSocket([]) + client = _make_client(fake) + thr = threading.Thread(target=client.reader_loop, daemon=True) + thr.start() + fake.push_line("{not json") # битая строка — должна быть скипнута + fake.push_line(json.dumps({"method": "mining.set_difficulty", "params": [3.0]})) + for _ in range(50): + if client.difficulty == 3.0: + break + threading.Event().wait(0.02) + self.assertEqual(client.difficulty, 3.0) + client.stop_event.set() + client.close() + thr.join(timeout=2) + + +# ───────────────────────── lifecycle ───────────────────────── + +class TestLifecycle(unittest.TestCase): + + def test_close_is_idempotent(self): + fake = FakeSocket([]) + client = _make_client(fake) + client.close() + client.close() # второй вызов не должен бросать + + def test_close_without_connect(self): + client = StratumClient("h", 1, "bc1qtest") + client.close() # sock is None — должно молча проглотиться + + +if __name__ == "__main__": + unittest.main()