diff --git a/bsv/constants.py b/bsv/constants.py index 118f5bd..6f1556b 100644 --- a/bsv/constants.py +++ b/bsv/constants.py @@ -8,6 +8,7 @@ TRANSACTION_VERSION: int = int(os.getenv('BSV_PY_SDK_TRANSACTION_VERSION') or 1) TRANSACTION_LOCKTIME: int = int(os.getenv('BSV_PY_SDK_TRANSACTION_LOCKTIME') or 0) TRANSACTION_FEE_RATE: float = float(os.getenv('BSV_PY_SDK_TRANSACTION_FEE_RATE') or 0.5) # satoshi per byte +BIP32_DERIVATION_PATH = os.getenv('BSV_PY_SDK_BIP32_DERIVATION_PATH') or "m/" BIP39_ENTROPY_BIT_LENGTH: int = int(os.getenv('BSV_PY_SDK_BIP39_ENTROPY_BIT_LENGTH') or 128) BIP44_DERIVATION_PATH = os.getenv('BSV_PY_SDK_BIP44_DERIVATION_PATH') or "m/44'/236'/0'" diff --git a/bsv/hd/bip32.py b/bsv/hd/bip32.py index 9367873..003b917 100644 --- a/bsv/hd/bip32.py +++ b/bsv/hd/bip32.py @@ -1,16 +1,18 @@ import hmac from hashlib import sha512 -from typing import Union +from typing import Union, List +from .bip39 import seed_from_mnemonic from ..base58 import base58check_decode, base58check_encode from ..constants import BIP32_SEED_BYTE_LENGTH from ..constants import NETWORK_XPUB_PREFIX_DICT, NETWORK_XPRV_PREFIX_DICT from ..constants import Network, XKEY_BYTE_LENGTH, XKEY_PREFIX_LIST, PUBLIC_KEY_COMPRESSED_PREFIX_LIST -from ..constants import XPUB_PREFIX_NETWORK_DICT, XPRV_PREFIX_NETWORK_DICT +from ..constants import XPUB_PREFIX_NETWORK_DICT, XPRV_PREFIX_NETWORK_DICT, BIP32_DERIVATION_PATH from ..curve import curve, curve_add, curve_multiply from ..keys import PublicKey, PrivateKey + class Xkey: """ [ : 4] prefix @@ -65,7 +67,11 @@ def ckd(self, index: Union[int, str, bytes]) -> 'Xpub': elif isinstance(index, str): index = bytes.fromhex(index) assert len(index) == 4, 'index should be a 4 bytes integer' - assert index[0] < 0x80, "can't make hardened derivation from xpub" + assert index[0] < 0x80, ("can't make hardened derivation from xpub. " + "If you use hardened key, please set xpub with path from xpriv first. Example:\n" + " master_xprv = master_xprv_from_seed(seed)\n" + " account_xprv = ckd(master_xprv, \"m/44'/0'/0'\")\n" + " account_xpub = account_xprv.xpub()") payload: bytes = self.prefix payload += (self.depth + 1).to_bytes(1, 'big') @@ -181,6 +187,7 @@ def step_to_index(step: Union[str, int]) -> int: def ckd(xkey: Union[Xprv, Xpub], path: str) -> Union[Xprv, Xpub]: """ + ckd = "Child Key Derivation" derive an extended key according to path like "m/44'/0'/1'/0/10" (absolute) or "./0/10" (relative) """ steps = path.strip(' ').strip('/').split('/') @@ -200,3 +207,92 @@ def ckd(xkey: Union[Xprv, Xpub], path: str) -> Union[Xprv, Xpub]: def master_xprv_from_seed(seed: Union[str, bytes], network: Network = Network.MAINNET) -> Xprv: return Xprv.from_seed(seed, network) + + +def _derive_xkeys_from_xkey(xkey: Union[Xprv, Xpub], + index_start: Union[str, int], + index_end: Union[str, int], + change: Union[str, int] = 0) -> List[Union[Xprv, Xpub]]: + """ + this function is internal use only within bip32 module + Use bip32_derive_xkeys_from_xkey instead. + """ + change_xkey = xkey.ckd(step_to_index(change)) + return [change_xkey.ckd(i) for i in range(step_to_index(index_start), step_to_index(index_end))] + + +def bip32_derive_xprv_from_mnemonic(mnemonic: str, + lang: str = 'en', + passphrase: str = '', + prefix: str = 'mnemonic', + path: str = BIP32_DERIVATION_PATH, + network: Network = Network.MAINNET) -> Xprv: + """ + Derive the subtree root extended private key from mnemonic and path. + """ + seed = seed_from_mnemonic(mnemonic, lang, passphrase, prefix) + master_xprv = Xprv.from_seed(seed, network) + return ckd(master_xprv, path) + + +def bip32_derive_xprvs_from_mnemonic(mnemonic: str, + index_start: Union[str, int], + index_end: Union[str, int], + lang: str = 'en', + passphrase: str = '', + prefix: str = 'mnemonic', + path: str = BIP32_DERIVATION_PATH, + change: Union[str, int] = 0, + network: Network = Network.MAINNET) -> List[Xprv]: + """ + Derive a range of extended keys from a nmemonic using BIP32 format + """ + xprv = bip32_derive_xprv_from_mnemonic(mnemonic, lang, passphrase, prefix, path, network) + return _derive_xkeys_from_xkey(xprv, index_start, index_end, change) + + +def bip32_derive_xkeys_from_xkey(xkey: Union[Xprv, Xpub], + index_start: Union[str, int], + index_end: Union[str, int], + path: str = BIP32_DERIVATION_PATH, + change: Union[str, int] = 0) -> List[Union[Xprv, Xpub]]: + """ + Derive a range of extended keys from Xprv and Xpub keys using BIP32 path structure. + + Args: + xkey: Parent extended key (Xprv or Xpub) + index_start: Starting index for derivation + index_end: Ending index for derivation (exclusive) + path: Base derivation path (default: BIP32_DERIVATION_PATH) + change: Change level (0 for receiving addresses, 1 for change addresses) + + Returns: + List[Union[Xprv, Xpub]]: List of derived extended keys + """ + # Convert index arguments to integers + start_idx = step_to_index(index_start) if isinstance(index_start, str) else index_start + end_idx = step_to_index(index_end) if isinstance(index_end, str) else index_end + + # Validate indices + if start_idx < 0 or end_idx < 0 or start_idx >= end_idx: + raise ValueError("Invalid index range: start must be non-negative and less than end") + + # Parse the base path and reconstruct with change value + base_path = path.rstrip('/') # Remove trailing slashes if any + if base_path.startswith('m/'): + # For absolute paths + derived_path = f"{base_path}/{change}" + else: + # For relative paths + derived_path = f"./{change}" + + # First derive to the change level + change_level = ckd(xkey, derived_path) + + # Then derive the range of addresses + derived_keys = [] + for i in range(start_idx, end_idx): + child_key = change_level.ckd(i) + derived_keys.append(child_key) + + return derived_keys \ No newline at end of file diff --git a/bsv/hd/bip44.py b/bsv/hd/bip44.py index 5c8b759..b91183c 100644 --- a/bsv/hd/bip44.py +++ b/bsv/hd/bip44.py @@ -1,35 +1,93 @@ from typing import Union, List - -from .bip32 import Xprv, Xpub, step_to_index, ckd -from .bip39 import seed_from_mnemonic +from .bip32 import Xprv, Xpub, step_to_index, bip32_derive_xprv_from_mnemonic, bip32_derive_xkeys_from_xkey from ..constants import Network, BIP44_DERIVATION_PATH -def derive_xprv_from_mnemonic(mnemonic: str, - lang: str = 'en', - passphrase: str = '', - prefix: str = 'mnemonic', - path: str = BIP44_DERIVATION_PATH, - network: Network = Network.MAINNET) -> Xprv: +def bip44_derive_xprv_from_mnemonic(mnemonic: str, + lang: str = 'en', + passphrase: str = '', + prefix: str = 'mnemonic', + path: str = BIP44_DERIVATION_PATH, + network: Network = Network.MAINNET) -> Xprv: + """ + Derives extended private key using BIP44 format- it is a subset of BIP32. + Inherits from BIP32, only changing the default path value. + """ + return bip32_derive_xprv_from_mnemonic( + mnemonic=mnemonic, + lang=lang, + passphrase=passphrase, + prefix=prefix, + path=path, + network=network + ) + + +def bip44_derive_xprvs_from_mnemonic(mnemonic: str, + index_start: Union[str, int], + index_end: Union[str, int], + lang: str = 'en', + passphrase: str = '', + prefix: str = 'mnemonic', + path: str = BIP44_DERIVATION_PATH, + change: Union[str, int] = 0, + network: Network = Network.MAINNET) -> List[Xprv]: + """ + Derive a range of extended keys from a nmemonic using BIP44 format + """ + + xprv = bip44_derive_xprv_from_mnemonic(mnemonic, lang, passphrase, prefix, path, network) + return _derive_xkeys_from_xkey(xprv, index_start, index_end, change) + + +def _derive_xkeys_from_xkey(xkey: Union[Xprv, Xpub], + index_start: Union[str, int], + index_end: Union[str, int], + change: Union[str, int] = 0) -> List[Union[Xprv, Xpub]]: """ - derive the subtree root extended private key from mnemonic and path + this function is internal use only within bip44 module """ - seed = seed_from_mnemonic(mnemonic, lang, passphrase, prefix) - master_xprv = Xprv.from_seed(seed, network) - return ckd(master_xprv, path) + change_xkey = xkey.ckd(step_to_index(change)) + return [change_xkey.ckd(i) for i in range(step_to_index(index_start), step_to_index(index_end))] +# [DEPRECATED] def derive_xkeys_from_xkey(xkey: Union[Xprv, Xpub], index_start: Union[str, int], index_end: Union[str, int], change: Union[str, int] = 0) -> List[Union[Xprv, Xpub]]: """ - derive extended keys according to path "./change/index" + [DEPRECATED] Use bip32_derive_xkeys_from_xkey instead. + This function name is kept for backward compatibility. """ - change_xkey = xkey.ckd(step_to_index(change)) - return [change_xkey.ckd(i) for i in range(step_to_index(index_start), step_to_index(index_end))] + return _derive_xkeys_from_xkey(xkey=xkey, + index_start=index_start, + index_end=index_end, + change=change) + + +# [DEPRECATED] +def derive_xprv_from_mnemonic(mnemonic: str, + lang: str = 'en', + passphrase: str = '', + prefix: str = 'mnemonic', + path: str = BIP44_DERIVATION_PATH, + network: Network = Network.MAINNET) -> Xprv: + """ + [DEPRECATED] Use bip44_derive_xprv_from_mnemonic instead. + This function name is kept for backward compatibility. + """ + return bip44_derive_xprv_from_mnemonic( + mnemonic=mnemonic, + lang=lang, + passphrase=passphrase, + prefix=prefix, + path=path, + network=network + ) +# [DEPRECATED] def derive_xprvs_from_mnemonic(mnemonic: str, index_start: Union[str, int], index_end: Union[str, int], @@ -39,5 +97,16 @@ def derive_xprvs_from_mnemonic(mnemonic: str, path: str = BIP44_DERIVATION_PATH, change: Union[str, int] = 0, network: Network = Network.MAINNET) -> List[Xprv]: - xprv = derive_xprv_from_mnemonic(mnemonic, lang, passphrase, prefix, path, network) - return derive_xkeys_from_xkey(xprv, index_start, index_end, change) + """ + [DEPRECATED] Use bip44_derive_xprvs_from_mnemonic instead. + This function name is kept for backward compatibility. + """ + return bip44_derive_xprvs_from_mnemonic(mnemonic=mnemonic, + index_start=index_start, + index_end=index_end, + lang=lang, + passphrase=passphrase, + prefix=prefix, + path=path, + change=change, + network=network) diff --git a/tests/test_hd.py b/tests/test_hd.py index 8b496c1..4368476 100644 --- a/tests/test_hd.py +++ b/tests/test_hd.py @@ -192,3 +192,5 @@ def test_derive(): with pytest.raises(AssertionError, match=r"can't make hardened derivation from xpub"): derive_xkeys_from_xkey(xpub, "0'", "1'") + + diff --git a/tests/test_hd_bip.py b/tests/test_hd_bip.py new file mode 100644 index 0000000..c44e472 --- /dev/null +++ b/tests/test_hd_bip.py @@ -0,0 +1,56 @@ +import pytest + +from bsv.hd.bip32 import master_xprv_from_seed, bip32_derive_xprvs_from_mnemonic, bip32_derive_xkeys_from_xkey +from bsv.hd.bip39 import seed_from_mnemonic +from bsv.hd.bip44 import bip44_derive_xprvs_from_mnemonic + +from bsv.constants import BIP32_DERIVATION_PATH, BIP44_DERIVATION_PATH + +# BIP32_DERIVATION_PATH = "m/" +# BIP44_DERIVATION_PATH = "m/44'/236'/0'" + +def test_key_derivation_consistency(): + # Test mnemonic phrase + test_mnemonic = "skin index hair zone brush soldier airport found stuff rare wonder physical" + + # Generate seed from mnemonic + seed = seed_from_mnemonic(test_mnemonic, lang='en') + + # Generate master keys + master_xprv = master_xprv_from_seed(seed) + master_xpub = master_xprv.xpub() + + # Key derivation using different methods + # 1. BIP32 derivation from master extended private key + keys_from_bip32_xprv = bip32_derive_xkeys_from_xkey(master_xprv, 0, 2, BIP32_DERIVATION_PATH, 0) + # 2. BIP32 derivation from master extended public key + keys_from_bip32_xpub = bip32_derive_xkeys_from_xkey(master_xpub, 0, 2, BIP32_DERIVATION_PATH, 0) + # 3. BIP32 derivation directly from mnemonic + keys_from_bip32_mnemonic = bip32_derive_xprvs_from_mnemonic(test_mnemonic, 0, 2, path=BIP32_DERIVATION_PATH, change=0) + + # Test BIP32 derivation consistency + for i in range(2): + assert keys_from_bip32_xprv[i].address() == keys_from_bip32_xpub[i].address(), \ + f"BIP32 xprv/xpub derivation mismatch at index {i}" + assert keys_from_bip32_xprv[i].address() == keys_from_bip32_mnemonic[i].address(), \ + f"BIP32 xprv/mnemonic derivation mismatch at index {i}" + + # Test BIP44 derivation + keys_from_bip32_mnemonic = bip32_derive_xprvs_from_mnemonic(test_mnemonic, 0, 2, path=BIP44_DERIVATION_PATH, change=0) + keys_from_bip44_mnemonic = bip44_derive_xprvs_from_mnemonic(test_mnemonic, 0, 2, path=BIP44_DERIVATION_PATH, change=0) + + # Test BIP44 derivation consistency + for i in range(2): + assert keys_from_bip32_mnemonic[i].address() == keys_from_bip44_mnemonic[i].address(), \ + f"BIP32/BIP44 derivation mismatch at index {i}" + +def test_invalid_mnemonic(): + with pytest.raises(ValueError): + invalid_mnemonic = "invalid mnemonic phrase" + bip32_derive_xprvs_from_mnemonic(invalid_mnemonic, 0, 2, path=BIP32_DERIVATION_PATH, change=0) + +def test_invalid_derivation_path(): + test_mnemonic = "skin index hair zone brush soldier airport found stuff rare wonder physical" + with pytest.raises(ValueError): + invalid_path = "m/invalid" + bip32_derive_xprvs_from_mnemonic(test_mnemonic, 0, 2, path=invalid_path, change=0) \ No newline at end of file