bitwalkit 0.1.0__tar.gz
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-0.1.0/COPYING +21 -0
- bitwalkit-0.1.0/MANIFEST.in +3 -0
- bitwalkit-0.1.0/PKG-INFO +116 -0
- bitwalkit-0.1.0/README.md +99 -0
- bitwalkit-0.1.0/pyproject.toml +30 -0
- bitwalkit-0.1.0/setup.cfg +4 -0
- bitwalkit-0.1.0/src/bitwalkit/__init__.py +74 -0
- bitwalkit-0.1.0/src/bitwalkit/_electrum.py +136 -0
- bitwalkit-0.1.0/src/bitwalkit/_secp.py +126 -0
- bitwalkit-0.1.0/src/bitwalkit/address.py +190 -0
- bitwalkit-0.1.0/src/bitwalkit/bip32.py +204 -0
- bitwalkit-0.1.0/src/bitwalkit/chain.py +150 -0
- bitwalkit-0.1.0/src/bitwalkit/descriptor.py +41 -0
- bitwalkit-0.1.0/src/bitwalkit/encoding.py +176 -0
- bitwalkit-0.1.0/src/bitwalkit/errors.py +39 -0
- bitwalkit-0.1.0/src/bitwalkit/hashing.py +157 -0
- bitwalkit-0.1.0/src/bitwalkit/hd.py +91 -0
- bitwalkit-0.1.0/src/bitwalkit/rpc.py +107 -0
- bitwalkit-0.1.0/src/bitwalkit.egg-info/PKG-INFO +116 -0
- bitwalkit-0.1.0/src/bitwalkit.egg-info/SOURCES.txt +33 -0
- bitwalkit-0.1.0/src/bitwalkit.egg-info/dependency_links.txt +1 -0
- bitwalkit-0.1.0/src/bitwalkit.egg-info/requires.txt +5 -0
- bitwalkit-0.1.0/src/bitwalkit.egg-info/top_level.txt +1 -0
- bitwalkit-0.1.0/tests/test_chain.py +92 -0
- bitwalkit-0.1.0/tests/test_derivation.py +115 -0
- bitwalkit-0.1.0/tests/test_descriptor.py +28 -0
- bitwalkit-0.1.0/tests/test_electrum.py +60 -0
- bitwalkit-0.1.0/tests/test_encoding.py +71 -0
- bitwalkit-0.1.0/tests/test_rpc.py +89 -0
- bitwalkit-0.1.0/tests/test_rpc_fixtures.py +98 -0
- bitwalkit-0.1.0/tests/test_rpc_http.py +80 -0
- bitwalkit-0.1.0/tests/test_secp.py +68 -0
- bitwalkit-0.1.0/tests/test_upstream_vectors.py +59 -0
- bitwalkit-0.1.0/tests/vectors/bip341_wallet_vectors.json +452 -0
- bitwalkit-0.1.0/tests/vectors/generated.json +399 -0
bitwalkit-0.1.0/COPYING
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Elmeri Niemelä
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
bitwalkit-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bitwalkit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Standalone watch-only Bitcoin wallet derivation and monitoring toolkit
|
|
5
|
+
Author: Elmeri Niemelä
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/elmeriniemela/bitwalkit
|
|
8
|
+
Keywords: bitcoin,rpc,bip32,electrum,wallet,watch-only
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: COPYING
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: build>=1.3; extra == "dev"
|
|
14
|
+
Requires-Dist: pyright>=1.1.400; extra == "dev"
|
|
15
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# bitwalkit — Watch-only Bitcoin wallet toolkit
|
|
19
|
+
|
|
20
|
+
`bitwalkit` is a standalone, dependency-free Python toolkit for deriving and
|
|
21
|
+
monitoring Bitcoin watch-only wallets. It handles public wallet data only: the
|
|
22
|
+
library does not accept private extended keys, hold secrets, or sign
|
|
23
|
+
transactions.
|
|
24
|
+
|
|
25
|
+
Its public API covers three tasks:
|
|
26
|
+
|
|
27
|
+
1. Derive single-signature and multisignature addresses from extended public
|
|
28
|
+
keys with `Account` and `MultisigAccount`.
|
|
29
|
+
2. Query address balances, UTXOs, and transaction history through an Electrum
|
|
30
|
+
server with `ChainQuery`.
|
|
31
|
+
3. Call a Bitcoin Core node over JSON-RPC with `NodeRPC`.
|
|
32
|
+
|
|
33
|
+
The runtime uses only the Python standard library. A small, public-key-only
|
|
34
|
+
secp256k1 implementation validates compressed public keys, derives
|
|
35
|
+
non-hardened BIP32 child public keys, and applies the BIP341/BIP86 Taproot
|
|
36
|
+
output-key tweak. It is not suitable for private-key handling or transaction
|
|
37
|
+
signing. There are no packages to install at runtime.
|
|
38
|
+
|
|
39
|
+
## Supported wallets
|
|
40
|
+
|
|
41
|
+
- Single-key P2PKH (BIP44), P2SH-P2WPKH (BIP49), P2WPKH (BIP84), and P2TR
|
|
42
|
+
(BIP86)
|
|
43
|
+
- P2WSH and P2SH-P2WSH multisig (BIP48), with optional BIP67 key sorting
|
|
44
|
+
- Mainnet, testnet, and regtest addresses
|
|
45
|
+
- BIP32 and SLIP-0132 public prefixes: xpub/tpub, ypub/upub, zpub/vpub, and the
|
|
46
|
+
corresponding multisig prefixes
|
|
47
|
+
|
|
48
|
+
Only non-hardened children can be derived from an extended public key. Export
|
|
49
|
+
an account-level public key from the signing wallet before using this library.
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
Install the latest release from PyPI:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
python -m pip install bitwalkit
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
For development, clone and install from the repository root:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
git clone https://github.com/elmeriniemela/bitwalkit.git
|
|
63
|
+
cd bitwalkit
|
|
64
|
+
python -m pip install -e '.[dev]'
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Usage
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
import bitwalkit as bw
|
|
71
|
+
|
|
72
|
+
# The SLIP-0132 prefix selects the default script type.
|
|
73
|
+
account = bw.Account("zpub6r...")
|
|
74
|
+
first_receive = account.receive_address(0)
|
|
75
|
+
first_change = account.change_address(0)
|
|
76
|
+
gap_limit = account.addresses(change=0, count=20)
|
|
77
|
+
|
|
78
|
+
# Pass script_type explicitly when xpub alone is ambiguous, as with BIP86.
|
|
79
|
+
taproot = bw.Account("xpub6B...", script_type="p2tr")
|
|
80
|
+
|
|
81
|
+
# Multisig derives the same change/index path for every cosigner.
|
|
82
|
+
multisig = bw.MultisigAccount(
|
|
83
|
+
["Zpub...", "Zpub...", "Zpub..."],
|
|
84
|
+
m=2,
|
|
85
|
+
script_type="p2wsh",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Query public chain state by address.
|
|
89
|
+
chain = bw.ChainQuery("electrum.example.com", port=50002, use_ssl=True)
|
|
90
|
+
balance = chain.get_balance(first_receive)
|
|
91
|
+
utxos = chain.get_utxos(first_receive)
|
|
92
|
+
history = chain.get_history(first_receive)
|
|
93
|
+
balances = chain.get_balances(gap_limit)
|
|
94
|
+
|
|
95
|
+
# Optional direct Bitcoin Core RPC access.
|
|
96
|
+
node = bw.NodeRPC("http://127.0.0.1:8332", "rpcuser", "rpcpassword")
|
|
97
|
+
height = node.getblockcount()
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Network access is performed only when calling `ChainQuery` or `NodeRPC`.
|
|
101
|
+
Address derivation and encoding are fully local.
|
|
102
|
+
|
|
103
|
+
## Tests
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
python -m pytest
|
|
107
|
+
python -m pyright
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The suite covers malformed input handling, BIP32 public derivation, address
|
|
111
|
+
encoding, BIP44/49/84/86 derivation, BIP341 taproot vectors, multisig,
|
|
112
|
+
Electrum protocol behavior, and Bitcoin Core JSON-RPC response handling.
|
|
113
|
+
|
|
114
|
+
## License
|
|
115
|
+
|
|
116
|
+
MIT. See [COPYING](COPYING).
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# bitwalkit — Watch-only Bitcoin wallet toolkit
|
|
2
|
+
|
|
3
|
+
`bitwalkit` is a standalone, dependency-free Python toolkit for deriving and
|
|
4
|
+
monitoring Bitcoin watch-only wallets. It handles public wallet data only: the
|
|
5
|
+
library does not accept private extended keys, hold secrets, or sign
|
|
6
|
+
transactions.
|
|
7
|
+
|
|
8
|
+
Its public API covers three tasks:
|
|
9
|
+
|
|
10
|
+
1. Derive single-signature and multisignature addresses from extended public
|
|
11
|
+
keys with `Account` and `MultisigAccount`.
|
|
12
|
+
2. Query address balances, UTXOs, and transaction history through an Electrum
|
|
13
|
+
server with `ChainQuery`.
|
|
14
|
+
3. Call a Bitcoin Core node over JSON-RPC with `NodeRPC`.
|
|
15
|
+
|
|
16
|
+
The runtime uses only the Python standard library. A small, public-key-only
|
|
17
|
+
secp256k1 implementation validates compressed public keys, derives
|
|
18
|
+
non-hardened BIP32 child public keys, and applies the BIP341/BIP86 Taproot
|
|
19
|
+
output-key tweak. It is not suitable for private-key handling or transaction
|
|
20
|
+
signing. There are no packages to install at runtime.
|
|
21
|
+
|
|
22
|
+
## Supported wallets
|
|
23
|
+
|
|
24
|
+
- Single-key P2PKH (BIP44), P2SH-P2WPKH (BIP49), P2WPKH (BIP84), and P2TR
|
|
25
|
+
(BIP86)
|
|
26
|
+
- P2WSH and P2SH-P2WSH multisig (BIP48), with optional BIP67 key sorting
|
|
27
|
+
- Mainnet, testnet, and regtest addresses
|
|
28
|
+
- BIP32 and SLIP-0132 public prefixes: xpub/tpub, ypub/upub, zpub/vpub, and the
|
|
29
|
+
corresponding multisig prefixes
|
|
30
|
+
|
|
31
|
+
Only non-hardened children can be derived from an extended public key. Export
|
|
32
|
+
an account-level public key from the signing wallet before using this library.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
Install the latest release from PyPI:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
python -m pip install bitwalkit
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
For development, clone and install from the repository root:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
git clone https://github.com/elmeriniemela/bitwalkit.git
|
|
46
|
+
cd bitwalkit
|
|
47
|
+
python -m pip install -e '.[dev]'
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Usage
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
import bitwalkit as bw
|
|
54
|
+
|
|
55
|
+
# The SLIP-0132 prefix selects the default script type.
|
|
56
|
+
account = bw.Account("zpub6r...")
|
|
57
|
+
first_receive = account.receive_address(0)
|
|
58
|
+
first_change = account.change_address(0)
|
|
59
|
+
gap_limit = account.addresses(change=0, count=20)
|
|
60
|
+
|
|
61
|
+
# Pass script_type explicitly when xpub alone is ambiguous, as with BIP86.
|
|
62
|
+
taproot = bw.Account("xpub6B...", script_type="p2tr")
|
|
63
|
+
|
|
64
|
+
# Multisig derives the same change/index path for every cosigner.
|
|
65
|
+
multisig = bw.MultisigAccount(
|
|
66
|
+
["Zpub...", "Zpub...", "Zpub..."],
|
|
67
|
+
m=2,
|
|
68
|
+
script_type="p2wsh",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Query public chain state by address.
|
|
72
|
+
chain = bw.ChainQuery("electrum.example.com", port=50002, use_ssl=True)
|
|
73
|
+
balance = chain.get_balance(first_receive)
|
|
74
|
+
utxos = chain.get_utxos(first_receive)
|
|
75
|
+
history = chain.get_history(first_receive)
|
|
76
|
+
balances = chain.get_balances(gap_limit)
|
|
77
|
+
|
|
78
|
+
# Optional direct Bitcoin Core RPC access.
|
|
79
|
+
node = bw.NodeRPC("http://127.0.0.1:8332", "rpcuser", "rpcpassword")
|
|
80
|
+
height = node.getblockcount()
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Network access is performed only when calling `ChainQuery` or `NodeRPC`.
|
|
84
|
+
Address derivation and encoding are fully local.
|
|
85
|
+
|
|
86
|
+
## Tests
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
python -m pytest
|
|
90
|
+
python -m pyright
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The suite covers malformed input handling, BIP32 public derivation, address
|
|
94
|
+
encoding, BIP44/49/84/86 derivation, BIP341 taproot vectors, multisig,
|
|
95
|
+
Electrum protocol behavior, and Bitcoin Core JSON-RPC response handling.
|
|
96
|
+
|
|
97
|
+
## License
|
|
98
|
+
|
|
99
|
+
MIT. See [COPYING](COPYING).
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "bitwalkit"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Standalone watch-only Bitcoin wallet derivation and monitoring toolkit"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "Elmeri Niemelä" }]
|
|
13
|
+
keywords = ["bitcoin", "rpc", "bip32", "electrum", "wallet", "watch-only"]
|
|
14
|
+
dependencies = []
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
dev = [
|
|
18
|
+
"build>=1.3",
|
|
19
|
+
"pyright>=1.1.400",
|
|
20
|
+
"pytest>=8",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/elmeriniemela/bitwalkit"
|
|
25
|
+
|
|
26
|
+
[tool.setuptools]
|
|
27
|
+
package-dir = { "" = "src" }
|
|
28
|
+
|
|
29
|
+
[tool.setuptools.packages.find]
|
|
30
|
+
where = ["src"]
|
|
@@ -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
|
+
]
|
|
@@ -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))]
|
|
@@ -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)
|