Skip to content

Commit c85ff59

Browse files
committed
add pure python hdwallet implementation (basic features) and removed hdwallet lib dependency
1 parent 9969d6b commit c85ff59

9 files changed

Lines changed: 346 additions & 41 deletions

File tree

README.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ python-bitcoin-utils
33

44
This is a bitcoin library that provides tools/utilities to interact with the Bitcoin network. One of the primary goals of the library is to explain the low-level details of Bitcoin. The code is easy to read and properly documented explaining in detail all the thorny aspects of the implementation. It is a low-level library which assumes some high-level understanding of how Bitcoin works. In the future this might change.
55

6-
The library (v0.8.1) currently supports private/public keys, all type of addresses and creation of any transaction, incl. segwit and taproot, with all SIGHASH types. All script op codes are included. Block parsing is also handled so you can read raw blocks directly. PSBT (BIP-174) is supported. Extra functionality will be added continuously and the documentation will be improved as the work progresses.
6+
The library (v0.8.2) currently supports private/public keys, all type of addresses and creation of any transaction, incl. segwit and taproot, with all SIGHASH types. All script op codes are included. Block parsing is also handled so you can read raw blocks directly. PSBT (BIP-174) is supported. Extra functionality will be added continuously and the documentation will be improved as the work progresses.
77

88
The API documentation can be build with Sphinx but is also available as a PDF for convenience. One can currently use the library for experimenting and learning the inner workings of Bitcoin. It is not meant for production yet and parts of the API might be updated with new versions.
99

@@ -13,7 +13,7 @@ Complementary to this library is a CC BY-SA 4.0 licensed `Bitcoin programming bo
1313
Notes
1414
-----
1515
* For schnorr, bech32[m], ripemd160 the python Bitcoin Core reference implementations are used.
16-
* For Hierarchical Deterministic keys we wrap the python hdwallet library. For now we wrap only some very basic functionality to acquire a PrivateKey object that is used throughtout the library.
16+
* For Hierarchical Deterministic keys we include minimal native BIP-32/BIP-39 functionality to acquire a PrivateKey object that is used throughtout the library.
1717

1818

1919
Installation

bitcoinutils/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.8.1"
1+
__version__ = "0.8.2"

bitcoinutils/hdwallet.py

Lines changed: 202 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -9,30 +9,183 @@
99
# propagated, or distributed except according to the terms contained in the
1010
# LICENSE file.
1111

12+
"""Minimal BIP-32/BIP-39 HD wallet support.
13+
14+
This module intentionally implements only the functionality used by
15+
bitcoinutils: deriving private keys from a mnemonic or an extended private key
16+
and returning them as :class:`bitcoinutils.keys.PrivateKey` objects.
17+
"""
18+
19+
from __future__ import annotations
20+
21+
import hashlib
22+
import hmac
23+
import unicodedata
1224
from typing import Optional
1325

14-
from hdwallet import HDWallet as ext_HDWallet # type: ignore
15-
from hdwallet.cryptocurrencies import Bitcoin
16-
from hdwallet.derivations import CustomDerivation
17-
from hdwallet.hds import BIP32HD
18-
from hdwallet.mnemonics import BIP39Mnemonic
26+
from base58check import b58decode # type: ignore
1927

20-
from bitcoinutils.setup import is_mainnet
2128
from bitcoinutils.keys import PrivateKey
29+
from bitcoinutils.utils import h_to_b
30+
31+
32+
_HARDENED_OFFSET = 0x80000000
33+
_EXTENDED_KEY_PAYLOAD_LENGTH = 78
34+
_XPRV_VERSION = bytes.fromhex("0488ade4")
35+
_TPRV_VERSION = bytes.fromhex("04358394")
36+
37+
# Order of the secp256k1 generator point.
38+
_SECP256K1_ORDER = int(
39+
"fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141", 16
40+
)
41+
42+
43+
def _normalize_bip39_text(text: str) -> str:
44+
"""Normalize mnemonic/passphrase text as required by BIP-39."""
45+
46+
return unicodedata.normalize("NFKD", text)
47+
48+
49+
def _mnemonic_to_seed(mnemonic: str, passphrase: str = "") -> bytes:
50+
"""Create a BIP-39 seed from a mnemonic.
51+
52+
The current public wrapper has no passphrase parameter, so callers use the
53+
BIP-39 default: an empty passphrase.
54+
"""
55+
56+
password = _normalize_bip39_text(mnemonic).encode("utf-8")
57+
salt = ("mnemonic" + _normalize_bip39_text(passphrase)).encode("utf-8")
58+
return hashlib.pbkdf2_hmac("sha512", password, salt, 2048)
59+
60+
61+
def _decode_base58check(data: str) -> bytes:
62+
"""Decode Base58Check and verify the checksum."""
63+
64+
decoded = b58decode(data.encode("utf-8"))
65+
if len(decoded) < 4:
66+
raise ValueError("Invalid Base58Check data")
67+
68+
payload = decoded[:-4]
69+
checksum = decoded[-4:]
70+
expected = hashlib.sha256(hashlib.sha256(payload).digest()).digest()[:4]
71+
if checksum != expected:
72+
raise ValueError("Invalid Base58Check checksum")
73+
return payload
74+
75+
76+
def _parse_xprivate_key(xprivate_key: str) -> tuple[bytes, bytes]:
77+
"""Return ``(private_key, chain_code)`` from an xprv/tprv string."""
78+
79+
payload = _decode_base58check(xprivate_key)
80+
if len(payload) != _EXTENDED_KEY_PAYLOAD_LENGTH:
81+
raise ValueError("Invalid extended private key length")
82+
83+
version = payload[:4]
84+
if version not in (_XPRV_VERSION, _TPRV_VERSION):
85+
raise ValueError("Unsupported extended private key version")
86+
87+
key_data = payload[45:78]
88+
if key_data[0] != 0:
89+
raise ValueError("Invalid extended private key payload")
2290

91+
private_key = key_data[1:]
92+
_validate_private_key(private_key)
93+
chain_code = payload[13:45]
94+
return private_key, chain_code
2395

24-
# class HDW:
25-
# """Implements mnemonic codes (BIP-39) and hierarchical deterministic
26-
# wallet (BIP-32)"""
96+
97+
def _master_key_from_seed(seed: bytes) -> tuple[bytes, bytes]:
98+
"""Create BIP-32 master private key and chain code from a BIP-39 seed."""
99+
100+
digest = hmac.new(b"Bitcoin seed", seed, hashlib.sha512).digest()
101+
private_key = digest[:32]
102+
chain_code = digest[32:]
103+
_validate_private_key(private_key)
104+
return private_key, chain_code
105+
106+
107+
def _validate_private_key(private_key: bytes) -> None:
108+
if len(private_key) != 32:
109+
raise ValueError("Private key must be 32 bytes")
110+
value = int.from_bytes(private_key, "big")
111+
if value == 0 or value >= _SECP256K1_ORDER:
112+
raise ValueError("Invalid private key")
113+
114+
115+
def _parse_path(path: str) -> list[int]:
116+
"""Parse derivation paths like ``m/44'/1'/0'/0/3``."""
117+
118+
if path in ("m", "M"):
119+
return []
120+
if not path or path[0] not in ("m", "M") or not path.startswith(("m/", "M/")):
121+
raise ValueError("Derivation path must start with 'm/'")
122+
123+
indexes: list[int] = []
124+
for raw_component in path.split("/")[1:]:
125+
if raw_component == "":
126+
raise ValueError("Derivation path contains an empty component")
127+
128+
hardened = raw_component[-1] in ("'", "h", "H")
129+
component = raw_component[:-1] if hardened else raw_component
130+
if not component.isdigit():
131+
raise ValueError(f"Invalid derivation path component: {raw_component}")
132+
133+
index = int(component)
134+
if index >= _HARDENED_OFFSET:
135+
raise ValueError("Derivation path index is too large")
136+
if hardened:
137+
index += _HARDENED_OFFSET
138+
indexes.append(index)
139+
140+
return indexes
141+
142+
143+
def _derive_child_private_key(
144+
parent_private_key: bytes, parent_chain_code: bytes, index: int
145+
) -> tuple[bytes, bytes]:
146+
"""Derive one BIP-32 private child key."""
147+
148+
if index >= _HARDENED_OFFSET:
149+
data = b"\x00" + parent_private_key + index.to_bytes(4, "big")
150+
else:
151+
parent_public_key = h_to_b(
152+
PrivateKey(b=parent_private_key).get_public_key().to_hex(compressed=True)
153+
)
154+
data = parent_public_key + index.to_bytes(4, "big")
155+
156+
digest = hmac.new(parent_chain_code, data, hashlib.sha512).digest()
157+
left = int.from_bytes(digest[:32], "big")
158+
if left >= _SECP256K1_ORDER:
159+
raise ValueError("Invalid child private key")
160+
161+
parent = int.from_bytes(parent_private_key, "big")
162+
child = (left + parent) % _SECP256K1_ORDER
163+
if child == 0:
164+
raise ValueError("Invalid child private key")
165+
166+
return child.to_bytes(32, "big"), digest[32:]
167+
168+
169+
def _derive_path(
170+
root_private_key: bytes, root_chain_code: bytes, path: str
171+
) -> tuple[bytes, bytes]:
172+
"""Derive an absolute BIP-32 path from root private key material."""
173+
174+
private_key = root_private_key
175+
chain_code = root_chain_code
176+
for index in _parse_path(path):
177+
private_key, chain_code = _derive_child_private_key(
178+
private_key, chain_code, index
179+
)
180+
return private_key, chain_code
27181

28182

29183
class HDWallet:
30-
"""Wraps the python hdwallet library to provide basic HD wallet functionality
184+
"""Minimal HD wallet wrapper used by bitcoinutils examples and tests.
31185
32-
Attributes
33-
----------
34-
hdw : object
35-
a hdwallet object
186+
The wrapper supports deriving Bitcoin private keys from a BIP-39 mnemonic
187+
or an extended private key. It does not implement the full external
188+
``hdwallet`` package API.
36189
"""
37190

38191
def __init__(
@@ -41,37 +194,55 @@ def __init__(
41194
path: Optional[str] = None,
42195
mnemonic: Optional[str] = None,
43196
):
44-
"""Instantiate a hdwallet object using the corresponding library with BTC"""
197+
if mnemonic and xprivate_key:
198+
raise ValueError("Pass either mnemonic or xprivate_key, not both")
45199

46-
self.hdw = ext_HDWallet(cryptocurrency=Bitcoin, network='mainnet' if is_mainnet() else 'testnet', hd=BIP32HD)
200+
self._root_private_key: Optional[bytes] = None
201+
self._root_chain_code: Optional[bytes] = None
202+
self._private_key: Optional[bytes] = None
203+
self._chain_code: Optional[bytes] = None
47204

48205
if mnemonic:
49-
self.hdw.from_mnemonic(mnemonic=BIP39Mnemonic(mnemonic=mnemonic))
50-
51-
if xprivate_key and path:
52-
self.hdw.from_xprivate_key(xprivate_key=xprivate_key)
53-
self.hdw.from_derivation(CustomDerivation(path))
206+
self._root_private_key, self._root_chain_code = _master_key_from_seed(
207+
_mnemonic_to_seed(mnemonic)
208+
)
209+
self._private_key = self._root_private_key
210+
self._chain_code = self._root_chain_code
211+
212+
if xprivate_key:
213+
if path is None:
214+
raise ValueError("Path must be provided with xprivate key")
215+
self._root_private_key, self._root_chain_code = _parse_xprivate_key(
216+
xprivate_key
217+
)
218+
self.from_path(path)
54219

55220
@classmethod
56221
def from_mnemonic(cls, mnemonic: str):
57-
"""Class method to instantiate from a mnemonic code for the HD Wallet"""
222+
"""Instantiate from a BIP-39 mnemonic code."""
223+
58224
return cls(mnemonic=mnemonic)
59225

60226
@classmethod
61227
def from_xprivate_key(cls, xprivate_key: str, path: Optional[str] = None):
62-
"""Class method to instantiate from an extended private key and optionally the path for the HD Wallet"""
63-
# Assert to ensure path is not None if xprivate_key is provided
64-
assert path is not None, "Path must be provided with xprivate key"
65-
# Create an instance directly using the xprivate key and path
228+
"""Instantiate from an extended private key and derivation path."""
229+
230+
if path is None:
231+
raise ValueError("Path must be provided with xprivate key")
66232
return cls(xprivate_key=xprivate_key, path=path)
67233

68234
def from_path(self, path: str):
69-
"""Set/update the path"""
235+
"""Derive and select a private key from an absolute BIP-32 path."""
70236

71-
self.hdw.clean_derivation() # type: ignore
72-
self.hdw.from_derivation(CustomDerivation(path))
237+
if self._root_private_key is None or self._root_chain_code is None:
238+
raise ValueError("No mnemonic or extended private key available")
239+
self._private_key, self._chain_code = _derive_path(
240+
self._root_private_key, self._root_chain_code, path
241+
)
73242

74243
def get_private_key(self):
75-
"""Return a PrivateKey object used throughout bitcoinutils library"""
244+
"""Return a PrivateKey object used throughout bitcoinutils."""
76245

77-
return PrivateKey(self.hdw.wif()) # type: ignore
246+
if self._private_key is None:
247+
raise ValueError("No private key has been derived")
248+
return PrivateKey(b=self._private_key)

docs/conf.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,9 @@
5858
# built documents.
5959
#
6060
# The short X.Y version.
61-
version = "0.8.1"
61+
version = "0.8.2"
6262
# The full version, including alpha/beta/rc tags.
63-
release = "0.8.1"
63+
release = "0.8.2"
6464

6565
# The language for content autogenerated by Sphinx. Refer to documentation
6666
# for a list of supported languages.

0 commit comments

Comments
 (0)