btc-toolkit 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """btc-toolkit — Bitcoin CLI tools. Zero dependencies, no Bitcoin Core required."""
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1,6 @@
1
+ """Allow running as: python -m btc_toolkit <command> ..."""
2
+
3
+ import sys
4
+ from .cli import run
5
+
6
+ sys.exit(run())
btc_toolkit/api.py ADDED
@@ -0,0 +1,94 @@
1
+ """
2
+ Shared Mempool.space API client.
3
+
4
+ Centralizes all HTTP communication with the Mempool.space REST API.
5
+ Every subcommand (opreturn, balance, fees, block, utxo) uses this module
6
+ instead of duplicating connection logic.
7
+
8
+ No external dependencies — standard library only.
9
+ """
10
+
11
+ import json
12
+ import urllib.request
13
+ import urllib.error
14
+
15
+ from . import __version__
16
+
17
+ # Mempool.space API base URLs
18
+ MEMPOOL_API = "https://mempool.space/api"
19
+ MEMPOOL_TESTNET_API = "https://mempool.space/testnet/api"
20
+
21
+ SUPPORTED_NETWORKS = ("mainnet", "testnet")
22
+
23
+ _USER_AGENT = f"btc-toolkit/{__version__}"
24
+ _TIMEOUT = 15
25
+
26
+
27
+ class MempoolAPIError(Exception):
28
+ """Raised when the Mempool.space API returns an error."""
29
+
30
+
31
+ class NotFoundError(MempoolAPIError):
32
+ """Raised when a requested resource (tx, address, block) is not found."""
33
+
34
+
35
+ def get_api_base(network: str) -> str:
36
+ """Return the correct API base URL for the given network."""
37
+ if network not in SUPPORTED_NETWORKS:
38
+ raise ValueError(
39
+ f"Unsupported network: {network}. Use one of: {SUPPORTED_NETWORKS}"
40
+ )
41
+ if network == "testnet":
42
+ return MEMPOOL_TESTNET_API
43
+ return MEMPOOL_API
44
+
45
+
46
+ def get_json(path: str, network: str = "mainnet") -> dict | list:
47
+ """
48
+ GET a Mempool.space API endpoint and return parsed JSON.
49
+
50
+ Args:
51
+ path: API path beginning with '/', e.g. '/tx/<txid>' or '/address/<addr>'.
52
+ network: 'mainnet' or 'testnet'.
53
+
54
+ Returns:
55
+ Parsed JSON (dict or list).
56
+
57
+ Raises:
58
+ NotFoundError: On HTTP 404.
59
+ MempoolAPIError: On other HTTP or connection errors.
60
+ """
61
+ api_base = get_api_base(network)
62
+ url = f"{api_base}{path}"
63
+
64
+ try:
65
+ req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
66
+ with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp:
67
+ return json.loads(resp.read().decode("utf-8"))
68
+ except urllib.error.HTTPError as e:
69
+ if e.code == 404:
70
+ raise NotFoundError(f"Not found: {path}") from e
71
+ raise MempoolAPIError(f"API error {e.code}: {e.reason}") from e
72
+ except urllib.error.URLError as e:
73
+ raise MempoolAPIError(f"Connection error: {e.reason}") from e
74
+
75
+
76
+ def get_text(path: str, network: str = "mainnet") -> str:
77
+ """
78
+ GET a Mempool.space API endpoint that returns plain text (not JSON).
79
+
80
+ Used for endpoints like /blocks/tip/height that return a bare number.
81
+ """
82
+ api_base = get_api_base(network)
83
+ url = f"{api_base}{path}"
84
+
85
+ try:
86
+ req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
87
+ with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp:
88
+ return resp.read().decode("utf-8").strip()
89
+ except urllib.error.HTTPError as e:
90
+ if e.code == 404:
91
+ raise NotFoundError(f"Not found: {path}") from e
92
+ raise MempoolAPIError(f"API error {e.code}: {e.reason}") from e
93
+ except urllib.error.URLError as e:
94
+ raise MempoolAPIError(f"Connection error: {e.reason}") from e
btc_toolkit/balance.py ADDED
@@ -0,0 +1,132 @@
1
+ """
2
+ Address balance checker.
3
+
4
+ Queries the Mempool.space API for an address and computes its
5
+ confirmed and unconfirmed (mempool) balances.
6
+
7
+ Balance is derived as funded_txo_sum - spent_txo_sum, following the
8
+ Esplora/Mempool.space API model. All amounts are in satoshis, with
9
+ BTC conversion provided for display.
10
+ """
11
+
12
+ from dataclasses import dataclass
13
+
14
+ from .api import get_json, NotFoundError
15
+
16
+ SATS_PER_BTC = 100_000_000
17
+
18
+
19
+ class AddressNotFoundError(NotFoundError):
20
+ """Raised when an address has no data or is invalid upstream."""
21
+
22
+
23
+ @dataclass
24
+ class AddressBalance:
25
+ """Confirmed and unconfirmed balance for a Bitcoin address."""
26
+
27
+ address: str
28
+ confirmed_sats: int
29
+ unconfirmed_sats: int
30
+ confirmed_tx_count: int
31
+ mempool_tx_count: int
32
+ funded_sats: int
33
+ spent_sats: int
34
+
35
+ @property
36
+ def total_sats(self) -> int:
37
+ """Confirmed + unconfirmed balance."""
38
+ return self.confirmed_sats + self.unconfirmed_sats
39
+
40
+ @staticmethod
41
+ def sats_to_btc(sats: int) -> str:
42
+ """Format a satoshi amount as a BTC string with 8 decimals."""
43
+ # Use integer arithmetic to avoid float precision issues
44
+ sign = "-" if sats < 0 else ""
45
+ sats = abs(sats)
46
+ whole = sats // SATS_PER_BTC
47
+ frac = sats % SATS_PER_BTC
48
+ return f"{sign}{whole}.{frac:08d}"
49
+
50
+ def to_dict(self) -> dict:
51
+ return {
52
+ "address": self.address,
53
+ "confirmed": {
54
+ "sats": self.confirmed_sats,
55
+ "btc": self.sats_to_btc(self.confirmed_sats),
56
+ },
57
+ "unconfirmed": {
58
+ "sats": self.unconfirmed_sats,
59
+ "btc": self.sats_to_btc(self.unconfirmed_sats),
60
+ },
61
+ "total": {
62
+ "sats": self.total_sats,
63
+ "btc": self.sats_to_btc(self.total_sats),
64
+ },
65
+ "confirmed_tx_count": self.confirmed_tx_count,
66
+ "mempool_tx_count": self.mempool_tx_count,
67
+ "funded_sats": self.funded_sats,
68
+ "spent_sats": self.spent_sats,
69
+ }
70
+
71
+
72
+ def _validate_address(address: str) -> str:
73
+ """
74
+ Basic sanity check on a Bitcoin address.
75
+
76
+ This is a lightweight format guard, not full validation — the
77
+ Mempool.space API is the source of truth and will reject malformed
78
+ addresses. We only catch obviously bad input early.
79
+ """
80
+ address = address.strip()
81
+ if not address:
82
+ raise ValueError("Address cannot be empty")
83
+
84
+ # Bitcoin addresses are alphanumeric and within a reasonable length range.
85
+ # Bech32 (bc1...) can be up to 90 chars; legacy is ~26-35.
86
+ if len(address) < 14 or len(address) > 100:
87
+ raise ValueError(f"Invalid address length: {address}")
88
+
89
+ if not all(c.isalnum() for c in address):
90
+ raise ValueError(f"Invalid characters in address: {address}")
91
+
92
+ return address
93
+
94
+
95
+ def get_balance(address: str, network: str = "mainnet") -> AddressBalance:
96
+ """
97
+ Fetch and compute the balance for a Bitcoin address.
98
+
99
+ Args:
100
+ address: The Bitcoin address (any type: P2PKH, P2SH, Bech32, Taproot).
101
+ network: 'mainnet' or 'testnet'.
102
+
103
+ Returns:
104
+ An AddressBalance with confirmed and unconfirmed amounts.
105
+
106
+ Raises:
107
+ ValueError: If the address format is obviously invalid.
108
+ AddressNotFoundError: If the address is not found upstream.
109
+ MempoolAPIError: On other API errors.
110
+ """
111
+ address = _validate_address(address)
112
+
113
+ try:
114
+ data = get_json(f"/address/{address}", network)
115
+ except NotFoundError as e:
116
+ raise AddressNotFoundError(f"Address not found: {address}") from e
117
+
118
+ chain = data.get("chain_stats", {})
119
+ mempool = data.get("mempool_stats", {})
120
+
121
+ confirmed = chain.get("funded_txo_sum", 0) - chain.get("spent_txo_sum", 0)
122
+ unconfirmed = mempool.get("funded_txo_sum", 0) - mempool.get("spent_txo_sum", 0)
123
+
124
+ return AddressBalance(
125
+ address=data.get("address", address),
126
+ confirmed_sats=confirmed,
127
+ unconfirmed_sats=unconfirmed,
128
+ confirmed_tx_count=chain.get("tx_count", 0),
129
+ mempool_tx_count=mempool.get("tx_count", 0),
130
+ funded_sats=chain.get("funded_txo_sum", 0),
131
+ spent_sats=chain.get("spent_txo_sum", 0),
132
+ )
btc_toolkit/block.py ADDED
@@ -0,0 +1,139 @@
1
+ """
2
+ Block info explorer.
3
+
4
+ Queries the Mempool.space API for block metadata by height, hash,
5
+ or the chain tip.
6
+
7
+ Endpoints:
8
+ GET /api/block/:hash -> block details (JSON)
9
+ GET /api/block-height/:height -> block hash (plain text)
10
+ GET /api/blocks/tip/height -> current tip height (plain text)
11
+ """
12
+
13
+ from dataclasses import dataclass
14
+ from datetime import datetime, timezone
15
+
16
+ from .api import get_json, get_text, NotFoundError
17
+
18
+
19
+ class BlockNotFoundError(NotFoundError):
20
+ """Raised when a block height or hash is not found."""
21
+
22
+
23
+ @dataclass
24
+ class BlockInfo:
25
+ """Metadata for a single Bitcoin block."""
26
+
27
+ hash: str
28
+ height: int
29
+ timestamp: int
30
+ tx_count: int
31
+ size: int
32
+ weight: int
33
+ version: int
34
+ merkle_root: str
35
+ previousblockhash: str
36
+ nonce: int
37
+ bits: int
38
+ difficulty: float
39
+ mediantime: int
40
+
41
+ @property
42
+ def timestamp_utc(self) -> str:
43
+ """Block timestamp as an ISO-8601 UTC string."""
44
+ return datetime.fromtimestamp(self.timestamp, tz=timezone.utc).strftime(
45
+ "%Y-%m-%d %H:%M:%S UTC"
46
+ )
47
+
48
+ @property
49
+ def size_mb(self) -> float:
50
+ """Block size in MB (1 MB = 1_000_000 bytes)."""
51
+ return self.size / 1_000_000
52
+
53
+ def to_dict(self) -> dict:
54
+ return {
55
+ "hash": self.hash,
56
+ "height": self.height,
57
+ "timestamp": self.timestamp,
58
+ "timestamp_utc": self.timestamp_utc,
59
+ "tx_count": self.tx_count,
60
+ "size_bytes": self.size,
61
+ "size_mb": round(self.size_mb, 2),
62
+ "weight": self.weight,
63
+ "version": self.version,
64
+ "merkle_root": self.merkle_root,
65
+ "previousblockhash": self.previousblockhash,
66
+ "nonce": self.nonce,
67
+ "bits": self.bits,
68
+ "difficulty": self.difficulty,
69
+ "mediantime": self.mediantime,
70
+ }
71
+
72
+
73
+ def _is_block_hash(ref: str) -> bool:
74
+ """A block hash is 64 hex chars; a height is a decimal number."""
75
+ ref = ref.strip().lower()
76
+ return len(ref) == 64 and all(c in "0123456789abcdef" for c in ref)
77
+
78
+
79
+ def _is_height(ref: str) -> bool:
80
+ return ref.strip().isdigit()
81
+
82
+
83
+ def get_tip_height(network: str = "mainnet") -> int:
84
+ """Return the current chain tip height."""
85
+ return int(get_text("/blocks/tip/height", network))
86
+
87
+
88
+ def get_block(ref: str, network: str = "mainnet") -> BlockInfo:
89
+ """
90
+ Fetch block metadata by height, hash, or 'latest'.
91
+
92
+ Args:
93
+ ref: Block height (decimal), block hash (64 hex chars),
94
+ or the literal string 'latest' for the chain tip.
95
+ network: 'mainnet' or 'testnet'.
96
+
97
+ Returns:
98
+ A BlockInfo with the block's metadata.
99
+
100
+ Raises:
101
+ ValueError: If ref is neither a height, a hash, nor 'latest'.
102
+ BlockNotFoundError: If the block does not exist.
103
+ MempoolAPIError: On other API errors.
104
+ """
105
+ ref = ref.strip()
106
+
107
+ try:
108
+ if ref.lower() == "latest":
109
+ height = get_tip_height(network)
110
+ block_hash = get_text(f"/block-height/{height}", network)
111
+ elif _is_height(ref):
112
+ block_hash = get_text(f"/block-height/{ref}", network)
113
+ elif _is_block_hash(ref):
114
+ block_hash = ref.lower()
115
+ else:
116
+ raise ValueError(
117
+ f"Invalid block reference: {ref!r}. "
118
+ "Use a height, a 64-char hash, or 'latest'."
119
+ )
120
+
121
+ data = get_json(f"/block/{block_hash}", network)
122
+ except NotFoundError as e:
123
+ raise BlockNotFoundError(f"Block not found: {ref}") from e
124
+
125
+ return BlockInfo(
126
+ hash=data.get("id", block_hash),
127
+ height=data.get("height", 0),
128
+ timestamp=data.get("timestamp", 0),
129
+ tx_count=data.get("tx_count", 0),
130
+ size=data.get("size", 0),
131
+ weight=data.get("weight", 0),
132
+ version=data.get("version", 0),
133
+ merkle_root=data.get("merkle_root", ""),
134
+ previousblockhash=data.get("previousblockhash", ""),
135
+ nonce=data.get("nonce", 0),
136
+ bits=data.get("bits", 0),
137
+ difficulty=data.get("difficulty", 0),
138
+ mediantime=data.get("mediantime", 0),
139
+ )