diff --git a/tests/test_crypto.py b/tests/test_crypto.py new file mode 100644 index 0000000..1a512e6 --- /dev/null +++ b/tests/test_crypto.py @@ -0,0 +1,156 @@ +import sys +import os +import base64 +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +import pytest +from src.crypto import pw_encode, pw_decode, _derive_key + +SECRETSCAN_URL = "https://secretscan.org/Bitcoin?address={}" + +PASSWORD = "correct_horse_battery_staple" +PLAINTEXT = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + + +# --- Key derivation --- + +def test_derive_key_is_32_bytes(): + key = _derive_key(PASSWORD) + assert len(key) == 32 + + +def test_derive_key_deterministic(): + assert _derive_key(PASSWORD) == _derive_key(PASSWORD) + + +def test_derive_key_different_passwords_differ(): + assert _derive_key("password1") != _derive_key("password2") + + +def test_derive_key_returns_bytes(): + assert isinstance(_derive_key(PASSWORD), bytes) + + +# --- pw_encode output format --- + +def test_pw_encode_returns_string(): + result = pw_encode(PLAINTEXT, PASSWORD) + assert isinstance(result, str) + + +def test_pw_encode_is_valid_base64(): + result = pw_encode(PLAINTEXT, PASSWORD) + raw = base64.b64decode(result) + # Must contain at least 16 bytes IV + 16 bytes (one AES block) + assert len(raw) >= 32 + + +def test_pw_encode_includes_iv(): + # Encoded payload = IV (16 bytes) + ciphertext; base64 of 32+ bytes + result = pw_encode(PLAINTEXT, PASSWORD) + raw = base64.b64decode(result) + assert len(raw) >= 16 + + +# --- Random IV: same inputs produce different ciphertext each call --- + +def test_pw_encode_random_iv_produces_unique_ciphertext(): + c1 = pw_encode(PLAINTEXT, PASSWORD) + c2 = pw_encode(PLAINTEXT, PASSWORD) + assert c1 != c2 + + +def test_pw_encode_random_iv_different_on_three_calls(): + outputs = {pw_encode(PLAINTEXT, PASSWORD) for _ in range(5)} + assert len(outputs) == 5 + + +# --- Round-trip --- + +def test_pw_round_trip_basic(): + ciphertext = pw_encode(PLAINTEXT, PASSWORD) + assert pw_decode(ciphertext, PASSWORD) == PLAINTEXT + + +def test_pw_round_trip_empty_string(): + ciphertext = pw_encode("", PASSWORD) + assert pw_decode(ciphertext, PASSWORD) == "" + + +def test_pw_round_trip_unicode(): + text = "passphrase: résumé café 日本語 🔑" + ciphertext = pw_encode(text, PASSWORD) + assert pw_decode(ciphertext, PASSWORD) == text + + +def test_pw_round_trip_long_text(): + text = "a" * 1000 + ciphertext = pw_encode(text, PASSWORD) + assert pw_decode(ciphertext, PASSWORD) == text + + +def test_pw_round_trip_special_characters(): + text = '!@#$%^&*()_+-=[]{}|;\':",./<>?`~\\' + ciphertext = pw_encode(text, PASSWORD) + assert pw_decode(ciphertext, PASSWORD) == text + + +def test_pw_round_trip_private_key_wif(): + # Simulate encrypting a real WIF-looking private key + wif = "KwDiBf89QgGbjEhKnhXJuH7LrciVrZi3qYjgd9M7rFU74NMTptX4" + ciphertext = pw_encode(wif, PASSWORD) + assert pw_decode(ciphertext, PASSWORD) == wif + + +# --- Wrong password / corrupted data --- + +def test_pw_decode_wrong_password_raises_value_error(): + ciphertext = pw_encode(PLAINTEXT, PASSWORD) + with pytest.raises(ValueError): + pw_decode(ciphertext, "wrong_password") + + +def test_pw_decode_wrong_password_error_message(): + ciphertext = pw_encode(PLAINTEXT, PASSWORD) + with pytest.raises(ValueError, match="Incorrect password or corrupted data"): + pw_decode(ciphertext, "wrong_password") + + +def test_pw_decode_empty_password_still_encrypts(): + # Empty password is technically valid; round-trip must work + ciphertext = pw_encode(PLAINTEXT, "") + assert pw_decode(ciphertext, "") == PLAINTEXT + + +def test_pw_decode_corrupted_not_base64_raises(): + with pytest.raises(ValueError): + pw_decode("!!!not_valid_base64!!!", PASSWORD) + + +def test_pw_decode_too_short_data_raises(): + # 15 bytes = less than an IV (16 bytes), cannot contain any ciphertext + short = base64.b64encode(b"x" * 15).decode() + with pytest.raises(ValueError): + pw_decode(short, PASSWORD) + + +def test_pw_decode_only_iv_no_ciphertext_raises(): + # Exactly 16 bytes: IV with zero ciphertext → unpadding must fail + iv_only = base64.b64encode(b"A" * 16).decode() + with pytest.raises(ValueError): + pw_decode(iv_only, PASSWORD) + + +# --- SecretScan integration note --- + +def test_crypto_print_secretscan(capsys): + """Document that encrypted wallet seeds can be verified via SecretScan after decryption.""" + ciphertext = pw_encode(PLAINTEXT, PASSWORD) + decrypted = pw_decode(ciphertext, PASSWORD) + # Once decrypted, the seed can be used to derive addresses verifiable on SecretScan + url = SECRETSCAN_URL.format("bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu") + print(f"\n[Crypto] Encrypted seed round-trip OK") + print(f"[Crypto] Derived addresses verifiable at: {url}") + captured = capsys.readouterr() + assert "secretscan.org" in captured.out + assert decrypted == PLAINTEXT diff --git a/tests/test_hd_wallet.py b/tests/test_hd_wallet.py index 0ef0b53..2ecd75a 100644 --- a/tests/test_hd_wallet.py +++ b/tests/test_hd_wallet.py @@ -320,6 +320,86 @@ def test_hd_decrypt_unencrypted_is_noop(): 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): diff --git a/tests/test_p2pk.py b/tests/test_p2pk.py index f983545..0aa983b 100644 --- a/tests/test_p2pk.py +++ b/tests/test_p2pk.py @@ -65,3 +65,32 @@ def test_p2pk_print_secretscan(capsys): print(f"[P2PK] SecretScan (search by pubkey): https://secretscan.org/") captured = capsys.readouterr() assert 'secretscan.org' in captured.out + + +# --- Error cases --- + +def test_p2pk_invalid_network_raises(): + import pytest + with pytest.raises(ValueError): + generate_p2pk('invalid_net') + + +# --- P2PK has no address field --- + +def test_p2pk_no_address_field(): + result = generate_p2pk('mainnet') + assert 'address' not in result + + +# --- Regtest WIF same as testnet --- + +def test_p2pk_wif_regtest_compressed_starts_c(): + result = generate_p2pk('regtest', compressed=True) + assert result['private_key_wif'][0] == 'c' + + +# --- Uncompressed WIF mainnet starts with 5 --- + +def test_p2pk_wif_mainnet_uncompressed_starts_5(): + result = generate_p2pk('mainnet', compressed=False) + assert result['private_key_wif'][0] == '5' diff --git a/tests/test_p2pkh.py b/tests/test_p2pkh.py index 1c05c51..40632dd 100644 --- a/tests/test_p2pkh.py +++ b/tests/test_p2pkh.py @@ -64,3 +64,45 @@ def test_p2pkh_print_secretscan(capsys): print(f"[P2PKH] Verify on SecretScan: {url}") captured = capsys.readouterr() assert 'secretscan.org' in captured.out + + +# --- WIF format validation --- + +def test_p2pkh_wif_mainnet_compressed_starts_k_or_l(): + result = generate_legacy_address('mainnet', compressed=True) + assert result['private_key_wif'][0] in ('K', 'L') + + +def test_p2pkh_wif_mainnet_uncompressed_starts_5(): + result = generate_legacy_address('mainnet', compressed=False) + assert result['private_key_wif'][0] == '5' + + +def test_p2pkh_wif_testnet_compressed_starts_c(): + result = generate_legacy_address('testnet', compressed=True) + assert result['private_key_wif'][0] == 'c' + + +def test_p2pkh_wif_testnet_uncompressed_starts_9(): + result = generate_legacy_address('testnet', compressed=False) + assert result['private_key_wif'][0] == '9' + + +# --- Error cases --- + +def test_p2pkh_invalid_network_raises(): + import pytest + with pytest.raises(ValueError): + generate_legacy_address('invalid_net') + + +# --- Address format for uncompressed keys --- + +def test_p2pkh_uncompressed_address_mainnet_starts_1(): + result = generate_legacy_address('mainnet', compressed=False) + assert result['address'].startswith('1') + + +def test_p2pkh_uncompressed_address_testnet(): + result = generate_legacy_address('testnet', compressed=False) + assert result['address'][0] in ('m', 'n') diff --git a/tests/test_p2sh.py b/tests/test_p2sh.py index b847330..67483ba 100644 --- a/tests/test_p2sh.py +++ b/tests/test_p2sh.py @@ -75,3 +75,91 @@ def test_p2sh_print_secretscan(capsys): print(f"[P2SH] Verify on SecretScan: {url}") captured = capsys.readouterr() assert 'secretscan.org' in captured.out + + +# --- Error cases --- + +def test_p2sh_invalid_network_raises(): + import pytest + with pytest.raises(ValueError): + generate_p2sh_multisig('invalid_net', m=2, n=3) + + +def test_p2sh_m_greater_than_n_raises(): + import pytest + with pytest.raises(ValueError): + generate_p2sh_multisig('mainnet', m=3, n=2) + + +def test_p2sh_m_zero_raises(): + import pytest + with pytest.raises(ValueError): + generate_p2sh_multisig('mainnet', m=0, n=2) + + +def test_p2sh_n_greater_than_16_raises(): + import pytest + with pytest.raises(ValueError): + generate_p2sh_multisig('mainnet', m=1, n=17) + + +# --- Redeem script structure --- + +def test_p2sh_redeem_script_is_valid_hex(): + result = generate_p2sh_multisig('mainnet', m=2, n=3) + hex_str = result['redeem_script_hex'] + assert all(c in '0123456789abcdef' for c in hex_str) + + +def test_p2sh_redeem_script_starts_with_op_m(): + # OP_2 = 0x52, OP_3 = 0x53 + result = generate_p2sh_multisig('mainnet', m=2, n=3) + redeem = bytes.fromhex(result['redeem_script_hex']) + assert redeem[0] == 0x52 # OP_2 + + +def test_p2sh_redeem_script_ends_with_op_checkmultisig(): + result = generate_p2sh_multisig('mainnet', m=2, n=3) + redeem = bytes.fromhex(result['redeem_script_hex']) + assert redeem[-1] == 0xAE # OP_CHECKMULTISIG + + +# --- Edge cases --- + +def test_p2sh_3of5(): + result = generate_p2sh_multisig('mainnet', m=3, n=5) + assert result['m'] == 3 + assert result['n'] == 5 + assert len(result['participants']) == 5 + + +def test_p2sh_1of16(): + result = generate_p2sh_multisig('mainnet', m=1, n=16) + assert result['m'] == 1 + assert result['n'] == 16 + assert len(result['participants']) == 16 + + +# --- BIP67: sorted vs unsorted pubkeys produce different redeem scripts --- + +def test_p2sh_sort_pubkeys_affects_redeem_script(): + import random + random.seed(42) + # Generate with and without sorting; addresses will differ + r_sorted = generate_p2sh_multisig('mainnet', m=2, n=3, sort_pubkeys=True) + r_unsorted = generate_p2sh_multisig('mainnet', m=2, n=3, sort_pubkeys=False) + # Both produce valid addresses + assert r_sorted['address'].startswith('3') + assert r_unsorted['address'].startswith('3') + + +def test_p2sh_compressed_pubkeys_33_bytes(): + result = generate_p2sh_multisig('mainnet', m=2, n=3, compressed=True) + for p in result['participants']: + assert len(bytes.fromhex(p['public_key_hex'])) == 33 + + +def test_p2sh_uncompressed_pubkeys_65_bytes(): + result = generate_p2sh_multisig('mainnet', m=2, n=3, compressed=False) + for p in result['participants']: + assert len(bytes.fromhex(p['public_key_hex'])) == 65 diff --git a/tests/test_p2tr.py b/tests/test_p2tr.py index b3044e3..ca97f3b 100644 --- a/tests/test_p2tr.py +++ b/tests/test_p2tr.py @@ -57,3 +57,51 @@ def test_p2tr_print_secretscan(capsys): print(f"[P2TR] Verify on SecretScan: {url}") captured = capsys.readouterr() assert 'secretscan.org' in captured.out + + +# --- Error cases --- + +def test_p2tr_invalid_network_raises(): + import pytest + with pytest.raises(ValueError): + generate_p2tr_address('invalid_net') + + +# --- Taproot tweak: output key differs from internal key --- + +def test_p2tr_tweaked_key_differs_from_internal(): + result = generate_p2tr_address('mainnet') + # The address encodes the tweaked output key (x-only). + # Derive internal key x-only from the result and verify they differ. + internal_x = result['internal_pubkey_x_hex'] + # Decode the bech32m address: find separator (last '1'), skip witness version + from src.p2tr import convertbits, _BECH32_CHARSET + addr = result['address'] + sep = addr.rfind('1') + data_chars = addr[sep + 1:] # data + checksum (6 chars) + decoded = [_BECH32_CHARSET.index(c) for c in data_chars[:-6]] # strip checksum + # decoded[0] = witness version (1 for Taproot), decoded[1:] = 32-byte prog in 5-bit groups + output_prog = bytes(convertbits(decoded[1:], 5, 8, False)) + output_x_hex = output_prog.hex() + # In key-path-only Taproot, the tweak is non-zero so output != internal + assert output_x_hex != internal_x + + +# --- WIF format --- + +def test_p2tr_wif_mainnet_compressed_starts_k_or_l(): + result = generate_p2tr_address('mainnet') + assert result['private_key_wif'][0] in ('K', 'L') + + +def test_p2tr_wif_testnet_starts_c(): + result = generate_p2tr_address('testnet') + assert result['private_key_wif'][0] == 'c' + + +# --- All networks produce correct network field --- + +def test_p2tr_all_networks(): + for network in NETWORKS: + result = generate_p2tr_address(network) + assert result['network'] == network diff --git a/tests/test_p2wpkh.py b/tests/test_p2wpkh.py index b3b0c13..dfe25db 100644 --- a/tests/test_p2wpkh.py +++ b/tests/test_p2wpkh.py @@ -58,3 +58,43 @@ def test_p2wpkh_print_secretscan(capsys): print(f"[P2WPKH] Verify on SecretScan: {url}") captured = capsys.readouterr() assert 'secretscan.org' in captured.out + + +# --- WIF format validation --- + +def test_p2wpkh_wif_mainnet_compressed_starts_k_or_l(): + result = generate_segwit_address('mainnet', compressed=True) + assert result['private_key_wif'][0] in ('K', 'L') + + +def test_p2wpkh_wif_testnet_compressed_starts_c(): + result = generate_segwit_address('testnet', compressed=True) + assert result['private_key_wif'][0] == 'c' + + +# --- Address length (bech32 native SegWit v0 is always 42 chars) --- + +def test_p2wpkh_address_length_mainnet(): + result = generate_segwit_address('mainnet') + assert len(result['address']) == 42 + + +def test_p2wpkh_address_length_testnet(): + result = generate_segwit_address('testnet') + assert len(result['address']) == 42 + + +# --- Error cases --- + +def test_p2wpkh_invalid_network_raises(): + import pytest + with pytest.raises(ValueError): + generate_segwit_address('invalid_net') + + +# --- All networks produce correct network field --- + +def test_p2wpkh_all_networks(): + for network in NETWORKS: + result = generate_segwit_address(network) + assert result['network'] == network diff --git a/tests/test_single_wallet.py b/tests/test_single_wallet.py new file mode 100644 index 0000000..3c219e3 --- /dev/null +++ b/tests/test_single_wallet.py @@ -0,0 +1,259 @@ +import sys +import os +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +import pytest +from src.single_wallet import encrypt_single_wallet, decrypt_single_wallet +from src.p2pkh import generate_legacy_address +from src.p2wpkh import generate_segwit_address +from src.p2tr import generate_p2tr_address +from src.p2pk import generate_p2pk + +SECRETSCAN_URL = "https://secretscan.org/Bitcoin?address={}" + +PASSWORD = "correct_horse_battery_staple" + + +# --- Fixtures --- + +def _p2pkh(): + return generate_legacy_address('mainnet') + +def _p2wpkh(): + return generate_segwit_address('mainnet') + +def _p2tr(): + return generate_p2tr_address('mainnet') + +def _p2pk(): + return generate_p2pk('mainnet', compressed=True) + + +# --- encrypt_single_wallet: output structure --- + +def test_encrypt_sets_use_encryption_flag(): + wallet = _p2pkh() + encrypted = encrypt_single_wallet(wallet, PASSWORD) + assert encrypted['use_encryption'] is True + + +def test_encrypt_returns_dict(): + wallet = _p2pkh() + result = encrypt_single_wallet(wallet, PASSWORD) + assert isinstance(result, dict) + + +# --- encrypt_single_wallet: sensitive fields are encrypted --- + +def test_encrypt_private_key_hex_changes(): + wallet = _p2pkh() + encrypted = encrypt_single_wallet(wallet, PASSWORD) + assert encrypted['private_key_hex'] != wallet['private_key_hex'] + + +def test_encrypt_private_key_wif_changes(): + wallet = _p2pkh() + encrypted = encrypt_single_wallet(wallet, PASSWORD) + assert encrypted['private_key_wif'] != wallet['private_key_wif'] + + +# --- encrypt_single_wallet: public fields are preserved --- + +def test_encrypt_preserves_address(): + wallet = _p2pkh() + encrypted = encrypt_single_wallet(wallet, PASSWORD) + assert encrypted['address'] == wallet['address'] + + +def test_encrypt_preserves_public_key_hex(): + wallet = _p2pkh() + encrypted = encrypt_single_wallet(wallet, PASSWORD) + assert encrypted['public_key_hex'] == wallet['public_key_hex'] + + +def test_encrypt_preserves_network(): + wallet = _p2pkh() + encrypted = encrypt_single_wallet(wallet, PASSWORD) + assert encrypted['network'] == wallet['network'] + + +def test_encrypt_preserves_script_type(): + wallet = _p2pkh() + encrypted = encrypt_single_wallet(wallet, PASSWORD) + assert encrypted['script_type'] == wallet['script_type'] + + +# --- encrypt_single_wallet: immutability --- + +def test_encrypt_does_not_mutate_original_private_key_hex(): + wallet = _p2pkh() + original_key = wallet['private_key_hex'] + encrypt_single_wallet(wallet, PASSWORD) + assert wallet['private_key_hex'] == original_key + + +def test_encrypt_does_not_mutate_original_private_key_wif(): + wallet = _p2pkh() + original_wif = wallet['private_key_wif'] + encrypt_single_wallet(wallet, PASSWORD) + assert wallet['private_key_wif'] == original_wif + + +def test_encrypt_does_not_set_flag_on_original(): + wallet = _p2pkh() + assert 'use_encryption' not in wallet + encrypt_single_wallet(wallet, PASSWORD) + assert 'use_encryption' not in wallet + + +# --- encrypt_single_wallet: error cases --- + +def test_encrypt_empty_password_raises_value_error(): + wallet = _p2pkh() + with pytest.raises(ValueError): + encrypt_single_wallet(wallet, "") + + +# --- decrypt_single_wallet: round-trips --- + +def test_decrypt_round_trip_private_key_hex(): + wallet = _p2pkh() + encrypted = encrypt_single_wallet(wallet, PASSWORD) + decrypted = decrypt_single_wallet(encrypted, PASSWORD) + assert decrypted['private_key_hex'] == wallet['private_key_hex'] + + +def test_decrypt_round_trip_private_key_wif(): + wallet = _p2pkh() + encrypted = encrypt_single_wallet(wallet, PASSWORD) + decrypted = decrypt_single_wallet(encrypted, PASSWORD) + assert decrypted['private_key_wif'] == wallet['private_key_wif'] + + +def test_decrypt_clears_use_encryption_flag(): + wallet = _p2pkh() + encrypted = encrypt_single_wallet(wallet, PASSWORD) + decrypted = decrypt_single_wallet(encrypted, PASSWORD) + assert decrypted['use_encryption'] is False + + +def test_decrypt_preserves_address(): + wallet = _p2pkh() + encrypted = encrypt_single_wallet(wallet, PASSWORD) + decrypted = decrypt_single_wallet(encrypted, PASSWORD) + assert decrypted['address'] == wallet['address'] + + +def test_decrypt_preserves_public_key(): + wallet = _p2pkh() + encrypted = encrypt_single_wallet(wallet, PASSWORD) + decrypted = decrypt_single_wallet(encrypted, PASSWORD) + assert decrypted['public_key_hex'] == wallet['public_key_hex'] + + +# --- decrypt_single_wallet: unencrypted wallet is a no-op --- + +def test_decrypt_unencrypted_wallet_noop(): + wallet = _p2pkh() + result = decrypt_single_wallet(wallet, PASSWORD) + assert result['private_key_hex'] == wallet['private_key_hex'] + + +def test_decrypt_unencrypted_wallet_returns_copy(): + wallet = _p2pkh() + result = decrypt_single_wallet(wallet, PASSWORD) + assert result is not wallet + + +# --- decrypt_single_wallet: immutability --- + +def test_decrypt_does_not_mutate_encrypted_wallet(): + wallet = _p2pkh() + encrypted = encrypt_single_wallet(wallet, PASSWORD) + original_enc_key = encrypted['private_key_hex'] + decrypt_single_wallet(encrypted, PASSWORD) + assert encrypted['private_key_hex'] == original_enc_key + + +# --- decrypt_single_wallet: error cases --- + +def test_decrypt_wrong_password_raises_value_error(): + wallet = _p2pkh() + encrypted = encrypt_single_wallet(wallet, PASSWORD) + with pytest.raises(ValueError): + decrypt_single_wallet(encrypted, "wrong_password") + + +def test_decrypt_empty_password_raises_value_error(): + wallet = _p2pkh() + encrypted = encrypt_single_wallet(wallet, PASSWORD) + with pytest.raises(ValueError): + decrypt_single_wallet(encrypted, "") + + +# --- Works with all wallet types --- + +def test_encrypt_decrypt_p2wpkh(): + wallet = _p2wpkh() + encrypted = encrypt_single_wallet(wallet, PASSWORD) + decrypted = decrypt_single_wallet(encrypted, PASSWORD) + assert decrypted['private_key_hex'] == wallet['private_key_hex'] + assert decrypted['address'] == wallet['address'] + + +def test_encrypt_decrypt_p2tr(): + wallet = _p2tr() + encrypted = encrypt_single_wallet(wallet, PASSWORD) + decrypted = decrypt_single_wallet(encrypted, PASSWORD) + assert decrypted['private_key_hex'] == wallet['private_key_hex'] + assert decrypted['address'] == wallet['address'] + + +def test_encrypt_decrypt_p2pk(): + # P2PK has no 'address' field; only private keys are sensitive + wallet = _p2pk() + encrypted = encrypt_single_wallet(wallet, PASSWORD) + decrypted = decrypt_single_wallet(encrypted, PASSWORD) + assert decrypted['private_key_hex'] == wallet['private_key_hex'] + assert decrypted['private_key_wif'] == wallet['private_key_wif'] + assert decrypted['public_key_hex'] == wallet['public_key_hex'] + + +def test_encrypt_all_networks(): + for network in ('mainnet', 'testnet', 'regtest'): + wallet = generate_legacy_address(network) + encrypted = encrypt_single_wallet(wallet, PASSWORD) + decrypted = decrypt_single_wallet(encrypted, PASSWORD) + assert decrypted['private_key_hex'] == wallet['private_key_hex'] + + +# --- Two different passwords produce different ciphertext --- + +def test_different_passwords_different_ciphertext(): + wallet = _p2pkh() + enc1 = encrypt_single_wallet(wallet, "password_one") + enc2 = encrypt_single_wallet(wallet, "password_two") + assert enc1['private_key_hex'] != enc2['private_key_hex'] + + +# --- SecretScan --- + +def test_single_wallet_print_secretscan(capsys): + wallet = _p2pkh() + encrypted = encrypt_single_wallet(wallet, PASSWORD) + decrypted = decrypt_single_wallet(encrypted, PASSWORD) + url = SECRETSCAN_URL.format(wallet['address']) + print(f"\n[SingleWallet] P2PKH Address: {wallet['address']}") + print(f"[SingleWallet] Verify on SecretScan: {url}") + captured = capsys.readouterr() + assert 'secretscan.org' in captured.out + assert decrypted['private_key_hex'] == wallet['private_key_hex'] + + +def test_single_wallet_p2wpkh_print_secretscan(capsys): + wallet = _p2wpkh() + url = SECRETSCAN_URL.format(wallet['address']) + print(f"\n[SingleWallet] P2WPKH Address: {wallet['address']}") + print(f"[SingleWallet] Verify on SecretScan: {url}") + captured = capsys.readouterr() + assert 'secretscan.org' in captured.out