feat: add HD wallet generator with BIP-44/49/84/86 support

- Add src/hd_wallet.py with deterministic key derivation (BIP-32/39)
- Support P2PKH (BIP-44), P2SH-P2WPKH (BIP-49), P2WPKH (BIP-84), P2TR (BIP-86)
- JSON output aligned to Electrum wallet format (keystore, xpub/xprv, derivation path, root fingerprint)
- Add tests/test_hd_wallet.py with 34 tests (structure, prefixes, determinism, SecretScan)
- Add bip-utils to requirements.txt
- Add option 6 to __main__.py menu
This commit is contained in:
2026-03-09 12:04:43 +01:00
parent 409669e000
commit 2727844ec8
4 changed files with 419 additions and 3 deletions

View File

@@ -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:

View File

@@ -3,3 +3,4 @@ bech32==1.2.0
ecdsa==0.19.0
six==1.17.0
pytest
bip-utils

179
src/hd_wallet.py Normal file
View File

@@ -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()

234
tests/test_hd_wallet.py Normal file
View File

@@ -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