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
31 changes: 26 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Query the Bitcoin network directly via the [Mempool.space](https://mempool.space
| `btc-toolkit opreturn <txid>` | Decode OP_RETURN messages from a transaction | ✅ Phase 1 |
| `btc-toolkit balance <address>` | Confirmed + unconfirmed balance of any address | ✅ Phase 2 |
| `btc-toolkit fees` | Recommended fee rates + mempool backlog | ✅ Phase 3 |
| `btc-toolkit block <height\|hash>` | Block metadata explorer | Phase 4 |
| `btc-toolkit block <height\|hash>` | Block metadata by height, hash, or latest | ✅ Phase 4 |
| `btc-toolkit utxo <address>` | UTXO set inspector | Phase 5 |

## Installation
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)

Expand All @@ -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.**
Expand All @@ -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/<txid>`
- Address data: `https://mempool.space/api/address/<address>`
- Fee data: `https://mempool.space/api/v1/fees/recommended`
- Block data: `https://mempool.space/api/block/<hash>`
- 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)

Expand Down
2 changes: 1 addition & 1 deletion btc_toolkit/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""btc-toolkit — Bitcoin CLI tools. Zero dependencies, no Bitcoin Core required."""

__version__ = "0.3.0"
__version__ = "0.4.0"
139 changes: 139 additions & 0 deletions btc_toolkit/block.py
Original file line number Diff line number Diff line change
@@ -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),
)
77 changes: 77 additions & 0 deletions btc_toolkit/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand Down Expand Up @@ -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
# ──────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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


Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
Loading
Loading