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 +74 -0
- bitwalkit/_electrum.py +136 -0
- bitwalkit/_secp.py +126 -0
- bitwalkit/address.py +190 -0
- bitwalkit/bip32.py +204 -0
- bitwalkit/chain.py +150 -0
- bitwalkit/descriptor.py +41 -0
- bitwalkit/encoding.py +176 -0
- bitwalkit/errors.py +39 -0
- bitwalkit/hashing.py +157 -0
- bitwalkit/hd.py +91 -0
- bitwalkit/rpc.py +107 -0
- bitwalkit-0.1.0.dist-info/METADATA +116 -0
- bitwalkit-0.1.0.dist-info/RECORD +17 -0
- bitwalkit-0.1.0.dist-info/WHEEL +5 -0
- bitwalkit-0.1.0.dist-info/licenses/COPYING +21 -0
- bitwalkit-0.1.0.dist-info/top_level.txt +1 -0
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))
|