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.
- btc_toolkit/__init__.py +3 -0
- btc_toolkit/__main__.py +6 -0
- btc_toolkit/api.py +94 -0
- btc_toolkit/balance.py +132 -0
- btc_toolkit/block.py +139 -0
- btc_toolkit/cli.py +469 -0
- btc_toolkit/colors.py +40 -0
- btc_toolkit/fees.py +90 -0
- btc_toolkit/opreturn.py +187 -0
- btc_toolkit/utxo.py +126 -0
- btc_toolkit-1.0.0.dist-info/METADATA +256 -0
- btc_toolkit-1.0.0.dist-info/RECORD +16 -0
- btc_toolkit-1.0.0.dist-info/WHEEL +5 -0
- btc_toolkit-1.0.0.dist-info/entry_points.txt +2 -0
- btc_toolkit-1.0.0.dist-info/licenses/LICENSE +21 -0
- btc_toolkit-1.0.0.dist-info/top_level.txt +1 -0
btc_toolkit/__init__.py
ADDED
btc_toolkit/__main__.py
ADDED
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
|
+
)
|