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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 55 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,22 @@
- [ ] TUI на `rich` / `curses` — отложено (зависимости либо ограниченная Win-поддержка)
- [ ] Команды Telegram-бота (`/stats`, `/restart`) — отложено

**Уровень 1.5 — глубокий аудит и UX (v0.3.0):**

- [x] Mid-state SHA-256 (`hashlib.sha256().copy()` после первых 64 байт) — ≈×1.5–2 хешрейт
- [x] `mining.suggest_difficulty` + CLI флаг `--suggest-diff` (vardiff)
- [x] Demo-режим: `--demo [--demo-diff]` — offline-майнинг без подключения к пулу
- [x] Pre-flight валидация BTC-адреса (bech32/bech32m/Base58Check, mainnet only)
- [x] Prometheus метрики `hopehash_shares_accepted_total` / `_rejected_total`
- [x] Запись шара в SQLite фиксируется только после подтверждения пула (`on_share_result` колбэк)
- [x] `mining.authorize` ответ верифицируется (раньше отказ авторизации игнорировался)
- [x] `time.perf_counter()` вместо `time.time()` для всех относительных интервалов
- [x] `except queue.Empty` вместо bare `except Exception` в горячих циклах

**Не сделано / известные ограничения:**

- Нет UI — только консоль через `logging` и `/metrics` через HTTP.
- Только Stratum V1, без `mining.suggest_difficulty` и без Stratum V2.
- Только Stratum V1, без Stratum V2.
- C/Rust/SIMD/GPU — Уровни 2–3, ещё впереди.

---
Expand All @@ -74,17 +86,21 @@
│ ├── parallel.py ← multiprocessing воркеры nonce-loop
│ ├── stratum.py ← StratumClient (TCP + JSON-RPC)
│ ├── block.py ← double_sha256, swap_words, target, merkle
│ ├── address.py ← валидация BTC-адресов (bech32/bech32m/Base58Check)
│ ├── demo.py ← offline-майнинг (--demo)
│ ├── storage.py ← SQLite журнал шаров и сессий
│ ├── metrics.py ← Prometheus экспортёр (http.server)
│ ├── notifier.py ← Telegram через urllib
│ ├── _logging.py ← настройка logger("hope_hash")
│ └── py.typed ← PEP 561 marker
└── tests/
├── conftest.py ← общие фикстуры (заготовка)
├── test_block.py ← 15 тестов на чистые функции
├── test_storage.py ← 9 тестов на SQLite журнал
├── test_block.py ← 22 теста на чистые функции + mid-state
├── test_storage.py ← 11 тестов на SQLite журнал
├── test_metrics.py ← 16 тестов на Prometheus экспортёр
└── test_notifier.py ← 16 тестов на Telegram (через mock)
├── test_notifier.py ← 16 тестов на Telegram (через mock)
├── test_address.py ← 18 тестов на валидацию BTC-адреса
└── test_stratum.py ← 15 тестов на Stratum-протокол (FakeSocket)
```

---
Expand Down Expand Up @@ -112,11 +128,29 @@ hope-hash bc1q5n2x4pvxhq8sxc7ck3uxq8sxc7ck3uxqzfm2py mylaptop

```bash
hope-hash <BTC_адрес> mylaptop \
--workers 8 \ # число процессов (default: cpu_count - 1)
--db ./shares.db \ # путь к SQLite (default: hope_hash.db)
--metrics-port 9090 # Prometheus /metrics (0 — отключить)
--workers 8 \ # число процессов (default: cpu_count - 1)
--db ./shares.db \ # путь к SQLite (default: hope_hash.db)
--metrics-port 9090 \ # Prometheus /metrics (0 — отключить)
--suggest-diff 0.001 # vardiff: запросить у пула низкую сложность
```

**Demo-режим (без подключения к пулу):**

```bash
hope-hash --demo # синтетический заголовок, низкая сложность
hope-hash --demo --demo-diff 0.0001 # ещё ниже — найдёт быстрее
hope-hash --demo --workers 4 # сколько процессов перебирают nonce
```

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

**Валидация BTC-адреса** срабатывает локально перед подключением к пулу. Принимаются только mainnet-адреса:
- `bc1q...` (P2WPKH, P2WSH) — bech32, BIP-173
- `bc1p...` (Taproot) — bech32m, BIP-350
- `1...` (P2PKH), `3...` (P2SH) — Base58Check

Неверная контрольная сумма, смешанный регистр, testnet-префикс — отвергаются с конкретным сообщением, без сетевого round-trip.

**Telegram-уведомления (опционально):** задать env vars и просто запустить:

```bash
Expand All @@ -128,10 +162,22 @@ hope-hash <BTC_адрес>
**Тесты:**

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

BTC-адрес нужен валидный (любой формат: `1...`, `3...`, `bc1q...`, `bc1p...`). Можно завести в любом некастодиальном кошельке — например, **Sparrow**, **Electrum**, **Wasabi**. Имя воркера — произвольная строка.
**Prometheus-метрики, экспортируемые на `/metrics`:**

| Метрика | Тип | Описание |
|---|---|---|
| `hopehash_hashrate_hps` | gauge | EMA-хешрейт в H/s |
| `hopehash_pool_difficulty` | gauge | текущая сложность от пула |
| `hopehash_workers` | gauge | число активных воркеров |
| `hopehash_uptime_seconds` | gauge | время работы майнера |
| `hopehash_shares_total` | counter | всего отправленных шаров |
| `hopehash_shares_accepted_total` | counter | подтверждённых пулом |
| `hopehash_shares_rejected_total` | counter | отклонённых пулом |

BTC-адрес нужен валидный mainnet-адрес (`1...`, `3...`, `bc1q...`, `bc1p...`). Можно завести в любом некастодиальном кошельке — например, **Sparrow**, **Electrum**, **Wasabi**. Имя воркера — произвольная строка.

**Что увидишь:**

Expand Down
207 changes: 207 additions & 0 deletions src/hope_hash/address.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
"""Валидация Bitcoin-адресов: bech32 (BIP-173), bech32m (BIP-350), Base58Check.

Полностью stdlib. Используется в `cli.py` как pre-flight проверка: ловим
опечатки и неправильные форматы локально, до сетевого round-trip к пулу.

Поддерживаются mainnet-адреса (bc1.../1.../3...). Testnet (tb1.../m.../n.../2...)
не валидируется намеренно — solo.ckpool.org работает только с mainnet, и
testnet-адрес почти всегда означает ошибку оператора, а не намерение.
"""

from __future__ import annotations

import hashlib

# Алфавит bech32 из BIP-173. Индекс символа = 5-битное значение.
_BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
_BECH32_CHARSET_REV = {c: i for i, c in enumerate(_BECH32_CHARSET)}

# Алфавит Base58 (Bitcoin): без 0/O/I/l, чтобы исключить визуальную путаницу.
_BASE58_CHARSET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
_BASE58_CHARSET_REV = {c: i for i, c in enumerate(_BASE58_CHARSET)}

# Константы постфиксов checksum для bech32 и bech32m. Различие — единственный
# способ отличить v0 (BIP-173) от v1+ (BIP-350) при декодировании.
_BECH32_CONST = 1
_BECH32M_CONST = 0x2BC830A3


def _bech32_polymod(values: list[int]) -> int:
"""Полином из BIP-173. Возвращает 30-битное контрольное значение."""
gen = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3]
chk = 1
for v in values:
b = chk >> 25
chk = ((chk & 0x1FFFFFF) << 5) ^ v
for i in range(5):
if (b >> i) & 1:
chk ^= gen[i]
return chk


def _bech32_hrp_expand(hrp: str) -> list[int]:
"""Развёртка HRP для polymod: high-bits, separator, low-bits."""
return [ord(c) >> 5 for c in hrp] + [0] + [ord(c) & 31 for c in hrp]


def _bech32_verify_checksum(hrp: str, data: list[int]) -> int | None:
"""Возвращает витнес-вариант (v0=bech32, v1+=bech32m) или None при несовпадении."""
polymod = _bech32_polymod(_bech32_hrp_expand(hrp) + data)
if polymod == _BECH32_CONST:
return 0 # bech32 (v0 segwit)
if polymod == _BECH32M_CONST:
return 1 # bech32m (v1+ segwit, taproot)
return None


def _bech32_decode(addr: str) -> tuple[str, list[int], int]:
"""Декод bech32/bech32m. Возвращает (hrp, data_5bit, variant).

Бросает ValueError с описанием конкретной проблемы.
"""
# Спецификация: смешанный регистр запрещён, но строки одного регистра
# эквивалентны. Приводим к нижнему после проверки.
if any(ord(c) < 33 or ord(c) > 126 for c in addr):
raise ValueError("адрес содержит недопустимые символы (вне ASCII 33-126)")
if addr.lower() != addr and addr.upper() != addr:
raise ValueError("адрес содержит смешанный регистр (запрещено BIP-173)")
addr = addr.lower()

pos = addr.rfind("1")
if pos < 1 or pos + 7 > len(addr):
raise ValueError("неверная позиция разделителя '1' в bech32-адресе")
hrp = addr[:pos]
data_str = addr[pos + 1:]

data: list[int] = []
for c in data_str:
if c not in _BECH32_CHARSET_REV:
raise ValueError(f"символ '{c}' вне алфавита bech32")
data.append(_BECH32_CHARSET_REV[c])

variant = _bech32_verify_checksum(hrp, data)
if variant is None:
raise ValueError("неверная контрольная сумма bech32")

# Последние 6 5-битных групп — checksum, отбрасываем.
return hrp, data[:-6], variant


def _convertbits(data: list[int], frombits: int, tobits: int, pad: bool) -> list[int]:
"""Перепаковка 5-битных групп в 8-битные байты (и наоборот)."""
acc = 0
bits = 0
ret: list[int] = []
maxv = (1 << tobits) - 1
for value in data:
if value < 0 or (value >> frombits) != 0:
raise ValueError("значение вне диапазона при перепаковке битов")
acc = (acc << frombits) | value
bits += frombits
while bits >= tobits:
bits -= tobits
ret.append((acc >> bits) & maxv)
if pad:
if bits > 0:
ret.append((acc << (tobits - bits)) & maxv)
elif bits >= frombits or ((acc << (tobits - bits)) & maxv):
raise ValueError("неверное выравнивание битов при перепаковке")
return ret


def _validate_segwit(addr: str) -> None:
"""Проверка bech32 segwit-адреса (mainnet, HRP='bc')."""
hrp, data, variant = _bech32_decode(addr)
if hrp != "bc":
raise ValueError(f"HRP='{hrp}', ожидается 'bc' (mainnet)")
if not data:
raise ValueError("пустая data-часть segwit-адреса")

witver = data[0]
if witver < 0 or witver > 16:
raise ValueError(f"witness version={witver} вне диапазона [0..16]")

# Витнес-вариант чексуммы должен соответствовать версии:
# v0 → bech32 (BIP-173), v1+ → bech32m (BIP-350).
if witver == 0 and variant != 0:
raise ValueError("v0 segwit требует чексумму bech32, найдена bech32m")
if witver != 0 and variant != 1:
raise ValueError(f"v{witver} segwit требует чексумму bech32m, найдена bech32")

program = _convertbits(data[1:], 5, 8, False)
if len(program) < 2 or len(program) > 40:
raise ValueError(f"длина witness program={len(program)} байт, ожидается 2..40")
# v0 определяет ровно две длины: P2WPKH (20) и P2WSH (32).
if witver == 0 and len(program) not in (20, 32):
raise ValueError(f"v0 segwit с program={len(program)} байт (ожидается 20 или 32)")


def _base58_decode(s: str) -> bytes:
"""Декод Base58 с учётом ведущих нулей (символ '1' → 0x00 байт)."""
if not s:
raise ValueError("пустая Base58-строка")
n = 0
for c in s:
if c not in _BASE58_CHARSET_REV:
raise ValueError(f"символ '{c}' вне алфавита Base58")
n = n * 58 + _BASE58_CHARSET_REV[c]
# Преобразуем целое в байты, добавляем ведущие нули по числу '1' в начале.
full_bytes = bytearray()
while n > 0:
full_bytes.insert(0, n & 0xFF)
n >>= 8
leading_zeros = 0
for c in s:
if c == "1":
leading_zeros += 1
else:
break
return b"\x00" * leading_zeros + bytes(full_bytes)


def _validate_legacy(addr: str) -> None:
"""Проверка legacy-адреса P2PKH/P2SH через Base58Check.

Формат: [1 байт version][20 байт hash160][4 байта checksum].
Mainnet: version=0x00 (P2PKH, '1...') или 0x05 (P2SH, '3...').
"""
decoded = _base58_decode(addr)
if len(decoded) != 25:
raise ValueError(f"длина Base58-декода={len(decoded)} байт, ожидается 25")
payload, checksum = decoded[:-4], decoded[-4:]
digest = hashlib.sha256(hashlib.sha256(payload).digest()).digest()[:4]
if digest != checksum:
raise ValueError("неверная Base58Check-контрольная сумма")
version = payload[0]
if version not in (0x00, 0x05):
raise ValueError(
f"version-байт=0x{version:02x}, ожидается 0x00 (P2PKH) или 0x05 (P2SH) для mainnet"
)


def validate_btc_address(addr: str) -> None:
"""Валидирует mainnet BTC-адрес. Бросает ValueError при ошибке.

Принимает три формата:
- bech32 P2WPKH/P2WSH ('bc1q...'): BIP-173, witness v0, 20 или 32 байта.
- bech32m taproot ('bc1p...'): BIP-350, witness v1+, 2..40 байт.
- legacy P2PKH ('1...') / P2SH ('3...'): Base58Check, 25 байт.
"""
if not isinstance(addr, str):
raise ValueError("адрес должен быть строкой")
if not addr:
raise ValueError("пустой адрес")

# Решение по префиксу. Определяем формат по первой группе символов и
# делегируем в специализированный валидатор. Это даёт ясные сообщения
# об ошибках для каждого формата отдельно.
lower = addr.lower()
if lower.startswith("bc1"):
_validate_segwit(addr)
elif addr[0] in ("1", "3"):
_validate_legacy(addr)
else:
raise ValueError(
f"неизвестный префикс адреса: '{addr[:4]}...' "
"(ожидается 'bc1' для segwit/taproot, '1' для P2PKH, '3' для P2SH)"
)
10 changes: 10 additions & 0 deletions src/hope_hash/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from pathlib import Path

from ._logging import logger, setup_logging
from .address import validate_btc_address
from .metrics import Metrics, MetricsServer
from .miner import mine, supervisor_loop
from .notifier import TelegramNotifier
Expand Down Expand Up @@ -90,6 +91,15 @@ def main():
print("error: btc_address обязателен (или используйте --demo)", file=sys.stderr)
sys.exit(2)

# Pre-flight: ловим опечатки/невалидный формат локально, до соединения с пулом.
# Без этого пул отклоняет mining.authorize, и пользователь видит мутное
# "auth failed" вместо конкретной причины.
try:
validate_btc_address(args.btc_address)
except ValueError as e:
print(f"error: некорректный BTC-адрес '{args.btc_address}': {e}", file=sys.stderr)
sys.exit(2)

# ─── observers ───
# Все три опциональны и не зависят друг от друга. Каждый сам решает,
# включаться ли (notifier — по env vars; metrics — по порту; store — по флагу).
Expand Down
6 changes: 5 additions & 1 deletion src/hope_hash/demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,17 @@
from .parallel import start_pool, stop_pool


def run_demo(n_workers: int = 1, diff: float = 0.001) -> None:
def run_demo(n_workers: int = 1, diff: float = 0.001) -> bool:
"""
Один раунд demo-майнинга.

Параметры:
n_workers — число параллельных процессов (как в реальном майнинге).
diff — желаемая сложность. 0.001 → ~4 млн хешей в среднем,
при ~200 KH/s на воркер ожидание ≈ 5–20с.

Возвращает True, если nonce найден; False — если все воркеры исчерпали
своё nonce-пространство без находки (возможно при очень высокой diff).
"""
# Синтетический 76-байтовый header_base:
# version(4) + prevhash(32) + merkle_root(32) + ntime(4) + nbits(4)
Expand Down Expand Up @@ -68,3 +71,4 @@ def run_demo(n_workers: int = 1, diff: float = 0.001) -> None:
if not found:
logger.warning("[demo] nonce не найден в заданном пространстве")
logger.info("[demo] demo-режим завершён")
return found
Loading
Loading