import sys import os sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) from src.hd_wallet import generate_hd_wallet, encrypt_wallet, decrypt_wallet from src.crypto import pw_decode 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 # --- Encryption --- TEST_PASSWORD = "correct_horse_battery_staple" def test_hd_no_encryption_by_default(): result = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC, num_addresses=1) assert result['use_encryption'] is False def test_hd_encryption_sets_flag(): result = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC, num_addresses=1) encrypted = encrypt_wallet(result, TEST_PASSWORD) assert encrypted['use_encryption'] is True def test_hd_encryption_encrypts_seed(): result = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC, num_addresses=1) encrypted = encrypt_wallet(result, TEST_PASSWORD) assert encrypted['keystore']['seed'] != TEST_MNEMONIC def test_hd_encryption_encrypts_xprv(): result = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC, num_addresses=1) original_xprv = result['keystore']['xprv'] encrypted = encrypt_wallet(result, TEST_PASSWORD) assert encrypted['keystore']['xprv'] != original_xprv def test_hd_encryption_leaves_xpub_plaintext(): result = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC, num_addresses=1) encrypted = encrypt_wallet(result, TEST_PASSWORD) assert encrypted['keystore']['xpub'] == result['keystore']['xpub'] def test_hd_encryption_leaves_addresses_plaintext(): result = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC, num_addresses=1) encrypted = encrypt_wallet(result, TEST_PASSWORD) assert encrypted['addresses']['receiving'][0]['address'] == result['addresses']['receiving'][0]['address'] assert encrypted['addresses']['receiving'][0]['public_key'] == result['addresses']['receiving'][0]['public_key'] def test_hd_encryption_encrypts_private_keys(): result = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC, num_addresses=2) encrypted = encrypt_wallet(result, TEST_PASSWORD) for i in range(2): orig = result['addresses']['receiving'][i] enc = encrypted['addresses']['receiving'][i] assert enc['private_key_hex'] != orig['private_key_hex'] assert enc['private_key_wif'] != orig['private_key_wif'] def test_hd_encryption_round_trip_seed(): result = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC, num_addresses=1) encrypted = encrypt_wallet(result, TEST_PASSWORD) decrypted = decrypt_wallet(encrypted, TEST_PASSWORD) assert decrypted['keystore']['seed'] == TEST_MNEMONIC def test_hd_encryption_round_trip_xprv(): result = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC, num_addresses=1) encrypted = encrypt_wallet(result, TEST_PASSWORD) decrypted = decrypt_wallet(encrypted, TEST_PASSWORD) assert decrypted['keystore']['xprv'] == result['keystore']['xprv'] def test_hd_encryption_round_trip_private_keys(): result = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC, num_addresses=3) encrypted = encrypt_wallet(result, TEST_PASSWORD) decrypted = decrypt_wallet(encrypted, TEST_PASSWORD) for i in range(3): assert decrypted['addresses']['receiving'][i]['private_key_hex'] == result['addresses']['receiving'][i]['private_key_hex'] assert decrypted['addresses']['receiving'][i]['private_key_wif'] == result['addresses']['receiving'][i]['private_key_wif'] def test_hd_decrypt_wrong_password(): result = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC, num_addresses=1) encrypted = encrypt_wallet(result, TEST_PASSWORD) try: decrypt_wallet(encrypted, "wrong_password") assert False, "Should have raised ValueError" except ValueError: pass def test_hd_encrypt_does_not_mutate_original(): result = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC, num_addresses=1) original_seed = result['keystore']['seed'] encrypt_wallet(result, TEST_PASSWORD) assert result['keystore']['seed'] == original_seed def test_hd_decrypt_unencrypted_is_noop(): result = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC, num_addresses=1) decrypted = decrypt_wallet(result, TEST_PASSWORD) assert decrypted['keystore']['seed'] == result['keystore']['seed'] # --- Error cases --- def test_hd_invalid_network_raises(): import pytest with pytest.raises(ValueError): generate_hd_wallet('invalid_net', 'bip84', TEST_MNEMONIC) def test_hd_invalid_bip_type_raises(): import pytest with pytest.raises(ValueError): generate_hd_wallet('mainnet', 'bip99', TEST_MNEMONIC) # --- BIP39 passphrase changes derived addresses --- def test_hd_passphrase_changes_addresses(): r_no_pass = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC, passphrase='', num_addresses=1) r_with_pass = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC, passphrase='secret', num_addresses=1) assert r_no_pass['addresses']['receiving'][0]['address'] != r_with_pass['addresses']['receiving'][0]['address'] def test_hd_passphrase_changes_xpub(): r1 = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC, passphrase='') r2 = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC, passphrase='passphrase') assert r1['keystore']['xpub'] != r2['keystore']['xpub'] def test_hd_same_passphrase_same_addresses(): r1 = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC, passphrase='TREZOR', num_addresses=2) r2 = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC, passphrase='TREZOR', num_addresses=2) assert r1['addresses']['receiving'] == r2['addresses']['receiving'] # --- Account index changes derived addresses --- def test_hd_different_account_different_addresses(): r0 = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC, account=0, num_addresses=1) r1 = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC, account=1, num_addresses=1) assert r0['addresses']['receiving'][0]['address'] != r1['addresses']['receiving'][0]['address'] def test_hd_different_account_different_xpub(): r0 = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC, account=0) r1 = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC, account=1) assert r0['keystore']['xpub'] != r1['keystore']['xpub'] def test_hd_account_reflected_in_derivation_path(): r = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC, account=2) assert r['keystore']['derivation'] == "m/84'/0'/2'" def test_hd_address_path_uses_correct_account(): r = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC, account=1, num_addresses=1) assert r['addresses']['receiving'][0]['path'].startswith("m/84'/0'/1'/") # --- words_num generates correct mnemonic length --- def test_hd_words_num_15(): r = generate_hd_wallet('mainnet', 'bip84', words_num=15) assert len(r['keystore']['seed'].split()) == 15 def test_hd_words_num_18(): r = generate_hd_wallet('mainnet', 'bip84', words_num=18) assert len(r['keystore']['seed'].split()) == 18 def test_hd_words_num_21(): r = generate_hd_wallet('mainnet', 'bip84', words_num=21) assert len(r['keystore']['seed'].split()) == 21 def test_hd_words_num_24(): r = generate_hd_wallet('mainnet', 'bip84', words_num=24) assert len(r['keystore']['seed'].split()) == 24 # --- 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