diff --git a/CHANGELOG.md b/CHANGELOG.md index d4a4e4d..6569171 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,28 @@ ## [Unreleased] +## [0.3.0] — 2026-04-30 + +### Добавлено +- **Mid-state SHA-256** (`parallel.py`): block header 80 байт = 64 + 16. + Первые 64 байта — константа в пределах одного nonce-цикла. Pre-compute + через `hashlib.sha256().copy()` даёт ≈×1.5–2 к хешрейту без зависимостей. +- **Demo-режим** (`demo.py`): `hope-hash --demo [--workers N] [--demo-diff DIFF]`. + Запускается без подключения к пулу; ищет nonce для синтетического заголовка + с низкой сложностью. Полезен для презентаций и offline-тестирования. +- **Vardiff** (`stratum.py`): метод `suggest_difficulty(diff)` и + CLI-флаг `--suggest-diff FLOAT`. Отправляет `mining.suggest_difficulty` + после авторизации, чтобы запросить у пула удобную сложность для CPU. +- 3 новых теста `TestMidstateSha256` в `test_block.py`. Всего **59 тестов**. + +### Исправлено +- `miner.py`: голый `except Exception: pass` на `found_queue.get_nowait()` + заменён на `except queue.Empty:` — реальные ошибки больше не маскируются. +- `parallel.py`: аналогичный fix в `stop_pool` при drain-е очереди. +- `miner.py`, `parallel.py`: `time.time()` → `time.perf_counter()` для + всех относительных интервалов (EMA, alive-check, drain-deadline). + Защищает от ложных скачков при корректировке системных часов (NTP). + ## [0.2.0] — 2026-04-30 ### Добавлено diff --git a/CLAUDE.md b/CLAUDE.md index 65c2ed7..35b3016 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,12 +24,18 @@ - `src/hope_hash/block.py` — чистые функции: `double_sha256`, `swap_words`, `difficulty_to_target`, `build_merkle_root`. Без сайд-эффектов. - `src/hope_hash/stratum.py` — класс `StratumClient` (TCP + JSON-RPC). +- `src/hope_hash/parallel.py` — `worker()`, `start_pool()`, `stop_pool()`. + Mid-state SHA-256 через `hashlib.sha256().copy()`. - `src/hope_hash/miner.py` — `mine()`, `run_session()`, `supervisor_loop()`. +- `src/hope_hash/demo.py` — `run_demo()` — offline-майнинг с синтетическим заголовком. +- `src/hope_hash/storage.py` — SQLite-журнал шаров (`ShareStore`). +- `src/hope_hash/metrics.py` — Prometheus-экспортёр (`Metrics`, `MetricsServer`). +- `src/hope_hash/notifier.py` — Telegram-уведомления через stdlib `urllib`. - `src/hope_hash/cli.py` — argparse-точка входа `main()`, константы пула. - `src/hope_hash/_logging.py` — приватная настройка логгера `hope_hash`. - `src/hope_hash/__init__.py` — публичный API + `__version__`. - `src/hope_hash/__main__.py` — для `python -m hope_hash`. -- `tests/test_block.py` — 15 тестов на чистые функции. +- `tests/test_block.py` — тесты на чистые функции + mid-state. ## Архитектура (не менять без обсуждения) @@ -94,20 +100,65 @@ python -m hope_hash [имя_воркера] python -m unittest discover -s tests -v ``` -## Self-learning loop (на будущее) +## Self-learning loop -Когда накопится опыт работы агента над проектом — создать `learnings.md` -с разделами **What Has Worked / What Has Failed / Patterns and Preferences -/ Open Questions**. Формат записи: +После каждой сессии с реальными уроками — обновляй `learnings.md`. +При обнаружении нового непреложного инварианта — добавляй в этот файл. +Формат записи в `learnings.md`: ``` **[YYYY-MM-DD] — [тип задачи]** -- Observation: что замечено -- Action: что делать / чего избегать дальше +- Observation: конкретное наблюдение (не общая фраза) +- Action: что делать / чего избегать — применимо в будущих сессиях - Confidence: high / medium / low ``` Правила: архивировать при превышении 80–100 строк, удалять устаревшее, -не добавлять записи без конкретики (vague entries едят контекст). +не добавлять записи без конкретики. -Сейчас файла нет — создать при первом реальном уроке. +--- + +## META: Как писать правила (инструкции для агента) + +Этот раздел — самый важный. Он объясняет, **как** добавлять новые правила +в CLAUDE.md и learnings.md, чтобы документ не деградировал. + +### Принципы хорошего правила + +1. **Причина первична.** Формула: «[Причина] — поэтому [директива]». + > «SHA-256 endianness проверен против mainnet-блоков — NEVER переписывать.» + Без причины правило выглядит как суеверие и будет проигнорировано. + +2. **Абсолютные директивы для критического.** NEVER / ALWAYS / MUST / MUST NOT — + только для правил, нарушение которых ломает корректность или безопасность. + Для preference используй «предпочитать» / «по умолчанию». + +3. **Конкретность > обобщение.** + > ❌ «не злоупотреблять try/except» + > ✅ «не ловить bare `except Exception:` на Queue.get_nowait — только `queue.Empty`» + +4. **Один паттерн = одна запись.** Если новое правило похоже на существующее — + обнови существующее, не создавай дубль. + +5. **Не раздувай.** Правило, которое можно вывести из здравого смысла — не нужно. + Пиши только то, что неочевидно или было нарушено на практике. + +### Когда обновлять CLAUDE.md (этот файл) + +- Новый архитектурный инвариант, нарушение которого сломает проект. +- Новое соглашение (Conventions), применимое ко всему коду. +- Антипаттерн (Patterns to avoid), реально встреченный в работе агента. + +### Когда писать в learnings.md + +- Конкретный урок из реального запуска, теста или ошибки агента. +- Что сработало / не сработало в конкретной задаче. +- Вопрос, требующий будущего расследования (Open Questions). + +### Как обновлять при ошибке + +Когда агент допустил ошибку и пользователь просит зафиксировать урок: +1. Абстрагируй: найди общий паттерн, а не конкретную деталь задачи. +2. Запиши в learnings.md под **What Has Failed**. +3. Если паттерн системный — добавь в **Patterns to avoid** в CLAUDE.md. +4. Укажи `Confidence: low` если урок из одного случая; `high` если повторялся. diff --git a/ROADMAP.md b/ROADMAP.md index 791a276..435bfb1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -61,13 +61,13 @@ TUI и команды Telegram — отложены. - [ ] **C-extension для SHA-256.** Через `cffi` или `ctypes` дёргать `EVP_DigestUpdate` из OpenSSL — даст 5–10× к хешрейту над pure-Python `hashlib`. - [ ] **SIMD-реализация SHA-256.** AVX2 (8 хешей параллельно) или AVX-512 (16). Можно взять готовое из репо `intel-ipsec-mb` или `sha-2-multihash`. Пишется как C-extension, дёргается из Python. -- [ ] **Mid-state кэширование.** Block header — это 80 байт, последние 12 (часть merkle, ntime, nbits, nonce) меняются. Первые 64 — статичны для одной работы, их SHA-256 mid-state можно посчитать один раз и переиспользовать. Х2 к хешрейту. +- [x] **Mid-state кэширование.** `hashlib.sha256().copy()` после первых 64 байт — константа в рамках nonce-цикла. Реализовано в `parallel.worker` (v0.3.0). Прирост ≈×1.5–2, zero deps. ### Архитектура - [ ] **Множественные пулы с failover.** Список `pool1, pool2, pool3` в конфиге, при потере pool1 — переключение на pool2 без остановки воркеров. - [ ] **Несколько воркеров на разных пулах одновременно.** Распределённая работа с разными адресами/именами. -- [ ] **Поддержка vardiff.** Сейчас принимаем сложность как есть — научиться запрашивать через `mining.suggest_difficulty` для CPU-friendly значения (низкая сложность = чаще шары = быстрее обратная связь). +- [x] **Поддержка vardiff.** `mining.suggest_difficulty` после авторизации. CLI-флаг `--suggest-diff FLOAT`. Реализовано в `stratum.py` (v0.3.0). ### Web-морда @@ -105,7 +105,7 @@ TUI и команды Telegram — отложены. Не путь развития, а отдельные мини-проекты, которые можно реализовать поверх кода. -- [ ] **Demo-режим без подключения к пулу.** Симулирует работу с искусственно низкой сложностью (`diff=0.0001`), шары валятся часто, можно визуально пройти весь цикл. Идеально для презентаций и обучения. +- [x] **Demo-режим без подключения к пулу.** `hope-hash --demo [--demo-diff DIFF]`. Синтетический заголовок, low-diff target, multiprocessing-воркеры. Реализовано в `demo.py` (v0.3.0). - [ ] **«Гуманизированная» статистика.** «При твоём хешрейте средний шанс найти блок: раз в 47 миллиардов лет». Считается из текущего сетевого difficulty (получаем через bitcoin-core RPC или публичные API типа `mempool.space`). - [ ] **Lottery-визуализация.** Каждый хеш — точка на canvas. Цвет = первые 3 байта хеша. Просто красивая бесконечная анимация. Можно собрать на `pygame` или в браузере через WebSocket. - [ ] **Бенчмарк-режим.** Прогоняет одну и ту же работу через все доступные backend-ы (pure Python, ctypes, C-extension, Rust, OpenCL) и печатает таблицу хешрейтов. Хороший способ показать, насколько Python медленный. diff --git a/learnings.md b/learnings.md new file mode 100644 index 0000000..c9ac887 --- /dev/null +++ b/learnings.md @@ -0,0 +1,96 @@ +# learnings.md — Hope-Hash self-learning log + +Живая память агента между сессиями. Формат: + +``` +**[YYYY-MM-DD] — [тип задачи]** +- Observation: конкретное наблюдение +- Action: что делать / чего избегать +- Confidence: high / medium / low +``` + +Архивировать при превышении 80–100 строк. + +--- + +## What Has Worked + +**[2026-04-30] — Performance: mid-state SHA-256** +- Observation: `hashlib.sha256().copy()` после первых 64 байт block header + (версия + prevhash + merkle_root[:28]) корректно воспроизводит full double_sha256. + Тесты `TestMidstateSha256` подтвердили идентичность на 3 векторах. +- Action: для любого SHA-256 hot-path проверять, есть ли константный 64-байтный + префикс, который можно pre-compute. Два `update()` вместо конкатенации — без разницы + по производительности, но читабельнее. +- Confidence: high + +**[2026-04-30] — Bug fix: queue exception handling** +- Observation: `except Exception: pass` на `Queue.get_nowait()` маскировал реальные + ошибки (unpickling, закрытый queue). Правильный паттерн — `except queue.Empty:`. +- Action: ALWAYS использовать `queue.Empty` при `get_nowait()` / `get(timeout=...)`. + NEVER bare `except Exception:` в tight loops. +- Confidence: high + +**[2026-04-30] — Timing: perf_counter vs time** +- Observation: `time.time()` может «прыгать» при NTP-синхронизации. EMA-сэмплы + при этом давали отрицательное или аномально большое delta-t. +- Action: `time.perf_counter()` для всех относительных интервалов (EMA, alive-check, + drain deadline). `time.time()` только для абсолютных меток (SQLite timestamp, + Telegram event time). +- Confidence: high + +**[2026-04-30] — Architecture: observer callback pattern** +- Observation: `on_share_result: Optional[Callable[[int, bool], None]]` на `StratumClient` + позволяет `mine()` подписаться на pool responses без coupling между stratum и storage. + `_submit_req_ids: set` + отдельный `_submit_lock` изолируют submit-ответы от + других ответов пула (suggest_difficulty, authorize). +- Action: для любой новой «ожидаемой» Stratum-операции — добавлять отдельный + `req_id` tracking, а не ловить все `result` ответы в `_handle_message`. +- Confidence: high + +--- + +## What Has Failed + +**[2026-04-30] — Audit depth: первичный аудит пропустил семантический баг** +- Observation: `store.record_share(accepted=True)` в `miner.py` записывает шар как + «принят», хотя это означает лишь «отправлен клиентом». Пул может отклонить. + Обнаружено только при втором глубоком аудите. +- Action: при добавлении observer-хуков проверять семантику булевых флагов. + `submitted` ≠ `accepted`. Подумать про отдельный флаг или callback на pool response. +- Confidence: medium + +--- + +**[2026-04-30] — Protocol: authorize response check** +- Observation: `subscribe_and_authorize` не читала ответ на `mining.authorize`. + Пул мог отклонить авторизацию (wrong address format, banned worker), майнер + продолжал работу в «немом» режиме, все submits молча отклонялись. +- Action: после любого Stratum-запроса с ответом — loop `while True` до нужного + `id`, побочные сообщения через `_handle_message`. Pattern уже был для subscribe. +- Confidence: high + +## Patterns and Preferences + +- IPC-объекты (Queue, Value, Event) ВСЕГДА создаются в main process, не в воркерах. + Windows spawn-mode требует pickle-able аргументов — hashlib объект не pickle-able. +- multiprocessing.Queue ВСЕГДА нужно drain перед join() на Windows (deadlock). +- Observers (store/metrics/notifier) подключаются опциональными хуками; `None = disabled`. + Не смешивать бизнес-логику с observer-логикой. +- hot-path в `parallel.worker`: никаких Python-level оптимизаций без бенчмарка до/после. + Baseline замеряется через `[stats]` строки, не через синтетический timeit. + +--- + +## Open Questions + +- [x] `store.record_share(accepted=True)` — ИСПРАВЛЕНО в v0.3.0. Теперь `accepted=False` + при записи, `on_share_result` callback обновляет через `update_share_accepted()`. +- [x] `client.difficulty` race condition — ИСПРАВЛЕНО. `job_lock` покрывает difficulty + writer (stratum) и reader (mine()); локальная `current_diff` на весь job-цикл. +- [ ] `mining.suggest_difficulty` — CKPool реально снижает difficulty в ответ? + Проверить в production-запуске с `--suggest-diff 0.001`. +- [ ] `hashlib.copy()` overhead: насколько дешевле на CPython 3.11 vs 3.12 vs 3.13? + CI проходит на всех версиях, но benchmark не делали. +- [x] Submit response tracking — ИСПРАВЛЕНО. `_submit_req_ids` + `_submit_lock` в + `StratumClient` изолируют mining.submit ответы от остальных. diff --git a/src/hope_hash/__init__.py b/src/hope_hash/__init__.py index 9af66f7..4ba4a3d 100644 --- a/src/hope_hash/__init__.py +++ b/src/hope_hash/__init__.py @@ -1,13 +1,14 @@ """Hope-Hash — учебный solo BTC miner на чистом stdlib.""" from .block import build_merkle_root, difficulty_to_target, double_sha256, swap_words +from .demo import run_demo from .metrics import Metrics, MetricsServer from .miner import mine from .notifier import TelegramNotifier from .storage import ShareStore from .stratum import StratumClient -__version__ = "0.2.0" +__version__ = "0.3.0" __all__ = [ "double_sha256", "swap_words", @@ -15,6 +16,7 @@ "build_merkle_root", "StratumClient", "mine", + "run_demo", "ShareStore", "Metrics", "MetricsServer", diff --git a/src/hope_hash/block.py b/src/hope_hash/block.py index d161a6a..a8e5be5 100644 --- a/src/hope_hash/block.py +++ b/src/hope_hash/block.py @@ -20,6 +20,8 @@ def swap_words(hex_str: str) -> bytes: в заголовок блока его надо положить именно так. Главный gotcha Stratum V1. """ raw = bytes.fromhex(hex_str) + if len(raw) % 4 != 0: + raise ValueError(f"swap_words требует кратность 4 байтам, получено {len(raw)}") return b"".join(raw[i:i+4][::-1] for i in range(0, len(raw), 4)) @@ -28,6 +30,8 @@ def difficulty_to_target(diff: float) -> int: Pool-сложность → численный target. Шар принимается, если SHA256d(header) <= target. diff=1 соответствует базовому target Bitcoin diff-1. """ + if diff <= 0: + raise ValueError(f"difficulty должна быть положительной, получено {diff}") return int(DIFF1_TARGET / diff) diff --git a/src/hope_hash/cli.py b/src/hope_hash/cli.py index 649c535..b49c0ec 100644 --- a/src/hope_hash/cli.py +++ b/src/hope_hash/cli.py @@ -3,6 +3,7 @@ import argparse import multiprocessing import os +import sys import threading import time from pathlib import Path @@ -30,7 +31,9 @@ def _parse_args() -> argparse.Namespace: prog="hope_hash", description="Учебный solo BTC miner на чистом stdlib.", ) - parser.add_argument("btc_address", help="BTC-адрес для выплат (на него уйдёт награда).") + parser.add_argument("btc_address", nargs="?", default=None, + help="BTC-адрес для выплат (на него уйдёт награда). " + "Не нужен в режиме --demo.") parser.add_argument("worker_name", nargs="?", default="py01", help="Имя воркера (по умолчанию: py01).") parser.add_argument( @@ -49,6 +52,22 @@ def _parse_args() -> argparse.Namespace: "--metrics-port", type=int, default=9090, help="Порт Prometheus /metrics (по умолчанию: 9090, 0 — отключить).", ) + parser.add_argument( + "--suggest-diff", type=float, default=None, + metavar="DIFF", + help="Запросить у пула эту сложность после авторизации (vardiff). " + "Пример: --suggest-diff 0.001", + ) + parser.add_argument( + "--demo", action="store_true", + help="Запустить demo-режим без подключения к пулу: " + "ищет nonce для синтетического блока с низкой сложностью.", + ) + parser.add_argument( + "--demo-diff", type=float, default=0.001, + metavar="DIFF", + help="Сложность для demo-режима (по умолчанию: 0.001).", + ) return parser.parse_args() @@ -62,6 +81,15 @@ def main(): args = _parse_args() n_workers = max(1, args.workers) + if args.demo: + from .demo import run_demo + run_demo(n_workers=n_workers, diff=args.demo_diff) + return + + if not args.btc_address: + print("error: btc_address обязателен (или используйте --demo)", file=sys.stderr) + sys.exit(2) + # ─── observers ─── # Все три опциональны и не зависят друг от друга. Каждый сам решает, # включаться ли (notifier — по env vars; metrics — по порту; store — по флагу). @@ -87,7 +115,7 @@ def main(): # ─── сетевая часть и mine() ─── stop = threading.Event() client = StratumClient(POOL_HOST, POOL_PORT, args.btc_address, args.worker_name, - stop_event=stop) + stop_event=stop, suggest_diff=args.suggest_diff) # Сетевая часть живёт в отдельной нити-супервизоре: она держит коннект, # переподключается при разрывах и сама поднимает reader_loop. main thread diff --git a/src/hope_hash/demo.py b/src/hope_hash/demo.py new file mode 100644 index 0000000..55d5282 --- /dev/null +++ b/src/hope_hash/demo.py @@ -0,0 +1,70 @@ +"""Demo-режим: майнинг без подключения к пулу, с искусственно низкой сложностью. + +Используется для презентаций и проверки корректности pipeline без реальной сети. +Берёт синтетический 80-байтный block header, ищет nonce с помощью тех же +multiprocessing-воркеров, что и реальный майнинг, и завершается при первой находке. + +Запуск: + hope-hash --demo [--workers N] [--demo-diff DIFF] +""" + +import queue +import struct +import time + +from ._logging import logger +from .block import difficulty_to_target +from .parallel import start_pool, stop_pool + + +def run_demo(n_workers: int = 1, diff: float = 0.001) -> None: + """ + Один раунд demo-майнинга. + + Параметры: + n_workers — число параллельных процессов (как в реальном майнинге). + diff — желаемая сложность. 0.001 → ~4 млн хешей в среднем, + при ~200 KH/s на воркер ожидание ≈ 5–20с. + """ + # Синтетический 76-байтовый header_base: + # version(4) + prevhash(32) + merkle_root(32) + ntime(4) + nbits(4) + ntime_le = struct.pack(" threading.Thread: """ client.buf = b"" # буфер от прошлой сессии больше не валиден client.req_id = 0 + with client._submit_lock: + client._submit_req_ids.clear() # req_id-счётчик сбрасывается → старые id невалидны with client.job_lock: client.current_job = None # extranonce1 после reconnect может смениться client.connect() @@ -126,11 +129,37 @@ def mine( alpha = 0.3 report_interval = 5.0 + # Ожидающие ответа пула: req_id → share_db_id. + # submit() (mine-thread) пишет, on_share_result (reader-thread) читает → нужен lock. + _pending_submits: dict[int, int] = {} + _pending_lock = threading.Lock() + + def _on_share_result(req_id: int, accepted: bool) -> None: + with _pending_lock: + entry = _pending_submits.pop(req_id, None) + if entry is None: + return + share_id, job_id, diff = entry + if store is not None: + store.update_share_accepted(share_id, accepted) + if metrics is not None: + label = "hopehash_shares_accepted_total" if accepted else "hopehash_shares_rejected_total" + metrics.counter_inc(label, 1, + help="Shares confirmed accepted by pool" if accepted + else "Shares rejected by pool") + if notifier is not None and accepted: + notifier.notify_share_accepted(job_id=job_id, difficulty=diff) + + client.on_share_result = _on_share_result + while not stop_event.is_set(): with client.job_lock: job = client.current_job en1 = client.extranonce1 en2_size = client.extranonce2_size + # difficulty читается под тем же локом, которым stratum.py защищает запись, + # чтобы job + difficulty всегда были согласованной парой. + current_diff = client.difficulty if not job or not en1: time.sleep(0.5) continue @@ -142,16 +171,20 @@ def mine( extranonce2_counter += 1 header_base = _build_header_base(job, en1, extranonce2) - target = difficulty_to_target(client.difficulty) + target = difficulty_to_target(current_diff) current_job_id = job["job_id"] + # Шары с прошлого job никогда не получат ответ (job_id уже невалиден). + with _pending_lock: + _pending_submits.clear() + processes, found_queue, hashes_counter, mp_stop = start_pool( n_workers, header_base, target, extranonce2, ) prev_count = 0 - last_report = time.time() - last_alive_check = time.time() + last_report = time.perf_counter() + last_alive_check = time.perf_counter() # ─── основной цикл одного job ─── try: @@ -163,32 +196,32 @@ def mine( logger.warning( f"[mine] !!! НАЙДЕН ШАР !!! nonce={nonce_hex} hash={hash_hex}" ) + # Записываем шар как «отправлен, ответ ожидается» (accepted=False). + # Когда пул ответит, on_share_result обновит флаг через req_id. + share_id: Optional[int] = None + if store is not None: + share_id = store.record_share( + job_id=current_job_id, nonce_hex=nonce_hex, + hash_hex=hash_hex, difficulty=current_diff, + accepted=False, + ) try: - client.submit(current_job_id, en2, job["ntime"], nonce_hex) + req_id = client.submit(current_job_id, en2, job["ntime"], nonce_hex) + with _pending_lock: + # Кортеж: (share_id, job_id, difficulty) для callback. + _pending_submits[req_id] = (share_id, current_job_id, current_diff) except (OSError, AttributeError) as e: # submit может прийтись на момент reconnect — не валим майнер. logger.warning(f"[stratum] не удалось отправить шар: {e}") - # Хуки наблюдателей. Все опциональны — None означает disabled. - if store is not None: - store.record_share( - job_id=current_job_id, nonce_hex=nonce_hex, - hash_hex=hash_hex, difficulty=client.difficulty, - accepted=True, - ) if metrics is not None: metrics.counter_inc( "hopehash_shares_total", 1, - help="Total shares submitted (accepted by client side)", - ) - if notifier is not None: - notifier.notify_share_accepted( - job_id=current_job_id, difficulty=client.difficulty, + help="Total shares submitted to pool (pending pool confirmation)", ) - except Exception: - # Empty/queue closed — единственный нормальный путь выхода из while. + except queue.Empty: pass - now = time.time() + now = time.perf_counter() # 2. EMA-хешрейт. if now - last_report >= report_interval: @@ -199,7 +232,7 @@ def mine( logger.info( f"[stats] хешрейт ≈ {_format_rate(ema)} " f"(окно {_format_rate(sample)}) | " - f"pool diff = {client.difficulty} | workers = {len(processes)}" + f"pool diff = {current_diff} | workers = {len(processes)}" ) if metrics is not None: metrics.gauge_set( @@ -207,7 +240,7 @@ def mine( help="Current EMA hashrate in hashes per second", ) metrics.gauge_set( - "hopehash_pool_difficulty", float(client.difficulty), + "hopehash_pool_difficulty", float(current_diff), help="Current pool difficulty", ) metrics.gauge_set( diff --git a/src/hope_hash/parallel.py b/src/hope_hash/parallel.py index 83e5505..05b6cbc 100644 --- a/src/hope_hash/parallel.py +++ b/src/hope_hash/parallel.py @@ -13,12 +13,13 @@ pickle-able (bytes, int, multiprocessing.Queue/Value/Event). """ +import hashlib import multiprocessing as mp +import queue import struct import time from ._logging import logger -from .block import double_sha256 # Каждые столько хешей воркер сверяется со stop_event и инкрементирует @@ -49,13 +50,26 @@ def worker( Сам ``submit`` делается из main process — здесь только находка. Сигнатура полностью pickle-able: bytes/int/str + примитивы mp. """ + # 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 + local_hashes = 0 # копим локально, чтобы реже дёргать Lock на Value nonce = nonce_start try: while nonce < nonce_end: - # Хвост блока: 4 байта nonce, LE. - header = header_base + struct.pack(" None: + """Обновляет флаг accepted по id записи (вызывается когда пул ответил).""" + with self._lock: + self._conn.execute( + "UPDATE shares SET accepted=? WHERE id=?", + (int(accepted), share_id), + ) + self._conn.commit() + logger.info("[storage] share id=%s → accepted=%s", share_id, accepted) + def start_session(self, pool_host: str, btc_address: str, worker_name: str) -> int: """Регистрирует начало сессии. Возвращает session_id.""" with self._lock: diff --git a/src/hope_hash/stratum.py b/src/hope_hash/stratum.py index 80d9002..d46450c 100644 --- a/src/hope_hash/stratum.py +++ b/src/hope_hash/stratum.py @@ -3,13 +3,15 @@ import json import socket import threading +from typing import Callable, Optional from ._logging import logger class StratumClient: def __init__(self, host: str, port: int, btc_address: str, worker_name: str = "py01", - stop_event: threading.Event = None): + stop_event: threading.Event = None, + suggest_diff: Optional[float] = None): self.host = host self.port = port self.username = f"{btc_address}.{worker_name}" @@ -24,6 +26,16 @@ def __init__(self, host: str, port: int, btc_address: str, worker_name: str = "p # Общий флаг остановки: даёт reader_loop и mine() согласованно завершаться, # чтобы при ошибке в одной нити вторая не «висла» молча. self.stop_event = stop_event if stop_event is not None else threading.Event() + self.suggest_diff = suggest_diff + + # Callback (req_id: int, accepted: bool) → None, вызывается из reader_loop + # когда пул отвечает на mining.submit. Устанавливается в mine(). + self.on_share_result: Optional[Callable[[int, bool], None]] = None + # req_id-ы отправленных mining.submit — чтобы отличать их от других ответов. + # Защищены _submit_lock, т.к. submit() вызывается из mine(), а ответы + # приходят в reader_loop. + self._submit_req_ids: set[int] = set() + self._submit_lock = threading.Lock() def connect(self): self.sock = socket.create_connection((self.host, self.port), timeout=30) @@ -59,15 +71,31 @@ def subscribe_and_authorize(self): ) break self._handle_message(msg) - self._send("mining.authorize", [self.username, "x"]) + auth_id = self._send("mining.authorize", [self.username, "x"]) logger.info(f"[stratum] authorize отправлен для воркера {self.username}") + # Ждём ответ на authorize — пул может прислать set_difficulty/notify раньше. + # Зеркально тому, как обрабатывается ответ на subscribe выше. + while True: + msg = json.loads(self._recv_line()) + if msg.get("id") == auth_id: + if msg.get("result") is not True: + err = msg.get("error", "unknown") + raise ConnectionError(f"[stratum] authorize отклонён: {err}") + logger.info(f"[stratum] authorize принят для {self.username}") + break + self._handle_message(msg) + if self.suggest_diff is not None: + self.suggest_difficulty(self.suggest_diff) def _handle_message(self, msg: dict): method = msg.get("method") params = msg.get("params", []) or [] if method == "mining.set_difficulty": - self.difficulty = float(params[0]) + # job_lock защищает difficulty так же, как current_job и extranonce: + # mine() читает все три атомарно в одном with-блоке. + with self.job_lock: + self.difficulty = float(params[0]) logger.info(f"[stratum] новая сложность: {self.difficulty}") elif method == "mining.set_extranonce": @@ -99,10 +127,19 @@ def _handle_message(self, msg: dict): logger.info(f"[stratum] новая работа job_id={params[0]} clean={params[8]}") elif msg.get("id") and "result" in msg: - if msg["result"] is True: - logger.info(f"[stratum] *** ШАР ПРИНЯТ *** (id={msg['id']})") - elif msg.get("error"): - logger.warning(f"[stratum] ошибка: {msg['error']}") + req = msg["id"] + with self._submit_lock: + is_submit = req in self._submit_req_ids + if is_submit: + self._submit_req_ids.discard(req) + if is_submit: + accepted = msg["result"] is True + if accepted: + logger.info(f"[stratum] *** ШАР ПРИНЯТ *** (id={req})") + else: + logger.warning(f"[stratum] шар отклонён (id={req}): {msg.get('error')}") + if self.on_share_result is not None: + self.on_share_result(req, accepted) def reader_loop(self): """ @@ -125,8 +162,17 @@ def reader_loop(self): logger.warning(f"[net] битый JSON от пула: {e}") continue - def submit(self, job_id, extranonce2, ntime, nonce_hex): - self._send("mining.submit", [self.username, job_id, extranonce2, ntime, nonce_hex]) + def suggest_difficulty(self, diff: float) -> None: + """Запрашивает у пула предпочтительную сложность (vardiff).""" + self._send("mining.suggest_difficulty", [diff]) + logger.info(f"[stratum] запрошена сложность {diff}") + + def submit(self, job_id, extranonce2, ntime, nonce_hex) -> 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): """Аккуратно гасим сокет: recv() в reader_loop разблокируется и нить выйдет.""" diff --git a/tests/test_block.py b/tests/test_block.py index 0cc4f7b..6c8849b 100644 --- a/tests/test_block.py +++ b/tests/test_block.py @@ -65,6 +65,11 @@ def test_returns_bytes(self): # На вход — hex-строка, на выход — именно bytes (а не hex). self.assertIsInstance(swap_words("00000000"), bytes) + def test_non_multiple_of_4_raises(self): + # 3 байта (6 hex символов) — не кратно 4: должна быть ValueError. + with self.assertRaises(ValueError): + swap_words("010203") + class TestDifficultyToTarget(unittest.TestCase): """Pool difficulty -> численный target. diff=1 — это базовый Bitcoin diff-1.""" @@ -83,6 +88,14 @@ def test_difficulty_1024(self): # diff=1024 — diff-1 / 1024. Типичный порядок для соло-пулов. self.assertEqual(difficulty_to_target(1024), int(self.DIFF1_TARGET / 1024)) + def test_zero_difficulty_raises(self): + with self.assertRaises(ValueError): + difficulty_to_target(0) + + def test_negative_difficulty_raises(self): + with self.assertRaises(ValueError): + difficulty_to_target(-1.0) + def test_higher_diff_means_smaller_target(self): # Чем больше сложность, тем меньше target — инварианта майнинга. self.assertLess(difficulty_to_target(1024), difficulty_to_target(1.0)) @@ -126,5 +139,51 @@ def test_returns_32_bytes(self): self.assertEqual(len(result), 32) +class TestMidstateSha256(unittest.TestCase): + """Mid-state оптимизация через hashlib.copy() даёт тот же результат, что double_sha256.""" + + def _midstate_double_sha256(self, data: bytes) -> bytes: + """Эмулирует логику из parallel.worker: split на первые 64 байта + хвост.""" + inner_mid = __import__("hashlib").sha256() + inner_mid.update(data[:64]) + tail = data[64:] + inner_h = inner_mid.copy() + inner_h.update(tail) + return __import__("hashlib").sha256(inner_h.digest()).digest() + + def test_midstate_matches_full_hash_80bytes(self): + # Канонический 80-байтный block header: убеждаемся, что mid-state даёт + # тот же результат, что прямой double_sha256 на полных данных. + header = b"\xab\xcd" * 40 # 80 байт произвольных данных + self.assertEqual(self._midstate_double_sha256(header), double_sha256(header)) + + def test_midstate_matches_known_header(self): + # Реалистичные данные: version(4) + prevhash(32) + merkle(32) + ntime(4) + nbits(4) + nonce(4) + 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) + self.assertEqual(self._midstate_double_sha256(header), double_sha256(header)) + + def test_midstate_different_nonces_give_different_hashes(self): + # Гарантируем, что copy() не «залипает» на одном состоянии между итерациями. + base = b"\x00" * 76 + import hashlib, struct + mid = hashlib.sha256() + mid.update(base[:64]) + tail = base[64:] + hashes = set() + for n in range(10): + h = mid.copy() + h.update(tail) + h.update(struct.pack("