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()