From 3ea99d84f48939d8a68058a53205b5f891ed733d Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Tue, 24 Mar 2026 17:06:51 +0100 Subject: [PATCH] Initial commit --- .gitignore | 4 + LICENSE | 21 +++++ README.md | 180 +++++++++++++++++++++++++++++++++++++++++ block_builder.py | 167 ++++++++++++++++++++++++++++++++++++++ config.py.example | 67 ++++++++++++++++ launcher.py | 198 ++++++++++++++++++++++++++++++++++++++++++++++ main.py | 145 +++++++++++++++++++++++++++++++++ miner.py | 140 ++++++++++++++++++++++++++++++++ requirements.txt | 4 + rpc.py | 101 +++++++++++++++++++++++ utils.py | 86 ++++++++++++++++++++ 11 files changed, 1113 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 block_builder.py create mode 100644 config.py.example create mode 100644 launcher.py create mode 100644 main.py create mode 100644 miner.py create mode 100644 requirements.txt create mode 100644 rpc.py create mode 100644 utils.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f084d36 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +venv/ +.venv/ +config.py \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c1f8543 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Davide Grilli + +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/README.md b/README.md new file mode 100644 index 0000000..52fbdb9 --- /dev/null +++ b/README.md @@ -0,0 +1,180 @@ +# SHA-256 Miner (From Scratch, Python Starter) + +A clean, production-minded **starting point** for building SHA-256 miners from scratch. + +This repository provides a complete, readable Python implementation of a Bitcoin-style mining pipeline using `getblocktemplate` and JSON-RPC. It is intentionally written in Python to keep the architecture simple, inspectable, and easy to evolve. + +## Positioning + +This project is designed for engineers who want to: +- understand mining internals end-to-end +- customize nonce-search and block-building logic +- use a practical base before moving performance-critical parts to lower-level languages + +This project is **not** an ASIC-grade miner and is **not** intended for profitable mainnet mining. + +## What You Get + +- End-to-end mining flow (`template -> coinbase -> merkle -> header -> PoW -> submit`) +- Multiprocess worker orchestration +- Worker-specific `extranonce2` generation +- SegWit-aware template processing +- Coinbase construction with custom message support +- Block serialization and submission to Bitcoin Core +- Watchdog-based restart on chain tip updates +- Live aggregated dashboard (attempts/hashrate by worker) + +## Repository Layout + +- `launcher.py`: supervisor, process lifecycle, dashboard +- `main.py`: single-worker mining loop +- `miner.py`: PoW nonce search engine +- `block_builder.py`: coinbase/header/block assembly +- `rpc.py`: Bitcoin Core RPC client helpers +- `utils.py`: hashing, target conversion, watchdog logic +- `config.py`: runtime configuration +- `config.py.example`: full configuration template + +## Architecture Overview + +1. `launcher.py` starts N worker processes. +2. Each worker requests a fresh block template from Bitcoin Core. +3. Coinbase is built using block height, optional message, `EXTRANONCE1`, and worker-specific `EXTRANONCE2`. +4. Merkle root and block header are assembled. +5. `miner.py` searches for a valid nonce according to the selected strategy. +6. If a valid header is found, the block is serialized and submitted. +7. Workers restart with a new template after successful submission or tip change. + +## Requirements + +- Python 3.10+ +- Bitcoin Core node with RPC enabled +- Recommended network for development: `regtest` + +Install: + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +``` + +## Bitcoin Core Setup + +Example `bitcoin.conf` values: + +```ini +server=1 +rpcuser=user +rpcpassword=password +rpcallowip=127.0.0.1 +rpcport=18443 +regtest=1 +``` + +Use values consistent with your local node and wallet setup. + +## Configuration (`config.py`) + +All runtime settings are defined in `config.py`. +Use `config.py.example` as the canonical template. + +Minimal example: + +```python +import multiprocessing as mp + +# RPC +RPC_USER = "user" +RPC_PASSWORD = "password" +RPC_HOST = "127.0.0.1" +RPC_PORT = 8332 + +# Wallet +WALLET_ADDRESS = "" + +# Mining +DIFFICULTY_FACTOR = 1.0 +NONCE_MODE = "incremental" # incremental | random | mixed +TIMESTAMP_UPDATE_INTERVAL = 30 +BATCH = 10_000 +COINBASE_MESSAGE = "/py-miner/" +EXTRANONCE1 = "1234567890abcdef" +EXTRANONCE2 = "12341234" + +# Workers +_NUM_PROCESSORS = 0 +NUM_PROCESSORS = _NUM_PROCESSORS if _NUM_PROCESSORS > 0 else mp.cpu_count() + +# Watchdog +CHECK_INTERVAL = 20 +``` + +## Supported Configuration Behavior + +- `NONCE_MODE`: must be `incremental`, `random`, or `mixed` +- `TIMESTAMP_UPDATE_INTERVAL`: seconds, `0` disables periodic timestamp updates +- `BATCH`: number of nonces per compute batch +- `NUM_PROCESSORS`: + - `> 0`: exact number of workers + - `<= 0`: auto CPU count +- `DIFFICULTY_FACTOR`: + - on `regtest`: + - `0`: keep original template target + - `> 0`: compute target as `max_target / factor` + - `< 0`: clamped to `0.1` + - on non-`regtest`: forced to `1.0` + +## Run + +Default: + +```bash +python launcher.py +``` + +CLI options: +- `-n`, `--num-procs`: number of workers +- `--base-extranonce2`: base hex value for per-worker extranonce2 derivation + +Example: + +```bash +python launcher.py -n 4 --base-extranonce2 12341234 +``` + +## Operational Notes + +- `EXTRANONCE1` and `EXTRANONCE2` must be valid hex strings. +- Coinbase `scriptSig` must remain within protocol constraints (the implementation enforces a 100-byte limit). +- Invalid RPC credentials or invalid wallet address will fail at runtime. + +## Tested Capacity + +Reference test result for this miner implementation: +- Platform: Raspberry Pi 5 +- CPU usage: 4 cores +- Observed throughput: **~1.8 MH/s** + +Performance depends on clock profile, thermal conditions, operating system, and background load. + +## Current Limits (By Design) + +This is a Python starter implementation. It intentionally does not include: +- ASIC/GPU kernels +- Stratum server/client stack +- advanced failover/job persistence +- extensive benchmark/profiling harness + +## Recommended Evolution Path + +1. Add strict schema validation for `config.py`. +2. Add deterministic unit tests for block assembly and hashing paths. +3. Introduce microbenchmarks for nonce-loop and hashing batches. +4. Add integration tests against disposable `bitcoind` `regtest` nodes. +5. Extract core components into reusable packages (RPC, builder, PoW engine). +6. Move performance-critical sections to C/Rust/CUDA while preserving Python orchestration. + +## License + +MIT. See [LICENSE](LICENSE). diff --git a/block_builder.py b/block_builder.py new file mode 100644 index 0000000..75739d3 --- /dev/null +++ b/block_builder.py @@ -0,0 +1,167 @@ +import struct, logging +from binascii import unhexlify, hexlify +from utils import double_sha256, encode_varint + +log = logging.getLogger(__name__) + +def tx_encode_coinbase_height(height: int) -> str: + """ + Encode block height according to BIP34 (CScriptNum format) to include it + in the coinbase transaction scriptSig. + """ + if height < 0: + raise ValueError("Block height must be greater than or equal to 0.") + if height == 0: + return "00" + result = bytearray() + v = height + while v: + result.append(v & 0xff) + v >>= 8 + # If the most significant bit of the last byte is 1, append 0x00 + if result and (result[-1] & 0x80): + result.append(0x00) + return f"{len(result):02x}" + result.hex() + +def is_segwit_tx(raw_hex: str) -> bool: + """ + Return True if the transaction is serialized in SegWit format. + """ + return len(raw_hex) >= 12 and raw_hex[8:12] == "0001" + +def build_coinbase_transaction(template, miner_script_pubkey, extranonce1, extranonce2, coinbase_message=None): + """Build a coinbase transaction for mining.""" + height = template["height"] + reward = template["coinbasevalue"] + wc_raw = template.get("default_witness_commitment") # can be full script or root only + segwit = bool(wc_raw) + + tx_version = struct.pack(" 100: + raise ValueError("scriptSig > 100 byte") + + parts.append(encode_varint(len(script_sig)//2) + script_sig) + parts.append("ffffffff") # sequence + + # ---- outputs ------------------------------------------------------- + outputs = [] + + miner_out = struct.pack(" str: + """Compute the Merkle root for a list of transaction IDs.""" + # leaves in little-endian bytes format + tx_hashes = [unhexlify(coinbase_txid)[::-1]] + [ + unhexlify(tx["hash"])[::-1] for tx in transactions + ] + + while len(tx_hashes) > 1: + if len(tx_hashes) % 2: + tx_hashes.append(tx_hashes[-1]) + tx_hashes = [ + double_sha256(tx_hashes[i] + tx_hashes[i+1]) + for i in range(0, len(tx_hashes), 2) + ] + + return hexlify(tx_hashes[0][::-1]).decode() + +def build_block_header(version, prev_hash, merkle_root, timestamp, bits, nonce): + """ + Build the 80-byte block header and return it as a hex string. + """ + # Build header by concatenating all fields in binary format + header = ( + struct.pack(" INFO + log.info("Block serialization started") + + # Compute total transaction count (coinbase + regular transactions) + num_tx = len(transactions) + 1 # +1 to include coinbase + # Encode transaction count as hex VarInt + num_tx_hex = encode_varint(num_tx) + + try: + # Concatenate all regular transactions in hex + transactions_hex = "".join(tx["data"] for tx in transactions) + except KeyError as e: + # Operational error -> ERROR (includes stack trace) + log.exception("A transaction is missing the 'data' field") + return None + + # Assemble full block: header + tx count + coinbase + remaining txs + block_hex = header_hex + num_tx_hex + coinbase_tx + transactions_hex + + # Success confirmation -> INFO + log.info("Block serialized successfully - %d total transactions", num_tx) + + # Verbose detail (can be thousands of characters) -> DEBUG + log.debug("Block HEX: %s", block_hex) + + # Return serialized block + return block_hex diff --git a/config.py.example b/config.py.example new file mode 100644 index 0000000..16b2e2b --- /dev/null +++ b/config.py.example @@ -0,0 +1,67 @@ +"""Complete miner configuration example. + +This file documents all options supported by the program. +""" + +import multiprocessing as mp + +# --------------------------------------------------------------------------- +# RPC (Bitcoin Core) +# --------------------------------------------------------------------------- +RPC_USER = "user" +RPC_PASSWORD = "password" +RPC_HOST = "127.0.0.1" +RPC_PORT = 18443 + +# --------------------------------------------------------------------------- +# Wallet +# --------------------------------------------------------------------------- +# Address that receives the coinbase reward. +# It must be valid for the node you are connected to. +WALLET_ADDRESS = "" + +# --------------------------------------------------------------------------- +# Mining +# --------------------------------------------------------------------------- +# Network behavior: +# - regtest: 0 -> use network target; >0 -> max_target / factor; <0 -> forced to 0.1 +# - non-regtest: ignored and always set to 1.0 +DIFFICULTY_FACTOR = 1.0 + +# Supported values: +# - "incremental": initial nonce is 0, then increments +# - "random": initial nonce is random, then increments by batch +# - "mixed": initial nonce is random, then increments +NONCE_MODE = "incremental" + +# Interval (seconds) to update the header timestamp. +# 0 disables periodic timestamp updates. +TIMESTAMP_UPDATE_INTERVAL = 30 + +# Number of nonces computed per batch (int > 0 recommended). +BATCH = 10_000 + +# Custom message inserted into coinbase scriptSig. +COINBASE_MESSAGE = "/py-miner/" + +# Extranonce values used in coinbase scriptSig. +# Must be hexadecimal strings (only 0-9a-fA-F) with even length. +EXTRANONCE1 = "1234567890abcdef" +EXTRANONCE2 = "12341234" + +# Practical note: total coinbase scriptSig must remain <= 100 bytes. + +# --------------------------------------------------------------------------- +# Worker +# --------------------------------------------------------------------------- +# Same semantics as config.py: +# - >0: use that exact number of processes +# - <=0: use all available CPU cores +_NUM_PROCESSORS = 0 +NUM_PROCESSORS = _NUM_PROCESSORS if _NUM_PROCESSORS > 0 else mp.cpu_count() + +# --------------------------------------------------------------------------- +# Watchdog +# --------------------------------------------------------------------------- +# Best-block polling interval (seconds). +CHECK_INTERVAL = 20 diff --git a/launcher.py b/launcher.py new file mode 100644 index 0000000..b20c7ed --- /dev/null +++ b/launcher.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +import argparse +import importlib +import logging +import multiprocessing as mp +import os +import sys +import time + +import config + +log = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Utilities +# --------------------------------------------------------------------------- + +def _extranonce2(base: str, idx: int) -> str: + """Return `base + idx` in hex, preserving the same width.""" + return f"{int(base, 16) + idx:0{len(base)}x}" + + +# --------------------------------------------------------------------------- +# Worker +# --------------------------------------------------------------------------- + +def _worker(idx: int, base_ex2: str, q: mp.Queue) -> None: + """Start a mining process and send structured events to the supervisor.""" + try: + os.sched_setaffinity(0, {idx}) + except (AttributeError, OSError): + pass + + # Workers send structured events through the queue; verbose logs are suppressed + logging.basicConfig( + level=logging.WARNING, + format="%(asctime)s | %(levelname)s | %(name)s | %(message)s", + ) + + main = importlib.import_module("main") + try: + main.main( + event_queue=q, + worker_idx=idx, + extranonce2=_extranonce2(base_ex2, idx), + ) + except KeyboardInterrupt: + pass + + +# --------------------------------------------------------------------------- +# Supervisor +# --------------------------------------------------------------------------- + +def _clear_lines(n: int) -> None: + for _ in range(n): + sys.stdout.write("\033[F\033[K") + sys.stdout.flush() + + +def _aggregate(q: mp.Queue, n: int) -> str: + """ + Receive structured events from workers and update the dashboard. + Return "restart" when a block is found and submitted. + """ + rates: list[float] = [0.0] * n + attempts: list[int] = [0] * n + block_hash: str | None = None + winner_idx: int | None = None + winner_rate: float | None = None + + t_start = time.time() + last_print = 0.0 + lines_printed = 0 + + while True: + try: + tag, idx, val = q.get(timeout=0.1) + + if tag == "status": + rates[idx] = val["rate"] + attempts[idx] = val["attempts"] + elif tag == "found": + winner_idx = idx + winner_rate = val.get("rate") if val else None + elif tag == "hash": + block_hash = val + elif tag == "submit": + _clear_lines(lines_printed) + elapsed = time.time() - t_start + total_att = sum(attempts) + avg_rate_k = total_att / elapsed / 1000 if elapsed else 0.0 + print("=" * 78) + print("[✓] BLOCK FOUND AND SUBMITTED") + print(f" • Hash: {block_hash or 'N/D'}") + if winner_idx is not None: + print(f" • Worker: {winner_idx}") + if winner_rate is not None: + print(f" • Worker hashrate: {winner_rate:.2f} kH/s") + print(f" • Average total hashrate: {avg_rate_k:,.2f} kH/s") + print(f" • Total attempts: {total_att:,}") + print("=" * 78) + return "restart" + + except Exception: + pass # empty queue + + now = time.time() + if now - last_print >= 1.0: + if lines_printed > 0: + _clear_lines(lines_printed) + + tot_rate = sum(rates) + tot_att = sum(attempts) + ts = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(now)) + + lines = [ + f"{ts} | MINING STATUS", + "=" * 40, + f"Total: {tot_rate:,.2f} kH/s | Attempts: {tot_att:,}", + "-" * 40, + ] + for i in range(n): + lines.append(f"Worker {i:<2}: {rates[i]:.2f} kH/s | Attempts: {attempts[i]:,}") + + print("\n".join(lines), flush=True) + lines_printed = len(lines) + last_print = now + + +# --------------------------------------------------------------------------- +# Start/restart loop +# --------------------------------------------------------------------------- + +def launch(n: int, base_ex2: str) -> None: + log.info("Per-process extranonce2:") + for i in range(n): + log.info(" • Process %d: extranonce2=%s", i, _extranonce2(base_ex2, i)) + + while True: + q = mp.Queue() + workers = [ + mp.Process(target=_worker, args=(i, base_ex2, q), daemon=True) + for i in range(n) + ] + for p in workers: + p.start() + + try: + reason = _aggregate(q, n) + finally: + for p in workers: + if p.is_alive(): + p.terminate() + for p in workers: + p.join() + + if reason != "restart": + break + print("\nRestarting workers...\n") + time.sleep(1) + + +# --------------------------------------------------------------------------- +# CLI entry point +# --------------------------------------------------------------------------- + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser("Multiprocess launcher for main.py miner") + parser.add_argument( + "-n", "--num-procs", + type=int, default=config.NUM_PROCESSORS, + help=f"Number of workers (default: {config.NUM_PROCESSORS})", + ) + parser.add_argument( + "--base-extranonce2", + default=config.EXTRANONCE2, + help=f"Hex base for EXTRANONCE2 (default: {config.EXTRANONCE2})", + ) + return parser.parse_args() + + +if __name__ == "__main__": + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s | %(levelname)s | %(name)s | %(message)s", + ) + + mp.set_start_method("spawn", force=True) + args = _parse_args() + + from rpc import test_rpc_connection + test_rpc_connection() + + print(f"\nStarting mining with {args.num_procs} processes (extranonce2 base={args.base_extranonce2})\n") + launch(args.num_procs, args.base_extranonce2) diff --git a/main.py b/main.py new file mode 100644 index 0000000..250f6d3 --- /dev/null +++ b/main.py @@ -0,0 +1,145 @@ +import hashlib +import logging +import threading +import time + +import config +from block_builder import ( + build_block_header, build_coinbase_transaction, + calculate_merkle_root, is_segwit_tx, serialize_block, +) +from miner import mine_block +from rpc import ( + connect_rpc, get_best_block_hash, get_block_template, + ensure_witness_data, submit_block, test_rpc_connection, +) +from utils import calculate_target, watchdog_bestblock + +log = logging.getLogger(__name__) + + +def _prepare_template(rpc) -> dict | None: + """Fetch and enrich the block template. Return None on error.""" + template = get_block_template(rpc) + if not template: + return None + ensure_witness_data(rpc, template) + tot_tx = len(template["transactions"]) + witness_tx = sum(1 for tx in template["transactions"] if is_segwit_tx(tx["data"])) + log.info( + "Template height=%d total tx=%d legacy=%d segwit=%d", + template["height"], tot_tx, tot_tx - witness_tx, witness_tx, + ) + return template + + +def main( + event_queue=None, + worker_idx: int = 0, + extranonce2: str | None = None, +) -> None: + """ + Main mining loop. + + Optional parameters for multiprocess usage via launcher: + event_queue - queue used to send structured events to supervisor + worker_idx - worker index (for event identification) + extranonce2 - worker-specific extranonce2 value + """ + extranonce2 = extranonce2 or config.EXTRANONCE2 + + test_rpc_connection() + log.info("Extranonce2: %s | Coinbase: %s", extranonce2, config.COINBASE_MESSAGE) + + # Main connection reused for the whole process lifecycle + rpc = connect_rpc() + + # Fetch chain and scriptPubKey once - they do not change across cycles + network = rpc.getblockchaininfo().get("chain", "") + miner_script = rpc.getaddressinfo(config.WALLET_ADDRESS)["scriptPubKey"] + log.info("Chain: %s", network) + + def _on_status(attempts: int, hashrate: float) -> None: + if event_queue is not None: + event_queue.put(("status", worker_idx, {"rate": hashrate / 1000, "attempts": attempts})) + + while True: + try: + log.info("=== New mining cycle ===") + + # STEP 1-3: template + template = _prepare_template(rpc) + if not template: + log.error("Unable to fetch template. Retrying in 5s...") + time.sleep(5) + continue + + # STEP 4: coinbase + coinbase_tx, coinbase_txid = build_coinbase_transaction( + template, miner_script, + config.EXTRANONCE1, extranonce2, + config.COINBASE_MESSAGE, + ) + + # STEP 5-7: target, Merkle root, header + modified_target = calculate_target(template, config.DIFFICULTY_FACTOR, network) + merkle_root = calculate_merkle_root(coinbase_txid, template["transactions"]) + header_hex = build_block_header( + template["version"], template["previousblockhash"], + merkle_root, template["curtime"], template["bits"], 0, + ) + + # STEP 8: start watchdog and mining + stop_event = threading.Event() + new_block_event = threading.Event() + rpc_watch = connect_rpc() + t_watch = threading.Thread( + target=watchdog_bestblock, + args=(rpc_watch, stop_event, new_block_event, get_best_block_hash), + daemon=True, + ) + t_watch.start() + + mined_header_hex, nonce, hashrate = mine_block( + header_hex, modified_target, config.NONCE_MODE, stop_event, _on_status, + ) + + stop_event.set() + t_watch.join(timeout=0.2) + + if new_block_event.is_set() or mined_header_hex is None: + log.info("Cycle interrupted: restarting with updated template") + continue + + # STEP 9: block hash and supervisor notification + header_bytes = bytes.fromhex(mined_header_hex) + block_hash = hashlib.sha256(hashlib.sha256(header_bytes).digest()).digest()[::-1].hex() + log.info("Found block hash: %s", block_hash) + + if event_queue is not None: + event_queue.put(("found", worker_idx, {"rate": hashrate / 1000 if hashrate else 0})) + event_queue.put(("hash", worker_idx, block_hash)) + + # STEP 10: serialize and submit + serialized_block = serialize_block(mined_header_hex, coinbase_tx, template["transactions"]) + if not serialized_block: + log.error("Block serialization failed. Retrying...") + continue + + submit_block(rpc, serialized_block) + + if event_queue is not None: + event_queue.put(("submit", worker_idx, None)) + + except Exception: + log.exception("Error in mining cycle") + + time.sleep(1) + + +if __name__ == "__main__": + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s | %(levelname)s | %(name)s | %(message)s", + ) + main() diff --git a/miner.py b/miner.py new file mode 100644 index 0000000..435b950 --- /dev/null +++ b/miner.py @@ -0,0 +1,140 @@ +import random +import struct +import time +import hashlib +import logging +from binascii import hexlify, unhexlify +from threading import Event +from typing import Callable + +import config + +log = logging.getLogger(__name__) + +# Seconds between hashrate logs +_RATE_INT = 2 + + +def _compute_hash_batch( + header_76: bytes, + start_nonce: int, + batch_size: int, + target_be: bytes, +): + """ + Compute hashes for a nonce batch and return the first valid nonce found. + + Optimizations: + - Precompute first chunk (64 bytes) with sha256.copy() to avoid + re-updating the invariant portion on each nonce. + - Preallocate a 16-byte tail and update only the nonce field + with struct.pack_into to minimize allocations. + - Local function binding to reduce lookups in the hot loop. + """ + first_chunk = header_76[:64] + tail_static = header_76[64:] # 12 bytes: merkle[28:32] + ts(4) + bits(4) + + sha_base = hashlib.sha256() + sha_base.update(first_chunk) + + tail = bytearray(16) + tail[0:12] = tail_static + + sha_copy = sha_base.copy + sha256 = hashlib.sha256 + pack_into = struct.pack_into + + for i in range(batch_size): + n = (start_nonce + i) & 0xFFFFFFFF + pack_into("= ts_interval: + ts_bytes = struct.pack("= _RATE_INT: + hashrate = (attempts - last_rate_n) / (now - last_rate_t) + last_rate_t = now + last_rate_n = attempts + log.info( + "Mining status - hashrate=%.2f kH/s attempts=%d nonce=%d", + hashrate / 1000, attempts, nonce, + ) + if status_callback: + status_callback(attempts, hashrate) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c3dfc30 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +python-bitcoinrpc==1.0 +python-dotenv==1.0.0 +numpy +numba \ No newline at end of file diff --git a/rpc.py b/rpc.py new file mode 100644 index 0000000..87fd025 --- /dev/null +++ b/rpc.py @@ -0,0 +1,101 @@ +import logging + +from bitcoinrpc.authproxy import AuthServiceProxy + +import config + +log = logging.getLogger(__name__) + + +def connect_rpc() -> AuthServiceProxy: + """Create an RPC connection to the Bitcoin node.""" + return AuthServiceProxy( + f"http://{config.RPC_USER}:{config.RPC_PASSWORD}@{config.RPC_HOST}:{config.RPC_PORT}" + ) + + +def test_rpc_connection() -> None: + """Check the connection and show basic blockchain information.""" + log.info("Checking RPC connection") + try: + info = connect_rpc().getblockchaininfo() + log.info( + "RPC connection successful - chain=%s, blocks=%d, difficulty=%s", + info["chain"], info["blocks"], info["difficulty"], + ) + except Exception: + log.exception("RPC connection error") + raise + + +def get_best_block_hash(rpc) -> str | None: + """Fetch the most recent block hash.""" + try: + h = rpc.getbestblockhash() + log.debug("Best block hash: %s", h) + return h + except Exception as e: + log.error("RPC error getbestblockhash: %s", e) + return None + + +def get_block_template(rpc) -> dict | None: + """Request a block template with SegWit support.""" + try: + tpl = rpc.getblocktemplate({"rules": ["segwit"]}) + log.debug("Template received - height=%d, tx=%d", tpl["height"], len(tpl["transactions"])) + return tpl + except Exception as e: + log.error("RPC error getblocktemplate: %s", e) + return None + + +def ensure_witness_data(rpc, template: dict) -> None: + """ + Enrich template transactions with full witness data. + Use a single HTTP batch call to reduce latency versus N single requests. + """ + txs = template["transactions"] + if not txs: + return + + # JSON-RPC batch call: one HTTP request for all transactions + try: + batch = [["getrawtransaction", tx["txid"], False] for tx in txs] + results = rpc._batch(batch) + raw_map = { + txs[r["id"]]["txid"]: r["result"] + for r in results + if r.get("result") is not None + } + except Exception as e: + log.warning("RPC batch unavailable, falling back to single calls: %s", e) + raw_map = {} + for tx in txs: + try: + raw = rpc.getrawtransaction(tx["txid"], False) + if raw: + raw_map[tx["txid"]] = raw + except Exception as e2: + log.debug("Missing raw witness for %s: %s", tx["txid"], e2) + + template["transactions"] = [ + {"hash": tx["txid"], "data": raw_map.get(tx["txid"], tx["data"])} + for tx in txs + ] + + +def submit_block(rpc, serialized_block: str) -> None: + """Submit the mined block to the Bitcoin node.""" + log.info("Submitting serialized block (%d bytes) to node", len(serialized_block) // 2) + if not serialized_block: + log.error("Block not serialized correctly - submission canceled") + return + try: + result = rpc.submitblock(serialized_block) + if result is None: + log.info("Block accepted into the blockchain") + else: + log.error("submitblock returned: %s", result) + except Exception: + log.exception("RPC error during submitblock") diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..e50fca6 --- /dev/null +++ b/utils.py @@ -0,0 +1,86 @@ +"""Common utility module with shared functions for the miner project.""" + +import hashlib +import logging +import threading + +import config + +log = logging.getLogger(__name__) + + +def double_sha256(data: bytes) -> bytes: + """Compute double SHA-256 for the given data.""" + return hashlib.sha256(hashlib.sha256(data).digest()).digest() + + +def encode_varint(value: int) -> str: + """Encode a number as VarInt according to the Bitcoin protocol.""" + if value < 0xFD: + return value.to_bytes(1, "little").hex() + if value <= 0xFFFF: + return "fd" + value.to_bytes(2, "little").hex() + if value <= 0xFFFFFFFF: + return "fe" + value.to_bytes(4, "little").hex() + if value <= 0xFFFFFFFFFFFFFFFF: + return "ff" + value.to_bytes(8, "little").hex() + raise ValueError("Value exceeds maximum VarInt limit (2^64-1)") + + +def decode_nbits(nBits: int) -> str: + """Decode the nBits field into a 256-bit target in hexadecimal format.""" + exponent = (nBits >> 24) & 0xFF + significand = nBits & 0x007FFFFF + return f"{(significand << (8 * (exponent - 3))):064x}" + + +def calculate_target(template, difficulty_factor: float, network: str) -> str: + """Compute a modified target based on network and difficulty factor.""" + if network == "regtest": + if difficulty_factor < 0: + difficulty_factor = 0.1 + else: + difficulty_factor = 1.0 + + nBits_int = int(template["bits"], 16) + original_target = decode_nbits(nBits_int) + + if difficulty_factor == 0: + return original_target + + max_target = 0x00000000FFFF0000000000000000000000000000000000000000000000000000 + target_value = int(max_target / difficulty_factor) + return f"{min(target_value, (1 << 256) - 1):064x}" + + +def watchdog_bestblock( + rpc_conn, + stop_event: threading.Event, + new_block_event: threading.Event, + get_best_block_hash_func, +) -> None: + """ + Periodically check if there is a new best block. + When detected, set new_block_event and stop_event + to interrupt current mining within the next batch. + """ + log.info("Watchdog started.") + try: + last_hash = get_best_block_hash_func(rpc_conn) + except Exception as e: + log.error("Watchdog: unable to get initial hash: %s", e) + return + + while not stop_event.wait(config.CHECK_INTERVAL): + try: + new_hash = get_best_block_hash_func(rpc_conn) + if new_hash and new_hash != last_hash: + log.info("New best block: %s", new_hash) + last_hash = new_hash + new_block_event.set() + stop_event.set() # interrupt current miner + return + except Exception as e: + log.error("Watchdog error: %s", e) + + log.info("Watchdog stopped.")