Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,43 @@

## [Unreleased]

## [0.4.0] — 2026-04-30

### Добавлено
- **Бенчмарк-режим** (`bench.py`): `hope-hash --benchmark [--bench-duration SEC]`.
Меряет pure-Python хешрейт без сети и без шар. Baseline для будущих
C/Rust/SIMD/GPU-оптимизаций (см. ROADMAP уровни 2–3). На Intel i7-12700H
даёт ~570 KH/s на воркер с mid-state SHA-256.
- **Pre-flight валидация BTC-адреса** (`address.py`): bech32 (BIP-173),
bech32m (BIP-350), Base58Check. Mainnet-only. Опечатки и testnet-адреса
отвергаются локально, до сетевого round-trip к пулу.
- **Тесты для StratumClient** (`test_stratum.py`): 15 тестов протокольного
слоя через FakeSocket-фикстуру (subscribe/authorize/notify/set_difficulty/
set_extranonce/submit/reader_loop). Закрывает крупнейшую неоттестированную
поверхность кода.
- Тест на demo-режим в spawn-подпроцессе.
- Тесты на адресную валидацию (18 шт).
- Тесты на бенчмарк (3 шт).
- Всего **101 тест** (было 64 до v0.3 audit tail).

### Изменено
- `parallel.stop_pool`: магическое 0.2с-окно drain-а заменено на
drain-until-`queue.Empty` с safety-cap. К моменту вызова все находки
уже в очереди или feeder-буфере воркера.
- `miner.supervisor_loop`: `logger.error()` → `logger.exception()` —
unexpected ошибки теперь пишут полный traceback, а не молча
маскируются однострочным логом.
- `miner.mine`: при потере шара (OSError на reconnect) лог теперь
включает `job_id`/`nonce`/`hash` — оператор может найти запись в
SQLite. Намеренно не ретраим: stale-share может привести к ban'у.
- `stratum.py`: добавлены return-type аннотации, `submit()` параметры
типизированы, `_send` явно бросает OSError при `sock is None`.

### Исправлено
- `miner.mine`: проверка переполнения `extranonce2_counter` —
`f"{counter:0{en2_size*2}x}"` без проверки молча обрубал старшие
биты при `counter >= 2^(en2_size*8)`. Теперь wrap с warning-логом.

## [0.3.0] — 2026-04-30

### Добавлено
Expand Down
31 changes: 29 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
│ ├── block.py ← double_sha256, swap_words, target, merkle
│ ├── address.py ← валидация BTC-адресов (bech32/bech32m/Base58Check)
│ ├── demo.py ← offline-майнинг (--demo)
│ ├── bench.py ← бенчмарк хешрейта (--benchmark)
│ ├── storage.py ← SQLite журнал шаров и сессий
│ ├── metrics.py ← Prometheus экспортёр (http.server)
│ ├── notifier.py ← Telegram через urllib
Expand All @@ -100,7 +101,8 @@
├── test_metrics.py ← 16 тестов на Prometheus экспортёр
├── test_notifier.py ← 16 тестов на Telegram (через mock)
├── test_address.py ← 18 тестов на валидацию BTC-адреса
└── test_stratum.py ← 15 тестов на Stratum-протокол (FakeSocket)
├── test_stratum.py ← 15 тестов на Stratum-протокол (FakeSocket)
└── test_bench.py ← 3 теста на бенчмарк-режим
```

---
Expand Down Expand Up @@ -144,6 +146,31 @@ hope-hash --demo --workers 4 # сколько процессов пе

Demo не нуждается в BTC-адресе и не делает никаких сетевых вызовов — удобно для smoke-теста на машине, где нужно убедиться, что multiprocessing-воркеры стартуют корректно.

**Бенчмарк-режим:**

```bash
hope-hash --benchmark # 10 секунд на cpu_count-1 воркерах
hope-hash --benchmark --bench-duration 30 # длиннее = точнее число
hope-hash --benchmark --workers 1 # baseline для одного ядра
```

Меряет pure-Python хешрейт без сети и без шар. Полезно как точка отсчёта перед C/Rust/SIMD-оптимизациями (см. ROADMAP уровни 2–3): без числа «до» сравнивать числа «после» бессмысленно. Пример вывода на Intel i7-12700H, 4 воркера, 5 секунд:

```
[bench] platform: Windows-10-10.0.26200-SP0
[bench] python: 3.11.9 (cpython)
[bench] cpu: 16 logical cores (Intel Family 6 Model 151)
[bench] workers: 4, duration: 5.0s
[bench] t= 1.0s hashes= 2,654,208 rate=2.64 MH/s
[bench] t= 2.0s hashes= 5,914,624 rate=2.93 MH/s
[bench] t= 3.0s hashes= 9,093,120 rate=3.01 MH/s
[bench] === result ===
[bench] total hashes: 11,436,032
[bench] wall time: 5.01s
[bench] hashrate: 2.28 MH/s
[bench] per-worker: 570.23 KH/s (workers: 4)
```

**Валидация BTC-адреса** срабатывает локально перед подключением к пулу. Принимаются только mainnet-адреса:
- `bc1q...` (P2WPKH, P2WSH) — bech32, BIP-173
- `bc1p...` (Taproot) — bech32m, BIP-350
Expand All @@ -162,7 +189,7 @@ hope-hash <BTC_адрес>
**Тесты:**

```bash
python -m unittest discover -s tests -v # 97 тестов
python -m unittest discover -s tests -v # 101 тест
```

**Prometheus-метрики, экспортируемые на `/metrics`:**
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ TUI и команды Telegram — отложены.
- [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 медленный.
- [x] **Бенчмарк-режим (pure-Python).** `hope-hash --benchmark [--bench-duration SEC]`. Меряет хешрейт на синтетическом заголовке с target=0, репортит платформу/CPU/python и H/s. Реализовано в `bench.py` (v0.4.0). Baseline на Intel i7-12700H: ~570 KH/s/worker. Multi-backend сравнение (ctypes/C-extension/Rust/OpenCL) — отдельная задача после Уровня 2.
- [ ] **Майнинг shitcoin-ов на той же базе.** Litecoin использует Scrypt, Dogecoin — тоже Scrypt. Если заменить хеш-функцию и пул, можно майнить с шансом найти блок раз в «всего» миллион лет вместо миллиарда. Кардинально меняется сложность кода.
- [ ] **«Майнер на ладошке».** Запаковать в `pyinstaller` или `nuitka` в один бинарник с TUI — чтобы можно было кому-то отдать `.exe` и они «майнили» (с нулевым шансом, но прикольно).
- [ ] **Лидерборд между друзьями.** Несколько твоих знакомых ставят майнер с разными worker-name на один и тот же BTC-адрес. Лидерборд показывает, кто из вас вносит больше хешрейта в общий пул. Если кто-то найдёт блок (хаха) — делите по вкладу.
Expand Down
5 changes: 4 additions & 1 deletion src/hope_hash/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Hope-Hash — учебный solo BTC miner на чистом stdlib."""

from .bench import BenchResult, run_benchmark
from .block import build_merkle_root, difficulty_to_target, double_sha256, swap_words
from .demo import run_demo
from .metrics import Metrics, MetricsServer
Expand All @@ -8,7 +9,7 @@
from .storage import ShareStore
from .stratum import StratumClient

__version__ = "0.3.0"
__version__ = "0.4.0"
__all__ = [
"double_sha256",
"swap_words",
Expand All @@ -17,6 +18,8 @@
"StratumClient",
"mine",
"run_demo",
"run_benchmark",
"BenchResult",
"ShareStore",
"Metrics",
"MetricsServer",
Expand Down
142 changes: 142 additions & 0 deletions src/hope_hash/bench.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
"""Бенчмарк-режим: измеряет pure-Python SHA-256 хешрейт без сети.

Используется как baseline перед оптимизациями (Rust/SIMD/GPU из ROADMAP
уровня 2-3): без числа «до» сравнивать с числом «после» нечестно.

Принцип: те же multiprocessing-воркеры, что и реальный майнинг (parallel.py),
крутят SHA256d на синтетическом 76-байтовом header_base. Target ставим в 0,
чтобы ни один хеш не прошёл — воркеры просто хешируют, не отвлекаясь на
submit-логику. Через `duration_s` секунд останавливаем и считаем H/s.

Запуск:
hope-hash --benchmark [--bench-duration 10] [--workers N]
"""

from __future__ import annotations

import multiprocessing
import platform
import struct
import sys
import time
from dataclasses import dataclass

from ._logging import logger
from .parallel import start_pool, stop_pool


@dataclass
class BenchResult:
"""Числа одного бенчмарк-прогона. Удобно для тестов и для JSON-экспорта."""
duration_s: float
n_workers: int
total_hashes: int
hashrate_hps: float

@property
def per_worker_hps(self) -> float:
return self.hashrate_hps / self.n_workers if self.n_workers > 0 else 0.0


def _make_header_base() -> bytes:
"""Синтетический 76-байтовый префикс header'а (всё кроме nonce).

Структура совпадает с реальным майнингом, чтобы mid-state-оптимизация
в worker() работала идентично — иначе бенчмарк измерял бы не тот код.
"""
ntime_le = struct.pack("<I", int(time.time()))
return (
b"\x01\x00\x00\x00" + # version
b"\x00" * 32 + # prevhash
b"\x00" * 32 + # merkle_root
ntime_le + # ntime
b"\xff\xff\x00\x1d" # nbits
)


def _format_rate(rate: float) -> str:
"""H/s → KH/s → MH/s. Дублирует _format_rate из miner.py намеренно:
bench.py не должен зависеть от внутренностей mine()."""
if rate < 1000:
return f"{rate:.0f} H/s"
if rate < 1_000_000:
return f"{rate / 1000:.2f} KH/s"
return f"{rate / 1_000_000:.2f} MH/s"


def run_benchmark(duration_s: float = 10.0, n_workers: int = 1) -> BenchResult:
"""Один прогон бенчмарка. Не делает сетевых вызовов и не находит шары.

Параметры:
duration_s — сколько секунд хешировать. Меньше 1 нет смысла —
накладные расходы на spawn доминируют.
n_workers — число процессов-воркеров (как в реальном майнинге).
"""
n_workers = max(1, int(n_workers))
duration_s = max(0.1, float(duration_s))
header_base = _make_header_base()
target = 0 # никакой хеш не пройдёт → воркеры хешируют без выходов
extranonce2 = "00000000"

logger.info(f"[bench] platform: {platform.platform()}")
logger.info(
f"[bench] python: {platform.python_version()} "
f"({sys.implementation.name})"
)
logger.info(
f"[bench] cpu: {multiprocessing.cpu_count()} logical cores"
f"{f' ({platform.processor()})' if platform.processor() else ''}"
)
logger.info(f"[bench] workers: {n_workers}, duration: {duration_s:.1f}s")
logger.info(f"[bench] running...")

processes, found_queue, hashes_counter, mp_stop = start_pool(
n_workers, header_base, target, extranonce2,
)

start = time.perf_counter()
# Промежуточные сэмплы — ~5 точек за прогон, чтобы пользователь видел,
# что что-то происходит, но без лишнего шума в логе.
report_interval = max(1.0, duration_s / 5)
next_report = start + report_interval
try:
while time.perf_counter() - start < duration_s:
time.sleep(0.05)
now = time.perf_counter()
if now >= next_report:
with hashes_counter.get_lock():
cur = hashes_counter.value
elapsed = now - start
rate = cur / elapsed if elapsed > 0 else 0.0
logger.info(
f"[bench] t={elapsed:5.1f}s "
f"hashes={cur:>14,} rate={_format_rate(rate)}"
)
next_report = now + report_interval
finally:
# Замеряем elapsed ДО stop_pool, чтобы не учесть время остановки.
# Воркеры коммитят последние батчи в hashes_counter в finally-блоке,
# поэтому финальное значение читаем уже после stop_pool().
elapsed = time.perf_counter() - start
stop_pool(processes, found_queue, mp_stop)

with hashes_counter.get_lock():
total = hashes_counter.value
hashrate = total / elapsed if elapsed > 0 else 0.0

logger.info(f"[bench]")
logger.info(f"[bench] === result ===")
logger.info(f"[bench] total hashes: {total:>14,}")
logger.info(f"[bench] wall time: {elapsed:>14.2f}s")
logger.info(f"[bench] hashrate: {_format_rate(hashrate):>14}")
logger.info(
f"[bench] per-worker: "
f"{_format_rate(hashrate / n_workers):>14} (workers: {n_workers})"
)

return BenchResult(
duration_s=elapsed,
n_workers=n_workers,
total_hashes=total,
hashrate_hps=hashrate,
)
19 changes: 19 additions & 0 deletions src/hope_hash/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,16 @@ def _parse_args() -> argparse.Namespace:
metavar="DIFF",
help="Сложность для demo-режима (по умолчанию: 0.001).",
)
parser.add_argument(
"--benchmark", action="store_true",
help="Запустить бенчмарк pure-Python хешрейта (без сети, без шар). "
"Полезно как baseline перед оптимизациями (C/Rust/SIMD/GPU).",
)
parser.add_argument(
"--bench-duration", type=float, default=10.0,
metavar="SEC",
help="Длительность бенчмарка в секундах (по умолчанию: 10).",
)
return parser.parse_args()


Expand All @@ -82,6 +92,15 @@ def main():
args = _parse_args()
n_workers = max(1, args.workers)

if args.benchmark and args.demo:
print("error: --benchmark и --demo взаимоисключающи", file=sys.stderr)
sys.exit(2)

if args.benchmark:
from .bench import run_benchmark
run_benchmark(duration_s=args.bench_duration, n_workers=n_workers)
return

if args.demo:
from .demo import run_demo
run_demo(n_workers=n_workers, diff=args.demo_diff)
Expand Down
72 changes: 72 additions & 0 deletions tests/test_bench.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Тесты для bench.py: проверяют форму результата и факт хеширования.

Запускаем короткий бенчмарк (1с, 1 воркер) в spawn-подпроцессе — те же
причины, что в test_demo.py: multiprocessing на Windows плохо живёт
с pytest-нитями в том же процессе.
"""

import multiprocessing as mp
import sys
import unittest


def _bench_worker(duration_s: float, n_workers: int, queue: "mp.Queue") -> None:
from hope_hash.bench import run_benchmark

result = run_benchmark(duration_s=duration_s, n_workers=n_workers)
queue.put({
"duration_s": result.duration_s,
"n_workers": result.n_workers,
"total_hashes": result.total_hashes,
"hashrate_hps": result.hashrate_hps,
"per_worker_hps": result.per_worker_hps,
})


class TestRunBenchmark(unittest.TestCase):

def test_short_run_produces_positive_hashrate(self):
ctx = mp.get_context("spawn")
q: "mp.Queue" = ctx.Queue()
# 1 секунда хватает: даже на CI runner'ах за 1с pure-Python успеет
# сделать десятки тысяч хешей.
p = ctx.Process(target=_bench_worker, args=(1.0, 1, q))
p.start()
p.join(timeout=60)
self.assertFalse(p.is_alive(), "benchmark не завершился за 60 секунд")
self.assertEqual(p.exitcode, 0, "benchmark упал с ошибкой")

result = q.get_nowait()
self.assertEqual(result["n_workers"], 1)
self.assertGreater(result["total_hashes"], 0, "ни одного хеша не посчитано")
self.assertGreater(result["hashrate_hps"], 0, "хешрейт нулевой")
# per_worker_hps == hashrate_hps при n_workers=1
self.assertAlmostEqual(
result["per_worker_hps"], result["hashrate_hps"], delta=0.01
)
# Длительность должна быть близка к запрошенной (с допуском на overhead).
self.assertGreater(result["duration_s"], 0.5)
self.assertLess(result["duration_s"], 5.0)


class TestBenchResultDataclass(unittest.TestCase):
"""Чистые свойства BenchResult — без multiprocessing."""

def test_per_worker_calculation(self):
from hope_hash.bench import BenchResult

r = BenchResult(
duration_s=10.0, n_workers=4, total_hashes=4_000_000, hashrate_hps=400_000.0
)
self.assertEqual(r.per_worker_hps, 100_000.0)

def test_per_worker_zero_workers(self):
from hope_hash.bench import BenchResult

# Защита от деления на ноль: 0 воркеров → 0 H/s на воркера.
r = BenchResult(duration_s=1.0, n_workers=0, total_hashes=0, hashrate_hps=0.0)
self.assertEqual(r.per_worker_hps, 0.0)


if __name__ == "__main__":
unittest.main()
Loading