bitwalkit 0.1.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.
bitwalkit/__init__.py ADDED
@@ -0,0 +1,74 @@
1
+ """bitwalkit -- Bitcoin Wallet Toolkit.
2
+
3
+ Three dependency-free capabilities:
4
+
5
+ * :class:`NodeRPC` -- call a Bitcoin Core node over JSON-RPC.
6
+ * :class:`Account` / :class:`MultisigAccount` -- watch-only HD address
7
+ derivation from master/account extended public keys (xpub/ypub/zpub/...).
8
+ * :class:`ChainQuery` -- fetch an address's balance / UTXOs / history (backed
9
+ by an Electrum server, but the caller only ever deals in addresses).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from .address import (
15
+ address_from_pubkey,
16
+ address_from_script,
17
+ address_to_script,
18
+ address_to_scripthash,
19
+ p2ms_script,
20
+ script_to_scripthash,
21
+ )
22
+ from .bip32 import ExtendedKey
23
+ from .chain import Balance, ChainQuery, HistoryEntry, Utxo
24
+ from .descriptor import descriptor_checksum
25
+ from .encoding import (
26
+ base58check_decode,
27
+ base58check_encode,
28
+ bech32_decode,
29
+ bech32_encode,
30
+ )
31
+ from .errors import (
32
+ BitwalkitError,
33
+ ConnectionError,
34
+ DerivationError,
35
+ EncodingError,
36
+ RpcError,
37
+ )
38
+ from .hd import Account, MultisigAccount
39
+ from .rpc import NodeRPC
40
+
41
+ __version__ = "0.1.0"
42
+
43
+ __all__ = [
44
+ "__version__",
45
+ # rpc
46
+ "NodeRPC",
47
+ # derivation
48
+ "ExtendedKey",
49
+ "Account",
50
+ "MultisigAccount",
51
+ "descriptor_checksum",
52
+ # balances
53
+ "ChainQuery",
54
+ "Balance",
55
+ "Utxo",
56
+ "HistoryEntry",
57
+ # address / encoding helpers
58
+ "address_from_pubkey",
59
+ "address_from_script",
60
+ "address_to_script",
61
+ "address_to_scripthash",
62
+ "script_to_scripthash",
63
+ "p2ms_script",
64
+ "base58check_encode",
65
+ "base58check_decode",
66
+ "bech32_encode",
67
+ "bech32_decode",
68
+ # errors
69
+ "BitwalkitError",
70
+ "RpcError",
71
+ "ConnectionError",
72
+ "DerivationError",
73
+ "EncodingError",
74
+ ]
bitwalkit/_electrum.py ADDED
@@ -0,0 +1,136 @@
1
+ """Internal ElectrumX client: newline-delimited JSON-RPC 2.0 over TCP/SSL.
2
+
3
+ This is a private module -- callers use :class:`bitwalkit.chain.ChainQuery`,
4
+ which speaks in addresses and hides scripthashes and Electrum method names.
5
+ The client supports TCP or TLS connections, configurable timeouts, and batch
6
+ requests.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import socket
13
+ import ssl
14
+
15
+ from .errors import ConnectionError, RpcError
16
+
17
+ __all__ = ["ElectrumClient"]
18
+
19
+
20
+ class ElectrumClient:
21
+ """A short-lived connection to an ElectrumX server."""
22
+
23
+ def __init__(self, host: str, port: int, use_ssl: bool = False, timeout: float = 10) -> None:
24
+ self.host = host
25
+ self.port = port
26
+ self.use_ssl = use_ssl
27
+ self.timeout = timeout
28
+ self._sock: socket.socket | None = None
29
+ self._buf = b""
30
+
31
+ # -- connection -------------------------------------------------------- #
32
+
33
+ def connect(self) -> "ElectrumClient":
34
+ if self._sock is not None:
35
+ return self
36
+ try:
37
+ sock = socket.create_connection((self.host, self.port), timeout=self.timeout)
38
+ if self.use_ssl:
39
+ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
40
+ ctx.check_hostname = False
41
+ ctx.verify_mode = ssl.CERT_NONE
42
+ sock = ctx.wrap_socket(sock, server_hostname=self.host)
43
+ sock.settimeout(self.timeout)
44
+ except (OSError, ssl.SSLError) as exc:
45
+ raise ConnectionError(
46
+ f"unable to connect to Electrum server {self.host}:{self.port}: {exc}"
47
+ ) from exc
48
+ self._sock = sock
49
+ return self
50
+
51
+ def close(self) -> None:
52
+ if self._sock is not None:
53
+ try:
54
+ self._sock.close()
55
+ finally:
56
+ self._sock = None
57
+ self._buf = b""
58
+
59
+ def __enter__(self) -> "ElectrumClient":
60
+ return self.connect()
61
+
62
+ def __exit__(self, *exc) -> None:
63
+ self.close()
64
+
65
+ # -- transport --------------------------------------------------------- #
66
+
67
+ def _send(self, obj) -> None:
68
+ if self._sock is None:
69
+ self.connect()
70
+ assert self._sock is not None
71
+ try:
72
+ self._sock.sendall(json.dumps(obj).encode() + b"\n")
73
+ except OSError as exc:
74
+ raise ConnectionError(f"Electrum send failed: {exc}") from exc
75
+
76
+ def _recv_line(self) -> bytes:
77
+ assert self._sock is not None
78
+ while b"\n" not in self._buf:
79
+ try:
80
+ chunk = self._sock.recv(4096)
81
+ except socket.timeout as exc:
82
+ raise ConnectionError("Electrum server timed out") from exc
83
+ except OSError as exc:
84
+ raise ConnectionError(f"Electrum recv failed: {exc}") from exc
85
+ if not chunk:
86
+ raise ConnectionError("Electrum server closed the connection")
87
+ self._buf += chunk
88
+ line, self._buf = self._buf.split(b"\n", 1)
89
+ return line
90
+
91
+ @staticmethod
92
+ def _result(item: dict):
93
+ if not isinstance(item, dict):
94
+ raise RpcError(f"invalid Electrum response: {item!r}")
95
+ error = item.get("error")
96
+ if error:
97
+ if isinstance(error, dict):
98
+ raise RpcError(error.get("message", "Electrum error"), error.get("code"))
99
+ raise RpcError(str(error))
100
+ if "result" not in item:
101
+ raise RpcError(f"Electrum response has no result: {item!r}")
102
+ return item["result"]
103
+
104
+ def _receive(self):
105
+ try:
106
+ return json.loads(self._recv_line())
107
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
108
+ raise RpcError("invalid JSON from Electrum server") from exc
109
+
110
+ # -- public calls ------------------------------------------------------ #
111
+
112
+ def call(self, method: str, params: list):
113
+ self._send({"jsonrpc": "2.0", "id": 0, "method": method, "params": params})
114
+ data = self._receive()
115
+ if not isinstance(data, dict) or data.get("id") != 0:
116
+ raise RpcError(f"mismatched Electrum response: {data!r}")
117
+ return self._result(data)
118
+
119
+ def batch(self, method: str, params_list: list[list]) -> list:
120
+ """Call ``method`` once per entry in ``params_list``; results stay in order."""
121
+ if not params_list:
122
+ return []
123
+ reqs = [
124
+ {"jsonrpc": "2.0", "id": i, "method": method, "params": params}
125
+ for i, params in enumerate(params_list)
126
+ ]
127
+ self._send(reqs)
128
+ data = self._receive()
129
+ if not isinstance(data, list) or len(data) != len(params_list):
130
+ raise RpcError("malformed Electrum batch response")
131
+ if any(not isinstance(item, dict) or "id" not in item for item in data):
132
+ raise RpcError("malformed Electrum batch response")
133
+ by_id = {item["id"]: item for item in data}
134
+ if set(by_id) != set(range(len(params_list))):
135
+ raise RpcError("mismatched Electrum batch response ids")
136
+ return [self._result(by_id[i]) for i in range(len(params_list))]
bitwalkit/_secp.py ADDED
@@ -0,0 +1,126 @@
1
+ """Minimal secp256k1 public-key arithmetic used by bitwalkit.
2
+
3
+ The implementation is intentionally limited to parsing, serializing, adding,
4
+ and multiplying public curve points. It is variable-time and must not be used
5
+ with private or otherwise secret scalars.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import ClassVar
12
+
13
+ __all__ = ["G", "GE"]
14
+
15
+ _FIELD = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F
16
+ _ORDER = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
17
+ _GX = 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798
18
+ _GY = 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class GE:
23
+ """A secp256k1 curve point, or infinity when coordinates are absent."""
24
+
25
+ x: int | None = None
26
+ y: int | None = None
27
+
28
+ ORDER: ClassVar[int] = _ORDER
29
+
30
+ def __post_init__(self) -> None:
31
+ if self.x is None or self.y is None:
32
+ if self.x is not None or self.y is not None:
33
+ raise ValueError("both point coordinates must be present")
34
+ return
35
+ if not (0 <= self.x < _FIELD and 0 <= self.y < _FIELD):
36
+ raise ValueError("point coordinate out of range")
37
+ if (self.y * self.y - self.x * self.x * self.x - 7) % _FIELD:
38
+ raise ValueError("point is not on secp256k1")
39
+
40
+ @property
41
+ def infinity(self) -> bool:
42
+ """Whether this is the point at infinity."""
43
+ return self.x is None
44
+
45
+ def __add__(self, other: object) -> GE:
46
+ """Add two curve points."""
47
+ if not isinstance(other, GE):
48
+ return NotImplemented
49
+ if self.infinity:
50
+ return other
51
+ if other.infinity:
52
+ return self
53
+
54
+ x1, y1, x2, y2 = self.x, self.y, other.x, other.y
55
+ assert x1 is not None and y1 is not None
56
+ assert x2 is not None and y2 is not None
57
+
58
+ if x1 == x2:
59
+ if y1 != y2 or y1 == 0:
60
+ return GE()
61
+ numerator = 3 * x1 * x1
62
+ denominator = 2 * y1
63
+ else:
64
+ numerator = y2 - y1
65
+ denominator = x2 - x1
66
+
67
+ slope = numerator * pow(denominator % _FIELD, -1, _FIELD) % _FIELD
68
+ x3 = (slope * slope - x1 - x2) % _FIELD
69
+ y3 = (slope * (x1 - x3) - y1) % _FIELD
70
+ return GE(x3, y3)
71
+
72
+ def __rmul__(self, scalar: object) -> GE:
73
+ """Multiply this point by an integer using double-and-add."""
74
+ if not isinstance(scalar, int):
75
+ return NotImplemented
76
+
77
+ scalar %= self.ORDER
78
+ result = GE()
79
+ addend = self
80
+ while scalar:
81
+ if scalar & 1:
82
+ result = result + addend
83
+ addend = addend + addend
84
+ scalar >>= 1
85
+ return result
86
+
87
+ @classmethod
88
+ def lift_x(cls, x: int) -> GE:
89
+ """Lift an x-coordinate to the curve point whose y-coordinate is even."""
90
+ if not isinstance(x, int) or not 0 <= x < _FIELD:
91
+ raise ValueError("x-coordinate out of range")
92
+ y_squared = (pow(x, 3, _FIELD) + 7) % _FIELD
93
+ y = pow(y_squared, (_FIELD + 1) // 4, _FIELD)
94
+ if y * y % _FIELD != y_squared:
95
+ raise ValueError("x-coordinate is not on secp256k1")
96
+ if y & 1:
97
+ y = _FIELD - y
98
+ return cls(x, y)
99
+
100
+ @classmethod
101
+ def from_bytes_compressed(cls, encoded: bytes) -> GE:
102
+ """Parse a 33-byte compressed public key."""
103
+ if len(encoded) != 33 or encoded[0] not in (2, 3):
104
+ raise ValueError("invalid compressed public key")
105
+ point = cls.lift_x(int.from_bytes(encoded[1:], "big"))
106
+ assert point.x is not None and point.y is not None
107
+ if (point.y & 1) != (encoded[0] & 1):
108
+ return cls(point.x, _FIELD - point.y)
109
+ return point
110
+
111
+ def to_bytes_compressed(self) -> bytes:
112
+ """Serialize a finite point as a 33-byte compressed public key."""
113
+ if self.infinity:
114
+ raise ValueError("cannot serialize the point at infinity")
115
+ assert self.x is not None and self.y is not None
116
+ return bytes([2 | (self.y & 1)]) + self.x.to_bytes(32, "big")
117
+
118
+ def to_bytes_xonly(self) -> bytes:
119
+ """Serialize the x-coordinate of a finite point."""
120
+ if self.infinity:
121
+ raise ValueError("cannot serialize the point at infinity")
122
+ assert self.x is not None
123
+ return self.x.to_bytes(32, "big")
124
+
125
+
126
+ G = GE(_GX, _GY)
bitwalkit/address.py ADDED
@@ -0,0 +1,190 @@
1
+ """Bitcoin scripts, addresses, and Electrum protocol scripthashes.
2
+
3
+ Build a scriptPubKey and address from a public key or multisig script for
4
+ P2PKH, P2SH-P2WPKH, P2WPKH, P2WSH, P2SH-P2WSH, and P2TR. Addresses can also
5
+ be decoded back to scriptPubKeys for watch-only chain queries.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from ._secp import G, GE
11
+ from .encoding import base58check_decode, base58check_encode, bech32_decode, bech32_encode
12
+ from .errors import EncodingError
13
+ from .hashing import hash160, sha256, tagged_hash
14
+
15
+ __all__ = [
16
+ "NETWORKS",
17
+ "address_from_pubkey",
18
+ "address_from_script",
19
+ "address_to_script",
20
+ "address_to_scripthash",
21
+ "script_to_scripthash",
22
+ "p2pkh_script",
23
+ "p2sh_script",
24
+ "p2wpkh_script",
25
+ "p2wsh_script",
26
+ "p2tr_script",
27
+ "p2ms_script",
28
+ "p2pk_script",
29
+ ]
30
+
31
+ # Base58 version bytes and bech32 HRP per network.
32
+ NETWORKS: dict[str, dict] = {
33
+ "mainnet": {"p2pkh": 0x00, "p2sh": 0x05, "hrp": "bc"},
34
+ "testnet": {"p2pkh": 0x6F, "p2sh": 0xC4, "hrp": "tb"},
35
+ "regtest": {"p2pkh": 0x6F, "p2sh": 0xC4, "hrp": "bcrt"},
36
+ }
37
+ _HRPS = {params["hrp"]: net for net, params in NETWORKS.items()}
38
+
39
+ # Opcodes.
40
+ OP_DUP = 0x76
41
+ OP_HASH160 = 0xA9
42
+ OP_EQUAL = 0x87
43
+ OP_EQUALVERIFY = 0x88
44
+ OP_CHECKSIG = 0xAC
45
+ OP_CHECKMULTISIG = 0xAE
46
+ OP_0 = 0x00
47
+ OP_1 = 0x51 # OP_1..OP_16 are 0x51..0x60
48
+
49
+
50
+ def _pushdata(data: bytes) -> bytes:
51
+ """Minimal push of ``data`` (adequate for keys/hashes, all < 76 bytes)."""
52
+ if len(data) < 0x4C:
53
+ return bytes([len(data)]) + data
54
+ raise EncodingError("pushdata too large for this helper")
55
+
56
+
57
+ # --------------------------------------------------------------------------- #
58
+ # Raw scriptPubKey / script builders
59
+ # --------------------------------------------------------------------------- #
60
+
61
+ def p2pkh_script(pubkey: bytes) -> bytes:
62
+ h = hash160(pubkey)
63
+ return bytes([OP_DUP, OP_HASH160, len(h)]) + h + bytes([OP_EQUALVERIFY, OP_CHECKSIG])
64
+
65
+
66
+ def p2sh_script(script_hash: bytes) -> bytes:
67
+ return bytes([OP_HASH160, len(script_hash)]) + script_hash + bytes([OP_EQUAL])
68
+
69
+
70
+ def p2wpkh_script(pubkey: bytes) -> bytes:
71
+ h = hash160(pubkey)
72
+ return bytes([OP_0, len(h)]) + h
73
+
74
+
75
+ def p2wsh_script(witness_script: bytes) -> bytes:
76
+ h = sha256(witness_script)
77
+ return bytes([OP_0, len(h)]) + h
78
+
79
+
80
+ def p2pk_script(pubkey: bytes) -> bytes:
81
+ return _pushdata(pubkey) + bytes([OP_CHECKSIG])
82
+
83
+
84
+ def p2ms_script(m: int, pubkeys: list[bytes]) -> bytes:
85
+ """Bare multisig script (used as the witness/redeem script for P2WSH/P2SH)."""
86
+ n = len(pubkeys)
87
+ if not (1 <= m <= n <= 16):
88
+ raise EncodingError(f"invalid multisig m-of-n: {m}-of-{n}")
89
+ body = b"".join(_pushdata(pk) for pk in pubkeys)
90
+ return bytes([OP_1 - 1 + m]) + body + bytes([OP_1 - 1 + n, OP_CHECKMULTISIG])
91
+
92
+
93
+ def _taproot_output_key(internal_pubkey: bytes) -> bytes:
94
+ """BIP86 output key: tweak the internal key with no script tree."""
95
+ xonly = internal_pubkey[-32:] if len(internal_pubkey) in (32, 33) else internal_pubkey
96
+ if len(xonly) != 32:
97
+ raise EncodingError("invalid taproot internal key length")
98
+ t = int.from_bytes(tagged_hash("TapTweak", xonly), "big")
99
+ if t >= GE.ORDER:
100
+ raise EncodingError("invalid taproot tweak")
101
+ try:
102
+ internal_point = GE.lift_x(int.from_bytes(xonly, "big"))
103
+ except ValueError as exc:
104
+ raise EncodingError("invalid taproot internal key") from exc
105
+ q = internal_point + (t * G)
106
+ if q.infinity:
107
+ raise EncodingError("taproot output key is infinity")
108
+ return q.to_bytes_xonly()
109
+
110
+
111
+ def p2tr_script(internal_pubkey: bytes) -> bytes:
112
+ program = _taproot_output_key(internal_pubkey)
113
+ return bytes([OP_1, len(program)]) + program
114
+
115
+
116
+ # --------------------------------------------------------------------------- #
117
+ # Address encoding
118
+ # --------------------------------------------------------------------------- #
119
+
120
+ def _b58(version_byte: int, payload: bytes) -> str:
121
+ return base58check_encode(bytes([version_byte]) + payload)
122
+
123
+
124
+ def address_from_pubkey(pubkey: bytes, script_type: str, network: str = "mainnet") -> str:
125
+ """Address for a single public key under ``script_type``.
126
+
127
+ ``script_type`` is one of p2pkh, p2sh-p2wpkh, p2wpkh, or p2tr.
128
+ """
129
+ params = NETWORKS[network]
130
+ if script_type == "p2pkh":
131
+ return _b58(params["p2pkh"], hash160(pubkey))
132
+ if script_type == "p2wpkh":
133
+ return bech32_encode(params["hrp"], 0, hash160(pubkey))
134
+ if script_type == "p2sh-p2wpkh":
135
+ redeem = p2wpkh_script(pubkey) # 0x00 0x14 <hash160(pubkey)>
136
+ return _b58(params["p2sh"], hash160(redeem))
137
+ if script_type == "p2tr":
138
+ return bech32_encode(params["hrp"], 1, _taproot_output_key(pubkey))
139
+ raise EncodingError(f"unsupported single-key script type: {script_type}")
140
+
141
+
142
+ def address_from_script(witness_script: bytes, script_type: str, network: str = "mainnet") -> str:
143
+ """Address wrapping a redeem/witness script (multisig): p2wsh or p2sh-p2wsh."""
144
+ params = NETWORKS[network]
145
+ if script_type == "p2wsh":
146
+ return bech32_encode(params["hrp"], 0, sha256(witness_script))
147
+ if script_type == "p2sh-p2wsh":
148
+ redeem = p2wsh_script(witness_script) # 0x00 0x20 <sha256(script)>
149
+ return _b58(params["p2sh"], hash160(redeem))
150
+ if script_type == "p2sh":
151
+ return _b58(params["p2sh"], hash160(witness_script))
152
+ raise EncodingError(f"unsupported script-wrapping type: {script_type}")
153
+
154
+
155
+ # --------------------------------------------------------------------------- #
156
+ # Address decoding + scripthash
157
+ # --------------------------------------------------------------------------- #
158
+
159
+ def address_to_script(address: str, network: str | None = None) -> bytes:
160
+ """Decode an address into its scriptPubKey bytes."""
161
+ lowered = address.lower()
162
+ pos = lowered.rfind("1")
163
+ hrp = lowered[:pos] if pos > 0 else None
164
+ if hrp in _HRPS:
165
+ if network is not None and _HRPS[hrp] != network:
166
+ raise EncodingError(f"address is {_HRPS[hrp]}, expected {network}")
167
+ _hrp, witver, program = bech32_decode(address)
168
+ op = OP_0 if witver == 0 else (OP_1 - 1 + witver)
169
+ return bytes([op, len(program)]) + program
170
+
171
+ payload = base58check_decode(address)
172
+ version, h = payload[0], payload[1:]
173
+ for net, params in NETWORKS.items():
174
+ if network is not None and net != network:
175
+ continue
176
+ if version == params["p2pkh"]:
177
+ return bytes([OP_DUP, OP_HASH160, len(h)]) + h + bytes([OP_EQUALVERIFY, OP_CHECKSIG])
178
+ if version == params["p2sh"]:
179
+ return p2sh_script(h)
180
+ raise EncodingError(f"unrecognized address version byte {version:#04x}")
181
+
182
+
183
+ def script_to_scripthash(script: bytes) -> str:
184
+ """Electrum protocol scripthash: ``sha256(scriptPubKey)`` reversed, hex."""
185
+ return sha256(script)[::-1].hex()
186
+
187
+
188
+ def address_to_scripthash(address: str, network: str | None = None) -> str:
189
+ """Electrum scripthash for an address (SHA256 of scriptPubKey, reversed)."""
190
+ return script_to_scripthash(address_to_script(address, network))