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:
@@ -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:
|
||||
|
||||
@@ -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
179
src/hd_wallet.py
Normal 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
234
tests/test_hd_wallet.py
Normal 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
|
||||
Reference in New Issue
Block a user