diff --git a/__main__.py b/__main__.py index 4bf7b23..63c5e7e 100644 --- a/__main__.py +++ b/__main__.py @@ -9,15 +9,17 @@ def main(): print("3. P2SH") print("4. P2WPKH") print("5. P2TR") - + print("6. HD Wallet (BIP-44/49/84/86)") + choice = input("Inserisci la tua scelta: ").strip() - + scripts = { '1': 'src/p2pk.py', '2': 'src/p2pkh.py', '3': 'src/p2sh.py', '4': 'src/p2wpkh.py', - '5': 'src/p2tr.py' + '5': 'src/p2tr.py', + '6': 'src/hd_wallet.py', } if choice in scripts: diff --git a/requirements.txt b/requirements.txt index d6edba9..29c7388 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ bech32==1.2.0 ecdsa==0.19.0 six==1.17.0 pytest +bip-utils diff --git a/src/hd_wallet.py b/src/hd_wallet.py new file mode 100644 index 0000000..7bb4d26 --- /dev/null +++ b/src/hd_wallet.py @@ -0,0 +1,179 @@ +import hashlib +import json +from typing import Dict, List, Optional + +from bip_utils import ( + Bip39MnemonicGenerator, Bip39WordsNum, Bip39SeedGenerator, + Bip44, Bip44Coins, Bip44Changes, + Bip49, Bip49Coins, + Bip84, Bip84Coins, + Bip86, Bip86Coins, +) + +COIN_MAP = { + 'mainnet': { + 'bip44': (Bip44, Bip44Coins.BITCOIN), + 'bip49': (Bip49, Bip49Coins.BITCOIN), + 'bip84': (Bip84, Bip84Coins.BITCOIN), + 'bip86': (Bip86, Bip86Coins.BITCOIN), + }, + 'testnet': { + 'bip44': (Bip44, Bip44Coins.BITCOIN_TESTNET), + 'bip49': (Bip49, Bip49Coins.BITCOIN_TESTNET), + 'bip84': (Bip84, Bip84Coins.BITCOIN_TESTNET), + 'bip86': (Bip86, Bip86Coins.BITCOIN_TESTNET), + }, +} + +PURPOSE = { + 'bip44': 44, + 'bip49': 49, + 'bip84': 84, + 'bip86': 86, +} + +COIN_TYPE = { + 'mainnet': 0, + 'testnet': 1, +} + + +def _hash160(data: bytes) -> bytes: + return hashlib.new('ripemd160', hashlib.sha256(data).digest()).digest() + + +def _root_fingerprint(master) -> str: + pub = bytes(master.PublicKey().RawCompressed().ToBytes()) + return _hash160(pub)[:4].hex() + + +def generate_hd_wallet( + network: str = 'mainnet', + bip_type: str = 'bip84', + mnemonic: Optional[str] = None, + passphrase: str = '', + account: int = 0, + num_addresses: int = 5, +) -> Dict: + """ + Genera un HD wallet deterministico secondo BIP-32/39/44/49/84/86. + Il JSON di output è compatibile con il formato wallet di Electrum. + + Args: + network: 'mainnet' o 'testnet' + bip_type: 'bip44' | 'bip49' | 'bip84' | 'bip86' + mnemonic: frase mnemonica BIP-39 (None = genera nuova a 12 parole) + passphrase: passphrase opzionale BIP-39 + account: indice account (default 0) + num_addresses: quanti indirizzi receiving derivare + + Returns: + Dict compatibile con il formato wallet Electrum. + """ + if network not in COIN_MAP: + raise ValueError(f"Network non supportato: '{network}'. Scegli 'mainnet' o 'testnet'.") + if bip_type not in COIN_MAP[network]: + raise ValueError(f"Tipo BIP non supportato: '{bip_type}'. Scegli tra bip44, bip49, bip84, bip86.") + + if mnemonic is None: + mnemonic = str(Bip39MnemonicGenerator().FromWordsNumber(Bip39WordsNum.WORDS_NUM_12)) + + seed = Bip39SeedGenerator(mnemonic).Generate(passphrase) + + bip_cls, coin = COIN_MAP[network][bip_type] + purpose = PURPOSE[bip_type] + coin_type = COIN_TYPE[network] + account_path = f"m/{purpose}'/{coin_type}'/{account}'" + + master = bip_cls.FromSeed(seed, coin) + account_ctx = master.Purpose().Coin().Account(account) + change_chain = account_ctx.Change(Bip44Changes.CHAIN_EXT) + + receiving: List[Dict] = [] + for i in range(num_addresses): + ctx = change_chain.AddressIndex(i) + receiving.append({ + 'index': i, + 'path': f"{account_path}/0/{i}", + 'address': ctx.PublicKey().ToAddress(), + 'public_key': ctx.PublicKey().RawCompressed().ToHex(), + 'private_key_wif': ctx.PrivateKey().ToWif(), + 'private_key_hex': ctx.PrivateKey().Raw().ToHex(), + }) + + return { + 'keystore': { + 'type': 'bip32', + 'xpub': account_ctx.PublicKey().ToExtended(), + 'xprv': account_ctx.PrivateKey().ToExtended(), + 'seed': str(mnemonic), + 'passphrase': passphrase, + 'derivation': account_path, + 'root_fingerprint': _root_fingerprint(master), + }, + 'wallet_type': 'standard', + 'use_encryption': False, + 'seed_type': 'bip39', + 'seed_version': 17, + 'addresses': { + 'receiving': receiving, + 'change': [], + }, + } + + +def main(): + print("=== HD Wallet Generator (BIP-32/39/44/49/84/86) ===") + + network = input("Network (mainnet/testnet): ").strip().lower() + print("Tipo di indirizzo:") + print(" bip44 → P2PKH (legacy, inizia con 1/m)") + print(" bip49 → P2SH-P2WPKH (wrapped SegWit, inizia con 3/2)") + print(" bip84 → P2WPKH (native SegWit, inizia con bc1q/tb1q)") + print(" bip86 → P2TR (Taproot, inizia con bc1p/tb1p)") + bip_type = input("BIP type (bip44/bip49/bip84/bip86): ").strip().lower() + + mnemonic_input = input("Mnemonic esistente (lascia vuoto per generarne una nuova): ").strip() + mnemonic = mnemonic_input if mnemonic_input else None + + passphrase = input("Passphrase BIP-39 (lascia vuoto per nessuna): ").strip() + + try: + account = int(input("Account index (default 0): ").strip() or "0") + num_addresses = int(input("Numero di indirizzi da derivare (default 5): ").strip() or "5") + except ValueError: + print("Valore non valido. Uso i default (account=0, indirizzi=5).") + account = 0 + num_addresses = 5 + + try: + result = generate_hd_wallet(network, bip_type, mnemonic, passphrase, account, num_addresses) + ks = result['keystore'] + + print("\n--- Keystore ---") + print(f"Seed: {ks['seed']}") + print(f"Derivation: {ks['derivation']}") + print(f"Root fingerprint: {ks['root_fingerprint']}") + print(f"xpub: {ks['xpub']}") + print(f"xprv: {ks['xprv']}") + + print(f"\n--- Indirizzi receiving ({num_addresses}) ---") + for a in result['addresses']['receiving']: + print(f"[{a['index']}] {a['address']} (path: {a['path']})") + print(f" pub: {a['public_key']}") + print(f" WIF: {a['private_key_wif']}") + + nome = input("\nNome file per salvare (senza estensione, vuoto per saltare): ").strip() + if nome: + if not nome.endswith('.json'): + nome += '.json' + with open(nome, 'w') as f: + json.dump(result, f, indent=4) + print(f"Salvato in {nome}") + + except Exception as e: + print(f"Errore: {e}") + + +if __name__ == '__main__': + main() diff --git a/tests/test_hd_wallet.py b/tests/test_hd_wallet.py new file mode 100644 index 0000000..450f846 --- /dev/null +++ b/tests/test_hd_wallet.py @@ -0,0 +1,234 @@ +import sys +import os +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +from src.hd_wallet import generate_hd_wallet + +SECRETSCAN_URL = "https://secretscan.org/Bitcoin?address={}" + +TEST_MNEMONIC = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + +BIP_TYPES = ['bip44', 'bip49', 'bip84', 'bip86'] + + +# --- Electrum top-level structure --- + +def test_hd_top_level_fields(): + result = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC) + assert set(result.keys()) == {'keystore', 'wallet_type', 'use_encryption', 'seed_type', 'seed_version', 'addresses'} + + +def test_hd_electrum_metadata(): + result = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC) + assert result['wallet_type'] == 'standard' + assert result['use_encryption'] is False + assert result['seed_type'] == 'bip39' + assert result['seed_version'] == 17 + + +# --- Keystore structure --- + +def test_hd_keystore_fields(): + result = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC) + ks = result['keystore'] + assert set(ks.keys()) == {'type', 'xpub', 'xprv', 'seed', 'passphrase', 'derivation', 'root_fingerprint'} + + +def test_hd_keystore_type(): + result = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC) + assert result['keystore']['type'] == 'bip32' + + +def test_hd_keystore_seed(): + result = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC) + assert result['keystore']['seed'] == TEST_MNEMONIC + + +def test_hd_keystore_root_fingerprint_length(): + result = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC) + assert len(result['keystore']['root_fingerprint']) == 8 # 4 bytes hex + + +def test_hd_keystore_same_root_fingerprint_across_bip_types(): + # Root fingerprint derives from master key, same for all BIP types with same mnemonic + fingerprints = { + bip: generate_hd_wallet('mainnet', bip, TEST_MNEMONIC)['keystore']['root_fingerprint'] + for bip in BIP_TYPES + } + assert len(set(fingerprints.values())) == 1 + + +# --- xpub/xprv prefixes per BIP type --- + +def test_hd_bip44_xpub_prefix(): + ks = generate_hd_wallet('mainnet', 'bip44', TEST_MNEMONIC)['keystore'] + assert ks['xpub'].startswith('xpub') + assert ks['xprv'].startswith('xprv') + + +def test_hd_bip49_xpub_prefix(): + ks = generate_hd_wallet('mainnet', 'bip49', TEST_MNEMONIC)['keystore'] + assert ks['xpub'].startswith('ypub') + assert ks['xprv'].startswith('yprv') + + +def test_hd_bip84_xpub_prefix(): + ks = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC)['keystore'] + assert ks['xpub'].startswith('zpub') + assert ks['xprv'].startswith('zprv') + + +def test_hd_bip86_xpub_prefix(): + ks = generate_hd_wallet('mainnet', 'bip86', TEST_MNEMONIC)['keystore'] + assert ks['xpub'].startswith('xpub') + assert ks['xprv'].startswith('xprv') + + +# --- Derivation paths --- + +def test_hd_derivation_bip44_mainnet(): + ks = generate_hd_wallet('mainnet', 'bip44', TEST_MNEMONIC)['keystore'] + assert ks['derivation'] == "m/44'/0'/0'" + + +def test_hd_derivation_bip49_mainnet(): + ks = generate_hd_wallet('mainnet', 'bip49', TEST_MNEMONIC)['keystore'] + assert ks['derivation'] == "m/49'/0'/0'" + + +def test_hd_derivation_bip84_mainnet(): + ks = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC)['keystore'] + assert ks['derivation'] == "m/84'/0'/0'" + + +def test_hd_derivation_bip86_mainnet(): + ks = generate_hd_wallet('mainnet', 'bip86', TEST_MNEMONIC)['keystore'] + assert ks['derivation'] == "m/86'/0'/0'" + + +def test_hd_derivation_testnet_coin_type(): + ks = generate_hd_wallet('testnet', 'bip84', TEST_MNEMONIC)['keystore'] + assert ks['derivation'] == "m/84'/1'/0'" + + +# --- Addresses structure --- + +def test_hd_addresses_keys(): + result = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC) + assert set(result['addresses'].keys()) == {'receiving', 'change'} + assert result['addresses']['change'] == [] + + +def test_hd_address_entry_fields(): + result = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC, num_addresses=1) + entry = result['addresses']['receiving'][0] + assert set(entry.keys()) == {'index', 'path', 'address', 'public_key', 'private_key_wif', 'private_key_hex'} + + +def test_hd_address_path_format(): + result = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC, num_addresses=3) + for i, entry in enumerate(result['addresses']['receiving']): + assert entry['path'] == f"m/84'/0'/0'/0/{i}" + assert entry['index'] == i + + +# --- Address prefixes mainnet --- + +def test_hd_bip44_address_mainnet(): + result = generate_hd_wallet('mainnet', 'bip44', TEST_MNEMONIC, num_addresses=1) + assert result['addresses']['receiving'][0]['address'].startswith('1') + + +def test_hd_bip49_address_mainnet(): + result = generate_hd_wallet('mainnet', 'bip49', TEST_MNEMONIC, num_addresses=1) + assert result['addresses']['receiving'][0]['address'].startswith('3') + + +def test_hd_bip84_address_mainnet(): + result = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC, num_addresses=1) + assert result['addresses']['receiving'][0]['address'].startswith('bc1q') + + +def test_hd_bip86_address_mainnet(): + result = generate_hd_wallet('mainnet', 'bip86', TEST_MNEMONIC, num_addresses=1) + assert result['addresses']['receiving'][0]['address'].startswith('bc1p') + + +# --- Address prefixes testnet --- + +def test_hd_bip44_address_testnet(): + result = generate_hd_wallet('testnet', 'bip44', TEST_MNEMONIC, num_addresses=1) + assert result['addresses']['receiving'][0]['address'][0] in ('m', 'n') + + +def test_hd_bip84_address_testnet(): + result = generate_hd_wallet('testnet', 'bip84', TEST_MNEMONIC, num_addresses=1) + assert result['addresses']['receiving'][0]['address'].startswith('tb1q') + + +def test_hd_bip86_address_testnet(): + result = generate_hd_wallet('testnet', 'bip86', TEST_MNEMONIC, num_addresses=1) + assert result['addresses']['receiving'][0]['address'].startswith('tb1p') + + +# --- Determinism --- + +def test_hd_same_mnemonic_same_addresses(): + r1 = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC, num_addresses=3) + r2 = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC, num_addresses=3) + addrs1 = [a['address'] for a in r1['addresses']['receiving']] + addrs2 = [a['address'] for a in r2['addresses']['receiving']] + assert addrs1 == addrs2 + + +def test_hd_same_mnemonic_same_xpub(): + r1 = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC) + r2 = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC) + assert r1['keystore']['xpub'] == r2['keystore']['xpub'] + + +def test_hd_different_mnemonic_different_xpub(): + r1 = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC) + r2 = generate_hd_wallet('mainnet', 'bip84') # random mnemonic + assert r1['keystore']['xpub'] != r2['keystore']['xpub'] + + +# --- num_addresses --- + +def test_hd_num_addresses(): + for n in [1, 3, 10]: + result = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC, num_addresses=n) + assert len(result['addresses']['receiving']) == n + + +# --- Key format --- + +def test_hd_private_key_length(): + result = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC, num_addresses=1) + assert len(result['addresses']['receiving'][0]['private_key_hex']) == 64 + + +def test_hd_public_key_compressed(): + result = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC, num_addresses=1) + pub = result['addresses']['receiving'][0]['public_key'] + assert len(pub) == 66 + assert pub[:2] in ('02', '03') + + +# --- Mnemonic generation --- + +def test_hd_generates_mnemonic_when_none(): + result = generate_hd_wallet('mainnet', 'bip84') + assert len(result['keystore']['seed'].split()) == 12 + + +# --- SecretScan --- + +def test_hd_print_secretscan(capsys): + result = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC, num_addresses=3) + for a in result['addresses']['receiving']: + url = SECRETSCAN_URL.format(a['address']) + print(f"\n[HD BIP84] [{a['index']}] {a['address']}") + print(f"[HD BIP84] Verify on SecretScan: {url}") + captured = capsys.readouterr() + assert captured.out.count('secretscan.org') == 3