From 9d507948448668bf51e8fd03d933b575b330c4a7 Mon Sep 17 00:00:00 2001 From: KruglikovskiiPA Date: Thu, 30 Apr 2026 13:45:13 +0300 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20Hope-Hash=20v0.1.0=20?= =?UTF-8?q?=E2=80=94=20solo=20BTC=20miner=20=D0=BD=D0=B0=20=D1=87=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D0=BC=20stdlib?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Учебный соло-майнер биткоина. Полностью pure-Python, без runtime-зависимостей. Реализовано: - Stratum V1 клиент к solo.ckpool.org:3333 (TCP + JSON-RPC). - Цикл хеширования SHA-256 + сборка block header с корректным endianness/word-swap. - Reconnect с экспоненциальным backoff (1→60с), supervisor_loop. - Обработка mining.set_difficulty / mining.notify / mining.set_extranonce. - Чистый shutdown через общий threading.Event (без daemon-нитей). - Логирование через stdlib logging со стандартными уровнями. - 15 unittest-тестов на криптографические функции. Структура: src/-layout (hatchling), пакет hope_hash, console-script hope-hash + python -m hope_hash. CI matrix Python 3.11–3.13 × ubuntu/windows/macos. Co-Authored-By: Claude Opus 4.7 (1M context) --- .editorconfig | 15 +++ .github/workflows/ci.yml | 28 ++++++ .gitignore | 22 +++++ CHANGELOG.md | 23 +++++ CLAUDE.md | 113 +++++++++++++++++++++ LICENSE | 21 ++++ Makefile | 20 ++++ README.md | 201 ++++++++++++++++++++++++++++++++++++++ ROADMAP.md | 126 ++++++++++++++++++++++++ pyproject.toml | 62 ++++++++++++ src/hope_hash/__init__.py | 16 +++ src/hope_hash/__main__.py | 6 ++ src/hope_hash/_logging.py | 22 +++++ src/hope_hash/block.py | 39 ++++++++ src/hope_hash/cli.py | 57 +++++++++++ src/hope_hash/miner.py | 143 +++++++++++++++++++++++++++ src/hope_hash/py.typed | 0 src/hope_hash/stratum.py | 137 ++++++++++++++++++++++++++ tests/__init__.py | 0 tests/conftest.py | 1 + tests/test_block.py | 130 ++++++++++++++++++++++++ 21 files changed, 1182 insertions(+) create mode 100644 .editorconfig create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 ROADMAP.md create mode 100644 pyproject.toml create mode 100644 src/hope_hash/__init__.py create mode 100644 src/hope_hash/__main__.py create mode 100644 src/hope_hash/_logging.py create mode 100644 src/hope_hash/block.py create mode 100644 src/hope_hash/cli.py create mode 100644 src/hope_hash/miner.py create mode 100644 src/hope_hash/py.typed create mode 100644 src/hope_hash/stratum.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_block.py diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a3c904b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{md,yml,yaml,toml}] +indent_size = 2 + +[Makefile] +indent_style = tab diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4bcfac5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + name: Test (Python ${{ matrix.python }} on ${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + python: ["3.11", "3.12", "3.13"] + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + - name: Install package + run: pip install -e . + - name: Run unittest + run: python -m unittest discover -s tests -v diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0bf110a --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +dist/ +*.egg-info/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ +.tox/ +.venv/ +venv/ +env/ +*.log +.idea/ +.vscode/ +.DS_Store +Thumbs.db diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5a6620a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,23 @@ +# Changelog + +Все значимые изменения проекта отражены здесь. + +Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/), +проект придерживается [Semantic Versioning](https://semver.org/lang/ru/). + +## [Unreleased] + +## [0.1.0] — 2026-04-30 + +### Добавлено +- Stratum V1 клиент к `solo.ckpool.org:3333` (TCP + JSON-RPC). +- Цикл хеширования SHA-256 на чистом stdlib. +- Обработка `mining.set_difficulty`, `mining.notify`, `mining.set_extranonce`. +- Reconnect с экспоненциальным backoff (1→60с). +- Общий `stop_event` для чистого shutdown по Ctrl+C. +- Логирование через `logging` со стандартными уровнями. +- 15 юнит-тестов на криптографические функции (`unittest`). +- Реструктуризация в `src/`-layout с пакетом `hope_hash`. + +[Unreleased]: https://github.com/KruglikovskiiPA/Hope-Hash/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/KruglikovskiiPA/Hope-Hash/releases/tag/v0.1.0 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..65c2ed7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,113 @@ +# Hope-Hash — solo Bitcoin miner на чистом Python + +Учебный соло-майнер биткоина. Подключается к `solo.ckpool.org:3333`, +реализует Stratum V1 с нуля, перебирает SHA-256 в pure-Python, отправляет +шары. Цель — **разобраться, как работает Bitcoin mining изнутри**, а не +зарабатывать. Реальный шанс найти блок ≈ 1 к 10¹⁵ в день. + +См. README.md для деталей и ROADMAP.md для плана развития. + +## Tech stack + +- Python ≥3.11, **только стандартная библиотека** (socket, hashlib, json, + struct, threading, logging, argparse). Runtime-зависимостей нет. +- Layout: `src/`-style, пакет `hope_hash` под `src/hope_hash/`. +- Build backend: `hatchling`. Установка: `python -m pip install -e .` + (на Windows: `py -3.11 -m pip install -e .`). +- Тесты: stdlib `unittest`, в `tests/`. Запуск: `python -m unittest discover -s tests`. +- Запуск: `python -m hope_hash [имя_воркера]` или после + install — `hope-hash [имя_воркера]`. +- Протокол: Stratum V1 поверх TCP, JSON line-delimited. + +## Структура пакета + +- `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/miner.py` — `mine()`, `run_session()`, `supervisor_loop()`. +- `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 тестов на чистые функции. + +## Архитектура (не менять без обсуждения) + +- Один процесс, две нити: `mine()` крутит хеши в main thread, + `reader_loop` слушает пул в отдельной нити (НЕ daemon — для clean + shutdown). +- `current_job` защищён `threading.Lock`. `mine()` проверяет смену + `job_id` каждые ~16k хешей (`hashes & 0x3FFF == 0`). +- Общий `stop_event` связывает все нити: при ошибке в `reader_loop` + он ставит флаг → `mine()` корректно выходит. +- `supervisor_loop` обеспечивает reconnect с backoff 1→2→4→…→60с. +- Endianness: version/ntime/nbits — LE через `[::-1]`, prevhash — + word-swap (`swap_words`), merkle_root — as-is из `double_sha256`. + Это корректно, не трогать. + +## Conventions + +- Комментарии и docstrings — на русском, как в существующем коде. +- Имена логов: `[net]`, `[stratum]`, `[mine]`, `[stats]`, `[main]` — + держать единый стиль при добавлении нового. +- Объяснять «зачем», а не «что» (см. word-swap в коде как образец). +- Файлы документации: README.md (что есть), ROADMAP.md (что будет), + CLAUDE.md (правила для агента). + +## Patterns to avoid + +- ❌ Не добавлять зависимости (`pip install ...`) без явной просьбы. + Pure-Python — ключевое свойство проекта. +- ❌ Не переписывать endianness/word-swap «для красоты» — это + работает и протестировано вручную против реальных блоков. +- ❌ Не добавлять `try/except` вокруг каждой строки. Отказы сети + и парсинга — точечная обработка в `reader_loop`, `subscribe` и + `supervisor_loop`. +- ❌ Не трогать структуру `header_base` в `mine()` — это hot path, + любая «оптимизация» проверяется бенчмарком до и после. +- ❌ Не возвращать `print` вместо `logger.*` — переход уже сделан, + держать единый канал вывода. +- ❌ Не складывать новый код в корень репо. Всё runtime — под + `src/hope_hash/`, всё тестовое — под `tests/`. + +## Workflow preferences + +- Перед изменениями кода — сверяться с ROADMAP.md: если фича уже + там описана, использовать формулировки и приоритеты оттуда, а не + придумывать свои. +- Перед рефакторингом — спросить пользователя. Учебный код ценен + читаемостью; «улучшение архитектуры ради архитектуры» вредно. +- Английский — для технических терминов (`nonce`, `merkle root`), + русский — для прозы. Не смешивать в пределах одного предложения. + +## Запуск (для справки) + +```bash +# Установка один раз: +py -3.11 -m pip install -e . + +# Запуск (любой из вариантов): +hope-hash [имя_воркера] +python -m hope_hash [имя_воркера] + +# Тесты: +python -m unittest discover -s tests -v +``` + +## Self-learning loop (на будущее) + +Когда накопится опыт работы агента над проектом — создать `learnings.md` +с разделами **What Has Worked / What Has Failed / Patterns and Preferences +/ Open Questions**. Формат записи: + +``` +**[YYYY-MM-DD] — [тип задачи]** +- Observation: что замечено +- Action: что делать / чего избегать дальше +- Confidence: high / medium / low +``` + +Правила: архивировать при превышении 80–100 строк, удалять устаревшее, +не добавлять записи без конкретики (vague entries едят контекст). + +Сейчас файла нет — создать при первом реальном уроке. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c420f9b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Pavel Kruglikovskii + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..839e186 --- /dev/null +++ b/Makefile @@ -0,0 +1,20 @@ +.PHONY: install test lint typecheck run clean + +install: + pip install -e . + +test: + python -m unittest discover -s tests -v + +lint: + ruff check src tests + +typecheck: + mypy src + +run: + python -m hope_hash $(ARGS) + +clean: + rm -rf build dist *.egg-info .pytest_cache .mypy_cache .ruff_cache + find . -type d -name __pycache__ -exec rm -rf {} + diff --git a/README.md b/README.md new file mode 100644 index 0000000..3ef924d --- /dev/null +++ b/README.md @@ -0,0 +1,201 @@ +# Solo BTC Miner — учебный соло-майнер на Python + +> Рабочее имя проекта. Финальное название не выбрано — кандидаты см. в конце файла. + +Минимальный, но настоящий соло-майнер биткоина: подключается к публичному соло-пулу, реализует протокол Stratum V1 с нуля, перебирает SHA-256 в чистом Python и отправляет шары. Без зависимостей. + +Цель проекта — **разобраться, как работает Bitcoin mining изнутри**: protocol, block header, merkle tree, target, double-SHA-256. Это не способ заработать (см. раздел «Реалистичные ожидания»), а образовательный код, который можно пощупать руками и развивать дальше. + +--- + +## Статус: что уже сделано + +- [x] TCP-клиент к `solo.ckpool.org:3333` через стандартную `socket` +- [x] JSON-RPC поверх Stratum V1 +- [x] `mining.subscribe` — получение `extranonce1` и `extranonce2_size` +- [x] `mining.authorize` — аутентификация под BTC-адресом +- [x] Обработка `mining.set_difficulty` (динамическая сложность пула) +- [x] Обработка `mining.notify` (получение свежей работы) +- [x] Сборка coinbase-транзакции (`coinb1 + extranonce1 + extranonce2 + coinb2`) +- [x] Вычисление merkle root через ветки от пула +- [x] Корректная сборка 80-байтного block header (с правильным word-swap для prevhash) +- [x] Цикл перебора `nonce` 0…2³² с double-SHA-256 +- [x] Сравнение хеша с pool target → отправка `mining.submit` при попадании +- [x] Фоновая нить чтения сообщений от пула, защита `current_job` через `Lock` +- [x] Прерывание цикла при получении свежей работы (clean job) +- [x] Печать хешрейта раз в 5 секунд + +**Уровень 0 — стабилизация: завершён.** + +- [x] Reconnect с экспоненциальным backoff (1→2→4→…→60с) +- [x] Обработка `mining.set_extranonce` +- [x] Корректное завершение по Ctrl+C (общий `stop_event`, `client.close()`, `join`) +- [x] `logging` вместо `print` со стандартными уровнями +- [x] 15 юнит-тестов на криптографические функции (`unittest`) +- [x] `src/`-layout, пакет `hope_hash`, `pyproject.toml` (hatchling) +- [x] CI matrix Python 3.11/3.12/3.13 × ubuntu/windows/macos +- [ ] Конфиг через CLI/YAML — перенесено в Уровень 1 + +**Не сделано / известные ограничения:** + +- Один поток → ~50–200 KH/s на ноуте. Нет multiprocessing. +- Нет персистентной статистики (логов, БД). +- Нет UI — только консоль через `logging`. +- Только Stratum V1, без `mining.suggest_difficulty` и без Stratum V2. + +--- + +## Структура + +``` +. +├── README.md ← этот файл +├── ROADMAP.md ← план развития, расставленный по сложности +├── CHANGELOG.md ← история версий (Keep a Changelog) +├── CLAUDE.md ← правила для AI-ассистента +├── LICENSE ← MIT +├── pyproject.toml ← метаданные + hatchling backend +├── Makefile ← short-cuts: install / test / run / lint +├── .github/workflows/ci.yml ← matrix Python 3.11–3.13 × ubuntu/windows/macos +├── src/hope_hash/ +│ ├── __init__.py ← публичный API + __version__ +│ ├── __main__.py ← `python -m hope_hash` +│ ├── cli.py ← argparse, точка входа +│ ├── miner.py ← mine(), supervisor_loop, run_session +│ ├── stratum.py ← StratumClient (TCP + JSON-RPC) +│ ├── block.py ← double_sha256, swap_words, target, merkle +│ ├── _logging.py ← настройка logger("hope_hash") +│ └── py.typed ← PEP 561 marker +└── tests/ + ├── conftest.py ← общие фикстуры (заготовка) + └── test_block.py ← 15 unittest-тестов на чистые функции +``` + +--- + +## Установка и запуск + +Никаких runtime-зависимостей, нужен только Python ≥3.11. + +```bash +# Установка один раз (editable): +python -m pip install -e . + +# Запуск любым из способов: +hope-hash [имя_воркера] +python -m hope_hash [имя_воркера] +``` + +**Пример:** + +```bash +hope-hash bc1q5n2x4pvxhq8sxc7ck3uxq8sxc7ck3uxqzfm2py mylaptop +``` + +**Тесты:** + +```bash +python -m unittest discover -s tests -v +``` + +BTC-адрес нужен валидный (любой формат: `1...`, `3...`, `bc1q...`, `bc1p...`). Можно завести в любом некастодиальном кошельке — например, **Sparrow**, **Electrum**, **Wasabi**. Имя воркера — произвольная строка. + +**Что увидишь:** + +``` +[net] подключён к solo.ckpool.org:3333 +[stratum] subscribed: extranonce1=ab12cd34, en2_size=4 +[stratum] authorize отправлен для воркера bc1q....mylaptop +[stratum] новая сложность: 1.0 +[stratum] новая работа job_id=4f2 clean=true +[stats] хешрейт ≈ 87 KH/s | pool diff = 1.0 +[stratum] *** ШАР ПРИНЯТ *** (id=3) +... +``` + +`*** ШАР ПРИНЯТ ***` означает, что ты честно работаешь и пул это видит — **это не заработок**. Реальная награда наступит только при `НАЙДЕН ШАР` с хешем ниже **сетевого** target (не пулового), что соответствует найденному блоку. + +--- + +## Архитектура + +``` +┌───────────────────────────────────────────┐ +│ solo.ckpool.org:3333 │ +└─────────────────┬─────────────────────────┘ + │ TCP + JSON line-delimited + │ +┌─────────────────▼─────────────────────────┐ +│ StratumClient (main thread) │ +│ • subscribe / authorize │ +│ • держит current_job под Lock │ +└──────┬──────────────────────────┬─────────┘ + │ │ + │ читает входящие │ держит работу + ▼ ▼ +┌─────────────┐ ┌─────────────────┐ +│ reader_loop │ │ mine() loop │ +│ (thread) │ │ (main thread) │ +│ │ │ │ +│ обновляет │ │ 1. coinbase │ +│ current_job │ │ 2. merkle root │ +│ при notify │ │ 3. header base │ +└─────────────┘ │ 4. nonce++ │ + │ 5. SHA256d │ + │ 6. compare │ + │ vs target │ + │ 7. submit ─────┼──> через client + └─────────────────┘ +``` + +Один процесс, две нити: одна крутит хеши, вторая слушает пул. Свежий `mining.notify` обновляет `current_job` под локом, цикл хеширования каждые ~16k итераций проверяет, не сменился ли `job_id` — если да, выходит и берёт свежую работу. + +--- + +## Реалистичные ожидания + +| Метрика | Значение | +|---|---| +| Хешрейт (Python, 1 поток) | 50–200 KH/s | +| Хешрейт всей сети Bitcoin | ~700 EH/s = 7×10²⁰ H/s | +| Доля от сети | ~10⁻¹⁵ | +| Блоков в день | ~144 | +| Ожидание блока соло | ~10¹³ дней | +| Награда при удаче | 3.125 BTC (~$200k) | + +Это лотерея с космически низкими шансами. Соло-майнинг на CPU имеет смысл только как: +1. **Учебный проект** (понять протокол на пальцах). +2. **Лотерейный билет** (формально шанс не ноль). +3. **База для оптимизации** (можно сравнивать с C/CUDA-версиями). + +Случаи, когда подобные мини-майнеры **находили** блок за всю историю — единичные, и каждый раз это был громкий новостной повод. + +--- + +## Кандидаты на название + +Финальное имя проекта не выбрано. Шорт-лист, на котором остановились: + +**Серьёзные:** +- **pyrite** — пирит, «золото дураков», + отсылка к Python (py-) +- **Sisyphus** — Сизиф, вечно катит хеш в гору + +**Самоироничные:** +- **CopiumMiner** — `copium` (мем-вещество, чтобы примириться с реальностью) +- **statisticallynever** — про реальный шанс +- **HopeHash** — короткое и грустное + +**Технические:** +- **PicoMiner** / **NanoNonce** — про размер +- **bitfly** — битовая муха + +Перед выбором проверить: +- занятость на GitHub: `https://github.com/` +- занятость на PyPI: `pip show ` +- свободный домен: `.dev` / `.io` + +--- + +## Дальше + +См. **[ROADMAP.md](./ROADMAP.md)** — план развития, разбитый на лёгкое / среднее / сложное и сгруппированный по фичам. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..31820ca --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,126 @@ +# ROADMAP + +План развития проекта. Разбит на четыре уровня по сложности, плюс отдельный блок «идеи поверх скелета». Можно идти по уровням, можно выдёргивать пункты выборочно. + +--- + +## Уровень 0 — стабилизация ✅ ЗАВЕРШЁН (2026-04-30) + +Базовая надёжность. Закрыт целиком одним пассом + реструктуризация в `src/`-layout. + +- [x] **Reconnect-логика.** `supervisor_loop` с backoff 1→2→4→…→60с, повторный subscribe/authorize, сброс `current_job`. +- [x] **Обработка `mining.set_extranonce`.** В `_handle_message` — обновляем `extranonce1`/`extranonce2_size` под локом, инвалидируем job. +- [x] **Корректное завершение.** Общий `stop_event` для всех нитей, `client.close()` разблокирует `recv()`, `reader_thread.join(timeout=5)`. Никаких висящих daemon-нитей. +- [x] **Логирование вместо `print`.** `logger = getLogger("hope_hash")`, формат `%(asctime)s [%(levelname)s] %(message)s`, уровни INFO/WARNING. Тэги `[net]/[stratum]/[mine]/[stats]/[main]` сохранены внутри сообщений. +- [ ] **Конфиг через CLI и/или YAML.** Перенесено в Уровень 1 (UI/UX) — runtime-аргументы уже через argparse, YAML добавим позже при необходимости. +- [x] **Юнит-тесты.** 15 тестов в `tests/test_block.py` на `double_sha256`, `swap_words`, `difficulty_to_target`, `build_merkle_root`. Векторы из mainnet. + +**Бонусом сделано в этом же раунде:** +- [x] Реструктуризация в `src/`-layout (пакет `hope_hash`). +- [x] `pyproject.toml` с `hatchling` backend, dist-name `hope-hash`. +- [x] Console script `hope-hash` + `python -m hope_hash`. +- [x] CI matrix (`.github/workflows/ci.yml`): Python 3.11/3.12/3.13 × ubuntu/windows/macos. +- [x] LICENSE (MIT), CHANGELOG.md, .gitignore, .editorconfig, Makefile. + +--- + +## Уровень 1 — лёгкие апгрейды (вечер каждый) + +Видимые фичи, не требующие глубокой переработки. + +### Производительность + +- [ ] **Multiprocessing.** Каждый CPU-ядро — отдельный процесс, общий `extranonce2` через `multiprocessing.Value`. Х2-х8 к хешрейту в зависимости от железа. +- [ ] **Замер реального хешрейта по окнам.** Сейчас считается «за интервал печати», правильнее — скользящее окно с EMA (exponential moving average). + +### UI/UX + +- [ ] **TUI на `rich`.** Постоянный дашборд со столбиками: текущий хешрейт, средний за час, аптайм, найденные шары, текущий job_id, pool difficulty. Обновляется in-place. +- [ ] **Альтернатива — `curses` дашборд** для терминалов без поддержки `rich`. +- [ ] **ASCII-арт логотип** в шапке при старте (когда определишься с названием). + +### Telegram-бот + +- [ ] **Уведомления о ключевых событиях:** старт майнера, потеря соединения, принятый шар, (мечты) найденный блок. Через `python-telegram-bot` или просто curl на `api.telegram.org`. +- [ ] **Команды `/stats`, `/restart`, `/stop`** через того же бота. + +### Логи и метрики + +- [ ] **SQLite-журнал шар.** Каждый принятый шар → строка с timestamp, job_id, hash, difficulty. +- [ ] **Экспорт в Prometheus формат** через эндпоинт `/metrics` на `http.server`. +- [ ] **Grafana-дашборд** с готовым JSON для импорта. + +--- + +## Уровень 2 — серьёзные фичи (несколько дней) + +### Производительность + +- [ ] **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 к хешрейту. + +### Архитектура + +- [ ] **Множественные пулы с failover.** Список `pool1, pool2, pool3` в конфиге, при потере pool1 — переключение на pool2 без остановки воркеров. +- [ ] **Несколько воркеров на разных пулах одновременно.** Распределённая работа с разными адресами/именами. +- [ ] **Поддержка vardiff.** Сейчас принимаем сложность как есть — научиться запрашивать через `mining.suggest_difficulty` для CPU-friendly значения (низкая сложность = чаще шары = быстрее обратная связь). + +### Web-морда + +- [ ] **FastAPI-сервис рядом с майнером.** Эндпоинты: + - `GET /stats` — JSON со статистикой + - `POST /restart` — перезапуск нитей + - `GET /` — HTML-страница с дашбордом + - `WS /live` — WebSocket стрим хешей в реальном времени +- [ ] **Конфиг через web-интерфейс**, чтобы не редактировать YAML руками. + +--- + +## Уровень 3 — для упоротых (недели) + +### Радикальная производительность + +- [ ] **Rust core через PyO3.** Переписать `mine()` на Rust с `sha2` крейтом и SIMD-оптимизациями. Дёргать из Python как обычный модуль. Ожидаемый прирост: с ~100 KH/s до ~10 MH/s. +- [ ] **GPU через PyOpenCL или CUDA через `cupy`.** На RTX 4090 — порядка 2 ГН/с. Это всё ещё в 100 000 раз меньше одного ASIC, но уже не лотерея масштаба «10¹³ дней», а «10⁸ дней». +- [ ] **FPGA-сборка.** Если есть Xilinx/Lattice плата — синтез SHA-256 ядра, общение через UART/USB. + +### Протоколы + +- [ ] **Stratum V2.** Современный бинарный протокол с шифрованием (Noise) и job negotiation. У `solo.ckpool.org` его пока нет, но есть на других пулах. Хорошая возможность разобраться в современном крипто-протоколе. +- [ ] **Прямое подключение к bitcoin-core.** Вообще без пула: запрашивать `getblocktemplate` через RPC у локальной ноды, собирать блок самостоятельно, при удаче — `submitblock`. Это и есть «настоящий соло-майнинг» без посредников. + +### Мониторинг и SRE + +- [ ] **Healthchecks endpoint.** Для k8s/docker liveness + readiness. +- [ ] **Docker-образ.** `Dockerfile`, `docker-compose.yml` с volumes для логов и конфигов. +- [ ] **Helm chart**, если совсем хочется хардкора с k8s на одном Raspberry Pi. + +--- + +## Идеи поверх скелета (для веселья) + +Не путь развития, а отдельные мини-проекты, которые можно реализовать поверх кода. + +- [ ] **Demo-режим без подключения к пулу.** Симулирует работу с искусственно низкой сложностью (`diff=0.0001`), шары валятся часто, можно визуально пройти весь цикл. Идеально для презентаций и обучения. +- [ ] **«Гуманизированная» статистика.** «При твоём хешрейте средний шанс найти блок: раз в 47 миллиардов лет». Считается из текущего сетевого difficulty (получаем через bitcoin-core RPC или публичные API типа `mempool.space`). +- [ ] **Lottery-визуализация.** Каждый хеш — точка на canvas. Цвет = первые 3 байта хеша. Просто красивая бесконечная анимация. Можно собрать на `pygame` или в браузере через WebSocket. +- [ ] **Бенчмарк-режим.** Прогоняет одну и ту же работу через все доступные backend-ы (pure Python, ctypes, C-extension, Rust, OpenCL) и печатает таблицу хешрейтов. Хороший способ показать, насколько Python медленный. +- [ ] **Майнинг shitcoin-ов на той же базе.** Litecoin использует Scrypt, Dogecoin — тоже Scrypt. Если заменить хеш-функцию и пул, можно майнить с шансом найти блок раз в «всего» миллион лет вместо миллиарда. Кардинально меняется сложность кода. +- [ ] **«Майнер на ладошке».** Запаковать в `pyinstaller` или `nuitka` в один бинарник с TUI — чтобы можно было кому-то отдать `.exe` и они «майнили» (с нулевым шансом, но прикольно). +- [ ] **Лидерборд между друзьями.** Несколько твоих знакомых ставят майнер с разными worker-name на один и тот же BTC-адрес. Лидерборд показывает, кто из вас вносит больше хешрейта в общий пул. Если кто-то найдёт блок (хаха) — делите по вкладу. + +--- + +## Если выбирать одно + +Если энергии хватит только на один шаг после стабилизации (Уровень 0), то самое осмысленное: + +**TUI на `rich` + multiprocessing + Telegram-бот.** + +Это даст: +- видимую разницу (красивый дашборд), +- ощутимый прирост хешрейта (×4–8 на современном ноуте), +- эмоциональную связь с проектом (телеграм пингает «принят шар!» прямо в карман). + +После этой связки уже понятно, хочется ли копать в производительность (Rust/SIMD) или в фичи (web-морда, мульти-пулы) — и можно идти по соответствующей ветке. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0b3a11c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,62 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "hope-hash" +description = "Учебный solo BTC miner на чистом Python stdlib" +readme = "README.md" +requires-python = ">=3.11" +license = { file = "LICENSE" } +authors = [{ name = "Pavel Kruglikovskii", email = "p.kruglikovskii@gmail.com" }] +keywords = ["bitcoin", "mining", "stratum", "education", "stdlib"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Environment :: Console", + "Intended Audience :: Education", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Education", + "Topic :: Security :: Cryptography", +] +dependencies = [] +dynamic = ["version"] + +[project.urls] +Repository = "https://github.com/KruglikovskiiPA/Hope-Hash" +Issues = "https://github.com/KruglikovskiiPA/Hope-Hash/issues" + +[project.scripts] +hope-hash = "hope_hash.cli:main" + +[tool.hatch.version] +path = "src/hope_hash/__init__.py" + +[tool.hatch.build.targets.wheel] +packages = ["src/hope_hash"] + +# pytest можно запускать опционально, но в проекте идём на unittest. +# Этот блок пригодится если кто-то захочет pytest: +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-ra -q" +python_files = ["test_*.py"] + +# Ruff/mypy — задел на будущее. Не активны пока пользователь не подключит [dev] extras. +[tool.ruff] +line-length = 100 +src = ["src", "tests"] +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "B", "UP", "W", "RUF"] + +[tool.mypy] +python_version = "3.11" +files = ["src", "tests"] +strict = true diff --git a/src/hope_hash/__init__.py b/src/hope_hash/__init__.py new file mode 100644 index 0000000..1f56b52 --- /dev/null +++ b/src/hope_hash/__init__.py @@ -0,0 +1,16 @@ +"""Hope-Hash — учебный solo BTC miner на чистом stdlib.""" + +from .block import build_merkle_root, difficulty_to_target, double_sha256, swap_words +from .miner import mine +from .stratum import StratumClient + +__version__ = "0.1.0" +__all__ = [ + "double_sha256", + "swap_words", + "difficulty_to_target", + "build_merkle_root", + "StratumClient", + "mine", + "__version__", +] diff --git a/src/hope_hash/__main__.py b/src/hope_hash/__main__.py new file mode 100644 index 0000000..887c70c --- /dev/null +++ b/src/hope_hash/__main__.py @@ -0,0 +1,6 @@ +"""Делает пакет запускаемым через `python -m hope_hash`.""" + +from .cli import main + +if __name__ == "__main__": + main() diff --git a/src/hope_hash/_logging.py b/src/hope_hash/_logging.py new file mode 100644 index 0000000..37825cb --- /dev/null +++ b/src/hope_hash/_logging.py @@ -0,0 +1,22 @@ +"""Настройка единого logger пакета hope_hash (приватный модуль).""" + +import logging + + +# Единый logger для всех модульных «тэгов» ([net]/[stratum]/[mine]/[stats]/[main]). +# Тэги остаются частью message — это часть стиля проекта, по нему удобно фильтровать grep'ом. +# Имя совпадает с именем пакета: так стандартно настраивается фильтрация в logging. +logger = logging.getLogger("hope_hash") + + +def setup_logging(level: int = logging.INFO) -> None: + """ + Конфигурирует корневой logging формат один раз. + + Вызывается из cli.main(). Идемпотентность basicConfig: повторный вызов + в том же процессе ничего не сломает, но и не перенастроит — это ок. + """ + logging.basicConfig( + level=level, + format="%(asctime)s [%(levelname)s] %(message)s", + ) diff --git a/src/hope_hash/block.py b/src/hope_hash/block.py new file mode 100644 index 0000000..d161a6a --- /dev/null +++ b/src/hope_hash/block.py @@ -0,0 +1,39 @@ +"""Чистые криптографические утилиты Bitcoin: SHA256d, word-swap, target, merkle.""" + +import hashlib + + +# Базовый target для difficulty=1 (Bitcoin diff-1). Вынесен на уровень модуля, +# чтобы быть единым источником правды и для difficulty_to_target, и для тестов. +DIFF1_TARGET = 0x00000000FFFF0000000000000000000000000000000000000000000000000000 + + +def double_sha256(data: bytes) -> bytes: + """SHA-256(SHA-256(x)) — основная хеш-функция Bitcoin.""" + return hashlib.sha256(hashlib.sha256(data).digest()).digest() + + +def swap_words(hex_str: str) -> bytes: + """ + Stratum-овский «word swap»: переворачивает каждые 4 байта внутри строки. + prev_block_hash приходит от пула в этом «свопнутом» формате — + в заголовок блока его надо положить именно так. Главный gotcha Stratum V1. + """ + raw = bytes.fromhex(hex_str) + return b"".join(raw[i:i+4][::-1] for i in range(0, len(raw), 4)) + + +def difficulty_to_target(diff: float) -> int: + """ + Pool-сложность → численный target. Шар принимается, если SHA256d(header) <= target. + diff=1 соответствует базовому target Bitcoin diff-1. + """ + return int(DIFF1_TARGET / diff) + + +def build_merkle_root(coinbase_hash: bytes, branches: list) -> bytes: + """Сворачивает merkle-дерево по веткам, полученным от пула.""" + h = coinbase_hash + for b in branches: + h = double_sha256(h + bytes.fromhex(b)) + return h diff --git a/src/hope_hash/cli.py b/src/hope_hash/cli.py new file mode 100644 index 0000000..e2108b7 --- /dev/null +++ b/src/hope_hash/cli.py @@ -0,0 +1,57 @@ +"""Точка входа CLI: argparse, запуск supervisor + mine().""" + +import argparse +import threading +import time + +from ._logging import logger, setup_logging +from .miner import mine, supervisor_loop +from .stratum import StratumClient + + +POOL_HOST = "solo.ckpool.org" +POOL_PORT = 3333 + + +def main(): + setup_logging() + + parser = argparse.ArgumentParser( + prog="hope_hash", + description="Учебный solo BTC miner на чистом stdlib.", + ) + parser.add_argument("btc_address", help="BTC-адрес для выплат (на него уйдёт награда).") + parser.add_argument("worker_name", nargs="?", default="py01", + help="Имя воркера (по умолчанию: py01).") + args = parser.parse_args() + + btc_address = args.btc_address + worker_name = args.worker_name + + stop = threading.Event() + client = StratumClient(POOL_HOST, POOL_PORT, btc_address, worker_name, stop_event=stop) + + # Сетевая часть живёт в отдельной нити-супервизоре: она держит коннект, + # переподключается при разрывах и сама поднимает reader_loop. main thread + # отдан под mine(), чтобы Ctrl+C ловился предсказуемо. + supervisor = threading.Thread(target=supervisor_loop, args=(client,), + name="stratum-supervisor", daemon=False) + supervisor.start() + + logger.info("[main] жду первый job от пула...") + while client.current_job is None and not stop.is_set(): + time.sleep(0.1) + + try: + if not stop.is_set(): + mine(client, stop) + except KeyboardInterrupt: + logger.info("[main] остановка по Ctrl+C") + finally: + # Согласованная остановка: флаг → закрытие сокета (recv разблокируется) + # → join всех нитей. Никаких висячих daemon'ов. + stop.set() + client.close() + supervisor.join(timeout=5) + if supervisor.is_alive(): + logger.warning("[main] supervisor не остановился за 5с") diff --git a/src/hope_hash/miner.py b/src/hope_hash/miner.py new file mode 100644 index 0000000..7181e0b --- /dev/null +++ b/src/hope_hash/miner.py @@ -0,0 +1,143 @@ +"""Главный цикл хеширования mine() и сетевой супервизор переподключений.""" + +import socket +import struct +import threading +import time + +from ._logging import logger +from .block import build_merkle_root, difficulty_to_target, double_sha256, swap_words +from .stratum import StratumClient + + +# ─────────────────────── сетевой супервизор ─────────────────────── + +def run_session(client: StratumClient) -> threading.Thread: + """ + Один цикл «жизни» соединения: connect → subscribe → запуск reader_loop. + Возвращает уже стартованную нить-читатель. На всех ошибках бросает наверх, + чтобы supervisor мог решить про backoff. + """ + client.buf = b"" # буфер от прошлой сессии больше не валиден + client.req_id = 0 + with client.job_lock: + client.current_job = None # extranonce1 после reconnect может смениться + client.connect() + client.subscribe_and_authorize() + # Не daemon: при Ctrl+C хотим явно дождаться join, а не убить грубо. + t = threading.Thread(target=client.reader_loop, name="stratum-reader", daemon=False) + t.start() + return t + + +def supervisor_loop(client: StratumClient): + """ + Поднимает соединение и переподключается с экспоненциальным backoff + (1с → 2с → 4с → ... до 60с) пока stop_event не выставлен. + Запускается в отдельной нити, чтобы main thread мог крутить mine(). + """ + backoff = 1 + while not client.stop_event.is_set(): + reader_thread = None + try: + reader_thread = run_session(client) + backoff = 1 # успешный коннект — сбрасываем задержку + # Ждём, пока reader не выйдет (по ошибке сети или stop_event). + while reader_thread.is_alive() and not client.stop_event.is_set(): + 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}") + + if client.stop_event.is_set(): + break + + # reader умер сам (разрыв TCP) — закрываем сокет и ждём. + client.close() + logger.warning(f"[net] reconnect через {backoff}с") + # Ждём через wait(), чтобы Ctrl+C прерывал паузу мгновенно. + if client.stop_event.wait(timeout=backoff): + break + backoff = min(backoff * 2, 60) + + +# ─────────────────────── основной майнинг-цикл ─────────────────────── + +def mine(client: StratumClient, stop_event: threading.Event): + hashes = 0 + last_print = time.time() + extranonce2_counter = 0 + + while not stop_event.is_set(): + with client.job_lock: + job = client.current_job + en1 = client.extranonce1 + en2_size = client.extranonce2_size + if not job or not en1: + time.sleep(0.5) + continue + + # extranonce2 — наша часть coinbase, чтобы каждый воркер крутил уникальные хеши. + extranonce2 = f"{extranonce2_counter:0{en2_size * 2}x}" + extranonce2_counter += 1 + + # Собираем coinbase = coinb1 + extranonce1 + extranonce2 + coinb2 + coinbase_hex = job["coinb1"] + en1 + extranonce2 + job["coinb2"] + coinbase_hash = double_sha256(bytes.fromhex(coinbase_hex)) + + # Считаем merkle root через ветки от пула + merkle_root = build_merkle_root(coinbase_hash, job["merkle_branch"]) + + # Базовый block header без nonce (76 байт = 80 - 4) + header_base = ( + bytes.fromhex(job["version"])[::-1] + # 4 b version (LE) + swap_words(job["prevhash"]) + # 32 b prev hash (word-swap) + merkle_root + # 32 b merkle (LE) + bytes.fromhex(job["ntime"])[::-1] + # 4 b ntime (LE) + bytes.fromhex(job["nbits"])[::-1] # 4 b nbits (LE) + ) + + target = difficulty_to_target(client.difficulty) + current_job_id = job["job_id"] + + # Перебор nonce: 0 .. 2^32 - 1 + for nonce in range(0, 0xFFFFFFFF): + if stop_event.is_set(): + return + + # Если пришла свежая работа — выходим, чтобы не тратить время на старую + if hashes & 0x3FFF == 0: # каждые 16k хешей дёргаем lock, чтобы не душить нить + with client.job_lock: + if not client.current_job or client.current_job["job_id"] != current_job_id: + break + + header = header_base + struct.pack("I", nonce).hex() + logger.warning( + f"[mine] !!! НАЙДЕН ШАР !!! nonce={nonce_hex} hash={h[::-1].hex()}" + ) + try: + client.submit(current_job_id, extranonce2, job["ntime"], nonce_hex) + except (OSError, AttributeError) as e: + # submit может прийтись на момент reconnect — не валим майнер. + logger.warning(f"[stratum] не удалось отправить шар: {e}") + + hashes += 1 + + # Каждые 5 секунд — статистика + now = time.time() + if now - last_print >= 5.0: + rate = hashes / (now - last_print) + rate_str = f"{rate:.0f} H/s" if rate < 1000 else f"{rate/1000:.2f} KH/s" + logger.info( + f"[stats] хешрейт ≈ {rate_str} | pool diff = {client.difficulty}" + ) + hashes = 0 + last_print = now diff --git a/src/hope_hash/py.typed b/src/hope_hash/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/hope_hash/stratum.py b/src/hope_hash/stratum.py new file mode 100644 index 0000000..80d9002 --- /dev/null +++ b/src/hope_hash/stratum.py @@ -0,0 +1,137 @@ +"""Stratum V1 клиент: TCP-сокет, JSON line-delimited, обработка mining.* сообщений.""" + +import json +import socket +import threading + +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): + self.host = host + self.port = port + self.username = f"{btc_address}.{worker_name}" + self.sock = None + self.buf = b"" + self.req_id = 0 + self.extranonce1 = "" + self.extranonce2_size = 0 + self.difficulty = 1.0 + self.current_job = None + self.job_lock = threading.Lock() + # Общий флаг остановки: даёт reader_loop и mine() согласованно завершаться, + # чтобы при ошибке в одной нити вторая не «висла» молча. + self.stop_event = stop_event if stop_event is not None else threading.Event() + + def connect(self): + 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: + self.req_id += 1 + msg = json.dumps({"id": self.req_id, "method": method, "params": params}) + "\n" + self.sock.sendall(msg.encode()) + return self.req_id + + def _recv_line(self) -> str: + while b"\n" not in self.buf: + chunk = self.sock.recv(4096) + if not chunk: + raise ConnectionError("pool закрыл соединение") + self.buf += chunk + line, _, self.buf = self.buf.partition(b"\n") + return line.decode().strip() + + def subscribe_and_authorize(self): + sub_id = self._send("mining.subscribe", ["py-solo-miner/0.1"]) + # Ответ на subscribe может прийти не первым — читаем до победы. + while True: + msg = json.loads(self._recv_line()) + if msg.get("id") == sub_id and msg.get("result"): + # result = [[(method, sub_id), ...], extranonce1_hex, extranonce2_size] + self.extranonce1 = msg["result"][1] + self.extranonce2_size = msg["result"][2] + logger.info( + f"[stratum] subscribed: extranonce1={self.extranonce1}, " + f"en2_size={self.extranonce2_size}" + ) + break + self._handle_message(msg) + self._send("mining.authorize", [self.username, "x"]) + logger.info(f"[stratum] authorize отправлен для воркера {self.username}") + + 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]) + logger.info(f"[stratum] новая сложность: {self.difficulty}") + + elif method == "mining.set_extranonce": + # Пул может «на лету» сменить extranonce1 (например, при ребалансе воркеров). + # Старый job становится невалидным: extranonce2 теперь компонуется иначе, + # поэтому сбрасываем current_job — mine() подождёт ближайший mining.notify. + with self.job_lock: + self.extranonce1 = params[0] + self.extranonce2_size = int(params[1]) + self.current_job = None + logger.info( + f"[stratum] новая extranonce1={self.extranonce1}, " + f"en2_size={self.extranonce2_size} (job сброшен)" + ) + + elif method == "mining.notify": + with self.job_lock: + self.current_job = { + "job_id": params[0], + "prevhash": params[1], + "coinb1": params[2], + "coinb2": params[3], + "merkle_branch": params[4], + "version": params[5], + "nbits": params[6], + "ntime": params[7], + "clean": params[8], + } + 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']}") + + def reader_loop(self): + """ + Фоновая нить, постоянно слушает сообщения от пула. + Выходит при ошибке сети или при выставленном stop_event — главное, + не «умирать тихо», иначе mine() будет крутить уже невалидную работу. + """ + while not self.stop_event.is_set(): + try: + line = self._recv_line() + if line: + self._handle_message(json.loads(line)) + except (ConnectionError, socket.error, OSError) as e: + if self.stop_event.is_set(): + return + logger.warning(f"[net] ошибка чтения: {e}") + return + except json.JSONDecodeError as e: + # Битая строка — не повод ронять соединение, просто скипаем. + 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 close(self): + """Аккуратно гасим сокет: recv() в reader_loop разблокируется и нить выйдет.""" + try: + if self.sock is not None: + self.sock.close() + except OSError: + pass diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..8a28981 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1 @@ +# Общие фикстуры pytest/unittest. Пока пусто. diff --git a/tests/test_block.py b/tests/test_block.py new file mode 100644 index 0000000..0cc4f7b --- /dev/null +++ b/tests/test_block.py @@ -0,0 +1,130 @@ +""" +Юнит-тесты для криптографических функций hope_hash.block. + +Покрывают «чистые» функции, которые работают без сети и состояния: +double_sha256, swap_words, difficulty_to_target, build_merkle_root. + +Запуск: + python -m unittest discover -s tests -v + +Никаких сетевых вызовов и зависимостей — только стандартная библиотека. +""" + +import unittest + +from hope_hash.block import ( + build_merkle_root, + difficulty_to_target, + double_sha256, + swap_words, +) + + +class TestDoubleSha256(unittest.TestCase): + """Проверяем, что SHA256d считается корректно по известным векторам.""" + + def test_empty_bytes(self): + # Канонический вектор: SHA256d пустой строки — широко известное значение. + expected = "5df6e0e2761359d30a8275058e299fcc0381534545f55cf43e41983f5d4c9456" + self.assertEqual(double_sha256(b"").hex(), expected) + + def test_hello(self): + # SHA256d(b"hello") — тоже стандартный вектор для проверки. + expected = "9595c9df90075148eb06860365df33584b75bff782a510c6cd4883a419833d50" + self.assertEqual(double_sha256(b"hello").hex(), expected) + + def test_returns_32_bytes(self): + # Длина выхода SHA-256 — всегда 32 байта, независимо от входа. + self.assertEqual(len(double_sha256(b"")), 32) + self.assertEqual(len(double_sha256(b"x" * 1000)), 32) + + +class TestSwapWords(unittest.TestCase): + """Word-swap на 4-байтных группах — главный gotcha Stratum V1.""" + + def test_16_bytes(self): + # 16 байт = 4 слова по 4 байта. Каждое слово реверсится отдельно. + # 00112233 -> 33221100, 44556677 -> 77665544 и т.д. + result = swap_words("00112233445566778899aabbccddeeff") + expected = bytes.fromhex("3322110077665544bbaa9988ffeeddcc") + self.assertEqual(result, expected) + + def test_4_bytes(self): + # Одно слово — просто реверс байтов. + result = swap_words("deadbeef") + expected = bytes.fromhex("efbeadde") + self.assertEqual(result, expected) + + def test_8_bytes(self): + # Два слова, проверяем, что они не «перемешиваются» между собой. + result = swap_words("0102030405060708") + expected = bytes.fromhex("0403020108070605") + self.assertEqual(result, expected) + + def test_returns_bytes(self): + # На вход — hex-строка, на выход — именно bytes (а не hex). + self.assertIsInstance(swap_words("00000000"), bytes) + + +class TestDifficultyToTarget(unittest.TestCase): + """Pool difficulty -> численный target. diff=1 — это базовый Bitcoin diff-1.""" + + DIFF1_TARGET = 0x00000000FFFF0000000000000000000000000000000000000000000000000000 + + def test_difficulty_one(self): + # diff=1 должен дать ровно базовый diff-1 target. + self.assertEqual(difficulty_to_target(1.0), self.DIFF1_TARGET) + + def test_difficulty_two(self): + # diff=2 — половина от diff-1 (целочисленно через int()). + self.assertEqual(difficulty_to_target(2.0), int(self.DIFF1_TARGET / 2.0)) + + def test_difficulty_1024(self): + # diff=1024 — diff-1 / 1024. Типичный порядок для соло-пулов. + self.assertEqual(difficulty_to_target(1024), int(self.DIFF1_TARGET / 1024)) + + def test_higher_diff_means_smaller_target(self): + # Чем больше сложность, тем меньше target — инварианта майнинга. + self.assertLess(difficulty_to_target(1024), difficulty_to_target(1.0)) + self.assertLess(difficulty_to_target(2.0), difficulty_to_target(1.0)) + + +class TestBuildMerkleRoot(unittest.TestCase): + """Сворачивание merkle-веток от пула в финальный merkle root.""" + + def test_empty_branches_returns_coinbase(self): + # В блоке #1 одна транзакция (coinbase), поэтому merkle_branch пустой + # и merkle_root равен самому coinbase_hash. Берём реальный merkle root + # block #1 как coinbase_hash — функция должна вернуть его без изменений. + coinbase_hash = bytes.fromhex( + "0e3e2357e806b6cdb1f70b54c3a3a17b6714ee1f0e68bebb44a74b1efd512098" + ) + self.assertEqual(build_merkle_root(coinbase_hash, []), coinbase_hash) + + def test_synthetic_one_branch(self): + # Один branch — результат должен совпасть с double_sha256(coinbase || branch). + # Тест согласованности самой реализации: build_merkle_root и double_sha256 + # должны давать одинаковый результат на этом простом случае. + coinbase_hash = b"\x00" * 32 + branch_hex = "11" * 32 + result = build_merkle_root(coinbase_hash, [branch_hex]) + expected = double_sha256(coinbase_hash + bytes.fromhex(branch_hex)) + self.assertEqual(result, expected) + + def test_synthetic_two_branches(self): + # Две ветки сворачиваются последовательно: h1 = dsha(cb||b0), root = dsha(h1||b1). + coinbase_hash = b"\xaa" * 32 + b0 = "22" * 32 + b1 = "33" * 32 + h1 = double_sha256(coinbase_hash + bytes.fromhex(b0)) + expected = double_sha256(h1 + bytes.fromhex(b1)) + self.assertEqual(build_merkle_root(coinbase_hash, [b0, b1]), expected) + + def test_returns_32_bytes(self): + # Merkle root — всегда 32 байта (это всё ещё SHA-256). + result = build_merkle_root(b"\x00" * 32, ["11" * 32]) + self.assertEqual(len(result), 32) + + +if __name__ == "__main__": + unittest.main()