diff --git a/README.md b/README.md index c23a70f..f732301 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Query the Bitcoin network directly via the [Mempool.space](https://mempool.space | `btc-toolkit opreturn ` | Decode OP_RETURN messages from a transaction | ✅ Phase 1 | | `btc-toolkit balance
` | Confirmed + unconfirmed balance of any address | ✅ Phase 2 | | `btc-toolkit fees` | Recommended fee rates + mempool backlog | ✅ Phase 3 | -| `btc-toolkit block ` | Block metadata explorer | Phase 4 | +| `btc-toolkit block ` | Block metadata by height, hash, or latest | ✅ Phase 4 | | `btc-toolkit utxo
` | UTXO set inspector | Phase 5 | ## Installation @@ -72,6 +72,24 @@ btc-toolkit fees --json btc-toolkit fees --network testnet ``` +### block — inspect any block + +```bash +btc-toolkit block latest # chain tip +btc-toolkit block 0 # by height (genesis) +btc-toolkit block 000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f # by hash +``` + +Shows height, hash, mined timestamp (UTC), tx count, size, weight, difficulty, nonce, and previous block hash. + +```bash +# JSON output for scripting +btc-toolkit block latest --json + +# Testnet +btc-toolkit block latest --network testnet +``` + ### opreturn — decode embedded messages ```bash @@ -114,11 +132,13 @@ btc-toolkit/ │ ├── colors.py # Shared terminal color helpers │ ├── opreturn.py # Phase 1 — OP_RETURN decoder │ ├── balance.py # Phase 2 — Address balance checker -│ └── fees.py # Phase 3 — Fee estimator +│ ├── fees.py # Phase 3 — Fee estimator +│ └── block.py # Phase 4 — Block info explorer ├── tests/ │ ├── test_opreturn.py # 18 tests (mocked API + parser validation) │ ├── test_balance.py # 18 tests (sats math + API response parsing) -│ └── test_fees.py # 6 tests (rates + backlog parsing) +│ ├── test_fees.py # 6 tests (rates + backlog parsing) +│ └── test_block.py # 12 tests (ref detection + genesis data) ├── pyproject.toml ├── LICENSE # MIT └── README.md @@ -134,7 +154,7 @@ Zero external dependencies — Python standard library only (`urllib`, `json`, ` python -m pytest tests/ -v ``` -42 tests, all API calls mocked — the suite runs offline. +54 tests, all API calls mocked — the suite runs offline. ![Tests passing](assets/tests.png) @@ -155,7 +175,7 @@ This is the same model used by Esplora/Electrs. Don't trust this README — veri - [x] **Phase 1** — OP_RETURN Reader - [x] **Phase 2** — Address Balance Checker - [x] **Phase 3** — Fee Estimator (mempool-based) -- [ ] **Phase 4** — Block Info Explorer +- [x] **Phase 4** — Block Info Explorer - [ ] **Phase 5** — UTXO Set Inspector All phases follow the same philosophy: **zero dependencies, no Bitcoin Core, verify everything on-chain.** @@ -166,6 +186,7 @@ Every txid, address, hex value, and technical claim in this README can be indepe - Transaction data: `https://mempool.space/api/tx/` - Address data: `https://mempool.space/api/address/
` - Fee data: `https://mempool.space/api/v1/fees/recommended` +- Block data: `https://mempool.space/api/block/` - OP_RETURN spec: [learnmeabitcoin.com/technical/script/return](https://learnmeabitcoin.com/technical/script/return/) - Esplora API model: [github.com/Blockstream/esplora/blob/master/API.md](https://github.com/Blockstream/esplora/blob/master/API.md) diff --git a/btc_toolkit/__init__.py b/btc_toolkit/__init__.py index 02b67a1..f680986 100644 --- a/btc_toolkit/__init__.py +++ b/btc_toolkit/__init__.py @@ -1,3 +1,3 @@ """btc-toolkit — Bitcoin CLI tools. Zero dependencies, no Bitcoin Core required.""" -__version__ = "0.3.0" +__version__ = "0.4.0" diff --git a/btc_toolkit/block.py b/btc_toolkit/block.py new file mode 100644 index 0000000..1f1b63c --- /dev/null +++ b/btc_toolkit/block.py @@ -0,0 +1,139 @@ +""" +Block info explorer. + +Queries the Mempool.space API for block metadata by height, hash, +or the chain tip. + +Endpoints: + GET /api/block/:hash -> block details (JSON) + GET /api/block-height/:height -> block hash (plain text) + GET /api/blocks/tip/height -> current tip height (plain text) +""" + +from dataclasses import dataclass +from datetime import datetime, timezone + +from .api import get_json, get_text, NotFoundError + + +class BlockNotFoundError(NotFoundError): + """Raised when a block height or hash is not found.""" + + +@dataclass +class BlockInfo: + """Metadata for a single Bitcoin block.""" + + hash: str + height: int + timestamp: int + tx_count: int + size: int + weight: int + version: int + merkle_root: str + previousblockhash: str + nonce: int + bits: int + difficulty: float + mediantime: int + + @property + def timestamp_utc(self) -> str: + """Block timestamp as an ISO-8601 UTC string.""" + return datetime.fromtimestamp(self.timestamp, tz=timezone.utc).strftime( + "%Y-%m-%d %H:%M:%S UTC" + ) + + @property + def size_mb(self) -> float: + """Block size in MB (1 MB = 1_000_000 bytes).""" + return self.size / 1_000_000 + + def to_dict(self) -> dict: + return { + "hash": self.hash, + "height": self.height, + "timestamp": self.timestamp, + "timestamp_utc": self.timestamp_utc, + "tx_count": self.tx_count, + "size_bytes": self.size, + "size_mb": round(self.size_mb, 2), + "weight": self.weight, + "version": self.version, + "merkle_root": self.merkle_root, + "previousblockhash": self.previousblockhash, + "nonce": self.nonce, + "bits": self.bits, + "difficulty": self.difficulty, + "mediantime": self.mediantime, + } + + +def _is_block_hash(ref: str) -> bool: + """A block hash is 64 hex chars; a height is a decimal number.""" + ref = ref.strip().lower() + return len(ref) == 64 and all(c in "0123456789abcdef" for c in ref) + + +def _is_height(ref: str) -> bool: + return ref.strip().isdigit() + + +def get_tip_height(network: str = "mainnet") -> int: + """Return the current chain tip height.""" + return int(get_text("/blocks/tip/height", network)) + + +def get_block(ref: str, network: str = "mainnet") -> BlockInfo: + """ + Fetch block metadata by height, hash, or 'latest'. + + Args: + ref: Block height (decimal), block hash (64 hex chars), + or the literal string 'latest' for the chain tip. + network: 'mainnet' or 'testnet'. + + Returns: + A BlockInfo with the block's metadata. + + Raises: + ValueError: If ref is neither a height, a hash, nor 'latest'. + BlockNotFoundError: If the block does not exist. + MempoolAPIError: On other API errors. + """ + ref = ref.strip() + + try: + if ref.lower() == "latest": + height = get_tip_height(network) + block_hash = get_text(f"/block-height/{height}", network) + elif _is_height(ref): + block_hash = get_text(f"/block-height/{ref}", network) + elif _is_block_hash(ref): + block_hash = ref.lower() + else: + raise ValueError( + f"Invalid block reference: {ref!r}. " + "Use a height, a 64-char hash, or 'latest'." + ) + + data = get_json(f"/block/{block_hash}", network) + except NotFoundError as e: + raise BlockNotFoundError(f"Block not found: {ref}") from e + + return BlockInfo( + hash=data.get("id", block_hash), + height=data.get("height", 0), + timestamp=data.get("timestamp", 0), + tx_count=data.get("tx_count", 0), + size=data.get("size", 0), + weight=data.get("weight", 0), + version=data.get("version", 0), + merkle_root=data.get("merkle_root", ""), + previousblockhash=data.get("previousblockhash", ""), + nonce=data.get("nonce", 0), + bits=data.get("bits", 0), + difficulty=data.get("difficulty", 0), + mediantime=data.get("mediantime", 0), + ) diff --git a/btc_toolkit/cli.py b/btc_toolkit/cli.py index 652dbab..e3e9fc7 100644 --- a/btc_toolkit/cli.py +++ b/btc_toolkit/cli.py @@ -22,6 +22,7 @@ from .opreturn import decode_op_return, TransactionNotFoundError from .balance import get_balance, AddressNotFoundError from .fees import get_fees +from .block import get_block, BlockNotFoundError BANNER = r""" @@ -215,6 +216,65 @@ def _fees_json(args: argparse.Namespace) -> int: return 0 +# ────────────────────────────────────────────────────────────────────── +# block subcommand +# ────────────────────────────────────────────────────────────────────── + +def _cmd_block(args: argparse.Namespace) -> int: + if args.json_output: + return _block_json(args) + + print(c.cyan(BANNER)) + print(c.dim(f" btc-toolkit v{__version__} · block · Mempool.space API\n")) + + print(f" {c.bold('Block:')} {args.ref}") + print(f" {c.bold('Network:')} {args.network}") + print(f" {'─' * 48}\n") + + try: + blk = get_block(args.ref, args.network) + except BlockNotFoundError: + print(f" {c.red('✗')} Block not found: {args.ref}\n") + return 1 + except MempoolAPIError as e: + print(f" {c.red('✗')} API error: {e}\n") + return 1 + except ValueError as e: + print(f" {c.red('✗')} {e}\n") + return 1 + + hash_short = f"{blk.hash[:16]}...{blk.hash[-8:]}" + prev_short = ( + f"{blk.previousblockhash[:16]}...{blk.previousblockhash[-8:]}" + if blk.previousblockhash else c.dim("(none — genesis block)") + ) + + print(f" {c.bold(f'Block #{blk.height:,}')}\n") + print(f" ├─ Hash: {c.green(hash_short)}") + print(f" ├─ Mined: {blk.timestamp_utc}") + print(f" ├─ Txs: {blk.tx_count:,}") + print(f" ├─ Size: {blk.size_mb:.2f} MB ({blk.size:,} bytes)") + print(f" ├─ Weight: {blk.weight:,} WU") + print(f" ├─ Difficulty: {blk.difficulty:,.0f}") + print(f" ├─ Nonce: {blk.nonce}") + print(f" └─ Previous: {prev_short}") + print() + print(f" {c.dim(f'https://mempool.space/block/{blk.hash}')}\n") + return 0 + + +def _block_json(args: argparse.Namespace) -> int: + try: + blk = get_block(args.ref, args.network) + except (BlockNotFoundError, MempoolAPIError, ValueError) as e: + print(json.dumps({"error": str(e), "ref": args.ref}, indent=2)) + return 1 + + output = {"network": args.network, **blk.to_dict()} + print(json.dumps(output, indent=2)) + return 0 + + # ────────────────────────────────────────────────────────────────────── # argument parser # ────────────────────────────────────────────────────────────────────── @@ -280,6 +340,23 @@ def build_parser() -> argparse.ArgumentParser: ) p_fees.set_defaults(func=_cmd_fees) + # block + p_blk = subparsers.add_parser( + "block", help="Show block metadata by height, hash, or 'latest'." + ) + p_blk.add_argument( + "ref", help="Block height, 64-char block hash, or 'latest'.", + ) + p_blk.add_argument( + "-n", "--network", choices=SUPPORTED_NETWORKS, default="mainnet", + help="Bitcoin network (default: mainnet).", + ) + p_blk.add_argument( + "--json", action="store_true", dest="json_output", + help="Output as JSON.", + ) + p_blk.set_defaults(func=_cmd_block) + return parser diff --git a/pyproject.toml b/pyproject.toml index e3e0fc8..a227f63 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "btc-toolkit" -version = "0.3.0" +version = "0.4.0" description = "Bitcoin CLI toolkit — OP_RETURN decoder, balance checker, fee estimator & more. Zero deps, no Bitcoin Core required." readme = "README.md" license = {text = "MIT"} diff --git a/tests/test_block.py b/tests/test_block.py new file mode 100644 index 0000000..883d06d --- /dev/null +++ b/tests/test_block.py @@ -0,0 +1,113 @@ +"""Tests for the block info explorer.""" + +import unittest +from unittest.mock import patch + +from btc_toolkit.block import ( + get_block, + get_tip_height, + BlockInfo, + _is_block_hash, + _is_height, +) + +# Real genesis block data (block 0) — verifiable at +# https://mempool.space/api/block/000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f +GENESIS_HASH = "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f" +GENESIS_RESPONSE = { + "id": GENESIS_HASH, + "height": 0, + "version": 1, + "timestamp": 1231006505, + "tx_count": 1, + "size": 285, + "weight": 1140, + "merkle_root": "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b", + "previousblockhash": None, + "mediantime": 1231006505, + "nonce": 2083236893, + "bits": 486604799, + "difficulty": 1, +} + + +class TestRefDetection(unittest.TestCase): + def test_valid_hash(self): + self.assertTrue(_is_block_hash(GENESIS_HASH)) + + def test_height_is_not_hash(self): + self.assertFalse(_is_block_hash("840000")) + + def test_valid_height(self): + self.assertTrue(_is_height("0")) + self.assertTrue(_is_height("840000")) + + def test_hash_is_not_height(self): + self.assertFalse(_is_height(GENESIS_HASH)) + + def test_garbage_is_neither(self): + self.assertFalse(_is_block_hash("not-a-block")) + self.assertFalse(_is_height("not-a-block")) + + +class TestGetBlock(unittest.TestCase): + @patch("btc_toolkit.block.get_json") + def test_by_hash(self, mock_json): + mock_json.return_value = GENESIS_RESPONSE + block = get_block(GENESIS_HASH) + self.assertEqual(block.height, 0) + self.assertEqual(block.tx_count, 1) + self.assertEqual(block.nonce, 2083236893) + self.assertEqual(block.difficulty, 1) + mock_json.assert_called_once_with(f"/block/{GENESIS_HASH}", "mainnet") + + @patch("btc_toolkit.block.get_json") + @patch("btc_toolkit.block.get_text") + def test_by_height(self, mock_text, mock_json): + mock_text.return_value = GENESIS_HASH + mock_json.return_value = GENESIS_RESPONSE + block = get_block("0") + self.assertEqual(block.hash, GENESIS_HASH) + mock_text.assert_called_once_with("/block-height/0", "mainnet") + + @patch("btc_toolkit.block.get_json") + @patch("btc_toolkit.block.get_text") + def test_latest(self, mock_text, mock_json): + # 'latest' resolves tip height, then that height's hash + mock_text.side_effect = ["840000", GENESIS_HASH] + mock_json.return_value = {**GENESIS_RESPONSE, "height": 840000} + block = get_block("latest") + self.assertEqual(block.height, 840000) + self.assertEqual(mock_text.call_count, 2) + + def test_invalid_ref_raises(self): + with self.assertRaises(ValueError): + get_block("not-a-block") + + @patch("btc_toolkit.block.get_json") + def test_genesis_timestamp_utc(self, mock_json): + mock_json.return_value = GENESIS_RESPONSE + block = get_block(GENESIS_HASH) + # 1231006505 = 2009-01-03 18:15:05 UTC (genesis block, verifiable) + self.assertEqual(block.timestamp_utc, "2009-01-03 18:15:05 UTC") + + @patch("btc_toolkit.block.get_json") + def test_to_dict_structure(self, mock_json): + mock_json.return_value = GENESIS_RESPONSE + d = get_block(GENESIS_HASH).to_dict() + self.assertEqual(d["height"], 0) + self.assertEqual(d["size_bytes"], 285) + self.assertEqual(d["timestamp_utc"], "2009-01-03 18:15:05 UTC") + self.assertIn("merkle_root", d) + + +class TestGetTipHeight(unittest.TestCase): + @patch("btc_toolkit.block.get_text") + def test_tip_height(self, mock_text): + mock_text.return_value = "905432" + self.assertEqual(get_tip_height(), 905432) + mock_text.assert_called_once_with("/blocks/tip/height", "mainnet") + + +if __name__ == "__main__": + unittest.main()