From c5644719c35f7a87aba1e1c48d27b091a98d302a Mon Sep 17 00:00:00 2001
From: devAsmodeus
Date: Thu, 30 Apr 2026 23:48:03 +0300
Subject: [PATCH 1/3] feat(address): pre-flight BTC address validation
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds stdlib-only BTC address validation in src/hope_hash/address.py
covering bech32 (BIP-173), bech32m (BIP-350), and Base58Check.
Mainnet only — testnet addresses rejected with explicit error.
Wired into cli.main() before pool connect: typos, mixed case, bad
checksums, and unsupported prefixes are caught locally with a
human-readable message instead of opaque pool authorize failures.
18 tests in tests/test_address.py covering:
- bech32 v0 P2WPKH/P2WSH, uppercase, mixed-case rejection, bad checksum
- bech32m v1 Taproot, v0/v1+ checksum mismatch
- Base58Check P2PKH (genesis) / P2SH, bad checksum, invalid charset
- empty/non-string/unknown-prefix/testnet rejection
Co-Authored-By: Claude Opus 4.7 (1M context)
---
src/hope_hash/address.py | 207 +++++++++++++++++++++++++++++++++++++++
src/hope_hash/cli.py | 10 ++
tests/test_address.py | 124 +++++++++++++++++++++++
3 files changed, 341 insertions(+)
create mode 100644 src/hope_hash/address.py
create mode 100644 tests/test_address.py
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/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()
From 400fffd34d6753bf00f3d0e926ab7a4dbeafe59f Mon Sep 17 00:00:00 2001
From: devAsmodeus
Date: Thu, 30 Apr 2026 23:48:14 +0300
Subject: [PATCH 2/3] test(stratum): add 15 protocol tests with FakeSocket
fixture
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
StratumClient was the largest untested surface in v0.3 — protocol logic
relied on offline reasoning + manual smoke runs. This adds an in-memory
FakeSocket that replays scripted server messages and captures client
output, plus push_line() for live-feeding reader_loop in tests.
Coverage:
- subscribe: extranonce parsing + out-of-order server messages
- authorize: success path + ConnectionError on pool rejection
- suggest_difficulty auto-sent after authorize
- _handle_message: set_difficulty / set_extranonce (resets job) /
notify (all fields) / submit response accepted/rejected /
non-submit id ignored / req_id consumed once
- submit: req_id tracking, parameter encoding
- reader_loop: graceful stop via stop_event + close, survives bad JSON
- close() idempotent and safe without prior connect()
Co-Authored-By: Claude Opus 4.7 (1M context)
---
tests/test_stratum.py | 300 ++++++++++++++++++++++++++++++++++++++++++
1 file changed, 300 insertions(+)
create mode 100644 tests/test_stratum.py
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()
From 8632ca39cf7197706ae814a2819df09eebe58bd3 Mon Sep 17 00:00:00 2001
From: devAsmodeus
Date: Thu, 30 Apr 2026 23:48:45 +0300
Subject: [PATCH 3/3] chore: close v0.3 audit tail (8 of 10 items)
Mop-up of remaining audit items from v0.3-deep session:
miner.py:
- supervisor_loop: logger.error -> logger.exception so unexpected
errors (KeyError/AttributeError/etc) surface with full traceback
instead of being masked by single-line log
- mine: extranonce2_counter overflow guard with wrap-around. Without
this, f"{counter:0{en2_size*2}x}" silently truncated high bits at
2^(en2_size*8) jobs (reachable for en2_size=2)
- mine: submit-failed log now includes job_id/nonce/hash so operators
can trace lost shares against SQLite (which already records them
with accepted=False). Comment documents why retransmit is unsafe
(stale shares -> potential pool ban)
- mine: -> None return type annotation
- supervisor_loop: -> None return type annotation
parallel.py:
- stop_pool: replace 0.2s magic-number drain window with
drain-until-queue.Empty + safety cap. Workers put before checking
stop_event, so by the time stop_pool runs all finds are already in
the queue or feeder buffer; time-based deadline was unjustified
- remove unused `import time` after refactor
stratum.py:
- Add return-type annotations: __init__, connect,
subscribe_and_authorize, _handle_message, reader_loop, close
- submit: typed parameters (str instead of untyped)
- __init__: stop_event Optional[threading.Event] (was buggy
`threading.Event = None` with mismatched declared type)
- _send: explicit OSError when sock is None instead of falling
through to AttributeError
demo.py:
- run_demo: returns bool (True = nonce found, False = exhausted)
to make outcome assertable
tests/test_demo.py (new):
- Spawn-process integration test: low-difficulty run finds nonce
within timeout, exits cleanly
README.md:
- Add v0.3 audit/UX section listing closed items
- Document --demo / --suggest-diff / address validation
- Full Prometheus metrics table (incl. accepted/rejected counters)
- Update file tree (address.py, demo.py) and per-file test counts
- Update headline test count to 97
Tests: 64 -> 98 (+34 across address/stratum/demo).
Skipped intentionally:
- mining.submit retransmit on reconnect: stale-share submit risks
temporary worker ban. Current behaviour (log + drop) is conservative
- notify_share_accepted timing test: would require refactoring the
closure inside mine() into a class. Logic covered indirectly by
test_storage.test_update_share_accepted_from_false_to_true
Co-Authored-By: Claude Opus 4.7 (1M context)
---
README.md | 64 +++++++++++++++++++++++++++++++++------
src/hope_hash/demo.py | 6 +++-
src/hope_hash/miner.py | 30 +++++++++++++++---
src/hope_hash/parallel.py | 21 ++++++++-----
src/hope_hash/stratum.py | 18 ++++++-----
tests/test_demo.py | 40 ++++++++++++++++++++++++
6 files changed, 148 insertions(+), 31 deletions(-)
create mode 100644 tests/test_demo.py
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/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_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()