From 8ec0296bcdaa456f0641f8fb063511c21e570f06 Mon Sep 17 00:00:00 2001
From: devAsmodeus
Date: Thu, 30 Apr 2026 23:19:00 +0300
Subject: [PATCH] =?UTF-8?q?feat:=20v0.3.0=20=E2=80=94=20mid-state=20SHA-25?=
=?UTF-8?q?6=20+=20deep=20audit=20fixes=20(9=20issues)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Performance:
- Mid-state SHA-256 via hashlib.copy() in parallel.worker: ≈×1.5-2 hashrate
- time.perf_counter() for EMA/alive-check (was time.time(), NTP-sensitive)
Correctness fixes:
- store.record_share(accepted=False) — records as pending, updated via
on_share_result callback when pool confirms (req_id → share_db_id mapping)
- client.difficulty protected by job_lock (writer in _handle_message,
reader in mine() — consistent pair with current_job)
- mining.authorize response verified; raises ConnectionError on rejection
- _submit_req_ids cleared on reconnect (req_id counter resets to 0)
- notify_share_accepted moved to pool-confirmed callback (was firing on submit)
- block.py: difficulty_to_target(<=0) and swap_words(non-mult-of-4) raise ValueError
- except queue.Empty instead of bare except Exception in tight loops
New features:
- demo.py: offline demo mode (--demo --demo-diff --workers)
- stratum.py: suggest_difficulty() + --suggest-diff CLI flag
- Prometheus: hopehash_shares_accepted_total + hopehash_shares_rejected_total
- storage.py: update_share_accepted(share_id, accepted)
Docs:
- CLAUDE.md: META section (self-improvement rules), updated package list
- learnings.md: live self-learning log (new file)
- CHANGELOG.md, ROADMAP.md updated
Tests: 59 → 64 (+5 new tests)
Co-Authored-By: Claude Sonnet 4.6
---
CHANGELOG.md | 22 +++++++++
CLAUDE.md | 69 ++++++++++++++++++++++++----
ROADMAP.md | 6 +--
learnings.md | 96 +++++++++++++++++++++++++++++++++++++++
src/hope_hash/__init__.py | 4 +-
src/hope_hash/block.py | 4 ++
src/hope_hash/cli.py | 32 ++++++++++++-
src/hope_hash/demo.py | 70 ++++++++++++++++++++++++++++
src/hope_hash/miner.py | 75 +++++++++++++++++++++---------
src/hope_hash/parallel.py | 28 +++++++++---
src/hope_hash/storage.py | 10 ++++
src/hope_hash/stratum.py | 64 ++++++++++++++++++++++----
tests/test_block.py | 59 ++++++++++++++++++++++++
tests/test_storage.py | 14 ++++++
14 files changed, 501 insertions(+), 52 deletions(-)
create mode 100644 learnings.md
create mode 100644 src/hope_hash/demo.py
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("