diff --git a/bitcoinutils/address.py b/bitcoinutils/address.py new file mode 100644 index 0000000..0328f7f --- /dev/null +++ b/bitcoinutils/address.py @@ -0,0 +1,367 @@ +# Copyright (C) 2018-2025 The python-bitcoin-utils developers +# +# This file is part of python-bitcoin-utils +# +# It is subject to the license terms in the LICENSE file found in the top-level +# directory of this distribution. +# +# No part of python-bitcoin-utils, including this file, may be copied, modified, +# propagated, or distributed except according to the terms contained in the +# LICENSE file. + +from typing import Optional, Union, Literal, Type, Any + +from bitcoinutils.constants import ( + P2PKH_ADDRESS, P2SH_ADDRESS, P2WPKH_ADDRESS_V0, P2WSH_ADDRESS_V0, P2TR_ADDRESS_V1 +) +from bitcoinutils.keys import ( + Address, P2pkhAddress, P2shAddress, SegwitAddress, P2wpkhAddress, P2wshAddress, P2trAddress +) +from bitcoinutils.script import Script + +AddressType = Literal[ + "p2pkh", "p2sh", "p2wpkhv0", "p2wshv0", "p2trv1" +] + +AddressClass = Union[ + Type[P2pkhAddress], Type[P2shAddress], Type[P2wpkhAddress], Type[P2wshAddress], Type[P2trAddress] +] + +AddressInstance = Union[ + P2pkhAddress, P2shAddress, P2wpkhAddress, P2wshAddress, P2trAddress +] + + +class UnifiedAddress: + """Unified Bitcoin address class that can handle all address types and conversions. + + This class wraps the existing address classes and provides conversion methods + between different address types. + + Attributes + ---------- + address : AddressInstance + The wrapped address object + address_type : str + The type of address (p2pkh, p2sh, p2wpkhv0, p2wshv0, p2trv1) + + Methods + ------- + from_address(address_str, address_type=None) + Creates a UnifiedAddress from an address string (classmethod) + from_script(script, address_type="p2sh") + Creates a UnifiedAddress from a script (classmethod) + from_hash160(hash_str, address_type="p2pkh") + Creates a UnifiedAddress from a hash160 string (classmethod) + from_witness_program(witness_program, address_type="p2wpkhv0") + Creates a UnifiedAddress from a witness program (classmethod) + to_script_pub_key() + Returns the script pubkey for the address + to_address_type(address_type) + Converts the address to a different type if possible + to_string() + Returns the address as a string + """ + + def __init__(self, address: AddressInstance): + """Initialize with an existing address object. + + Parameters + ---------- + address : AddressInstance + An instance of one of the address classes + """ + self.address = address + self.address_type = address.get_type() + + @classmethod + def from_address(cls, address_str: str, address_type: Optional[AddressType] = None) -> 'UnifiedAddress': + """Create a UnifiedAddress from an address string. + + Parameters + ---------- + address_str : str + The address string + address_type : str, optional + The type of address if known (otherwise will be auto-detected) + + Returns + ------- + UnifiedAddress + A new UnifiedAddress object + + Raises + ------ + ValueError + If the address is invalid + """ + # Auto-detect address type if not provided + if address_type is None: + address_type = cls._detect_address_type(address_str) + + # Create address object based on type + if address_type == P2PKH_ADDRESS: + address = P2pkhAddress(address=address_str) + elif address_type == P2SH_ADDRESS: + address = P2shAddress(address=address_str) + elif address_type == P2WPKH_ADDRESS_V0: + address = P2wpkhAddress(address=address_str) + elif address_type == P2WSH_ADDRESS_V0: + address = P2wshAddress(address=address_str) + elif address_type == P2TR_ADDRESS_V1: + address = P2trAddress(address=address_str) + else: + raise ValueError(f"Unsupported address type: {address_type}") + + return cls(address) + + @classmethod + def from_script(cls, script: Script, address_type: AddressType = P2SH_ADDRESS) -> 'UnifiedAddress': + """Create a UnifiedAddress from a script. + + Parameters + ---------- + script : Script + The script + address_type : str, optional + The type of address to create (default is P2SH) + + Returns + ------- + UnifiedAddress + A new UnifiedAddress object + + Raises + ------ + ValueError + If the address type is not supported for scripts + """ + if address_type == P2SH_ADDRESS: + address = P2shAddress(script=script) + elif address_type == P2WSH_ADDRESS_V0: + address = P2wshAddress(script=script) + else: + raise ValueError(f"Cannot create {address_type} directly from script") + + return cls(address) + + @classmethod + def from_hash160(cls, hash_str: str, address_type: AddressType = P2PKH_ADDRESS) -> 'UnifiedAddress': + """Create a UnifiedAddress from a hash160 string. + + Parameters + ---------- + hash_str : str + The hash160 hex string + address_type : str, optional + The type of address to create (default is P2PKH) + + Returns + ------- + UnifiedAddress + A new UnifiedAddress object + + Raises + ------ + ValueError + If the address type is not supported for hash160 + """ + if address_type == P2PKH_ADDRESS: + address = P2pkhAddress(hash160=hash_str) + elif address_type == P2SH_ADDRESS: + address = P2shAddress(hash160=hash_str) + else: + raise ValueError(f"Cannot create {address_type} directly from hash160") + + return cls(address) + + @classmethod + def from_witness_program(cls, witness_program: str, address_type: AddressType = P2WPKH_ADDRESS_V0) -> 'UnifiedAddress': + """Create a UnifiedAddress from a witness program. + + Parameters + ---------- + witness_program : str + The witness program hex string + address_type : str, optional + The type of address to create (default is P2WPKH) + + Returns + ------- + UnifiedAddress + A new UnifiedAddress object + + Raises + ------ + ValueError + If the address type is not supported for witness program + """ + if address_type == P2WPKH_ADDRESS_V0: + address = P2wpkhAddress(witness_program=witness_program) + elif address_type == P2WSH_ADDRESS_V0: + address = P2wshAddress(witness_program=witness_program) + elif address_type == P2TR_ADDRESS_V1: + address = P2trAddress(witness_program=witness_program) + else: + raise ValueError(f"Cannot create {address_type} from witness program") + + return cls(address) + + @staticmethod + def _detect_address_type(address_str: str) -> AddressType: + """Detect the address type from an address string. + + Parameters + ---------- + address_str : str + The address string + + Returns + ------- + str + The detected address type + + Raises + ------ + ValueError + If the address type cannot be detected + """ + # Try each address type until one works + try: + # Try P2PKH + P2pkhAddress(address=address_str) + return P2PKH_ADDRESS + except ValueError: + pass + + try: + # Try P2SH + P2shAddress(address=address_str) + return P2SH_ADDRESS + except ValueError: + pass + + try: + # Try P2WPKH + P2wpkhAddress(address=address_str) + return P2WPKH_ADDRESS_V0 + except (ValueError, TypeError): + pass + + try: + # Try P2WSH + P2wshAddress(address=address_str) + return P2WSH_ADDRESS_V0 + except (ValueError, TypeError): + pass + + try: + # Try P2TR + P2trAddress(address=address_str) + return P2TR_ADDRESS_V1 + except (ValueError, TypeError): + pass + + raise ValueError(f"Could not detect address type for {address_str}") + + def to_script_pub_key(self) -> Script: + """Get the scriptPubKey for this address. + + Returns + ------- + Script + The scriptPubKey for this address + """ + return self.address.to_script_pub_key() + + def to_address_type(self, address_type: AddressType) -> 'UnifiedAddress': + """Convert the address to a different type if possible. + + Parameters + ---------- + address_type : str + The target address type + + Returns + ------- + UnifiedAddress + A new UnifiedAddress object of the requested type + + Raises + ------ + ValueError + If conversion to the requested type is not possible + """ + # If already the requested type, return self + if self.address_type == address_type: + return self + + # P2PKH -> P2WPKH, P2SH-P2WPKH conversions + if self.address_type == P2PKH_ADDRESS: + # Extract the hash160 + hash160 = self.address.to_hash160() + + if address_type == P2WPKH_ADDRESS_V0: + # P2PKH -> P2WPKH + address = P2wpkhAddress(witness_program=hash160) + return UnifiedAddress(address) + + elif address_type == P2SH_ADDRESS: + # P2PKH -> P2SH-P2WPKH (nested SegWit) + # Create P2WPKH scriptPubKey + p2wpkh_script = Script(['OP_0', hash160]) + # Create P2SH address from that script + address = P2shAddress(script=p2wpkh_script) + return UnifiedAddress(address) + + # P2SH -> P2WSH (only if it's a nested SegWit) + # This is a limited case and generally requires knowing the redeem script + + # P2WPKH -> P2PKH, P2SH-P2WPKH + if self.address_type == P2WPKH_ADDRESS_V0: + # Extract the witness program + witness_program = self.address.to_witness_program() + + if address_type == P2PKH_ADDRESS: + # P2WPKH -> P2PKH + address = P2pkhAddress(hash160=witness_program) + return UnifiedAddress(address) + + elif address_type == P2SH_ADDRESS: + # P2WPKH -> P2SH-P2WPKH (nested SegWit) + p2wpkh_script = Script(['OP_0', witness_program]) + address = P2shAddress(script=p2wpkh_script) + return UnifiedAddress(address) + + # P2WSH -> P2SH-P2WSH + if self.address_type == P2WSH_ADDRESS_V0 and address_type == P2SH_ADDRESS: + witness_program = self.address.to_witness_program() + p2wsh_script = Script(['OP_0', witness_program]) + address = P2shAddress(script=p2wsh_script) + return UnifiedAddress(address) + + # No other direct conversions are possible without additional data + raise ValueError(f"Cannot convert from {self.address_type} to {address_type}") + + def to_string(self) -> str: + """Get the address as a string. + + Returns + ------- + str + The address string + """ + return self.address.to_string() + + def __str__(self) -> str: + return self.to_string() + + def __repr__(self) -> str: + return f"UnifiedAddress('{self.to_string()}', '{self.address_type}')" + + def __eq__(self, other: Any) -> bool: + if isinstance(other, UnifiedAddress): + return self.to_string() == other.to_string() + elif isinstance(other, str): + return self.to_string() == other + return False \ No newline at end of file diff --git a/examples/unified_address.py b/examples/unified_address.py new file mode 100644 index 0000000..aed0389 --- /dev/null +++ b/examples/unified_address.py @@ -0,0 +1,96 @@ +# Copyright (C) 2018-2025 The python-bitcoin-utils developers +# +# This file is part of python-bitcoin-utils +# +# It is subject to the license terms in the LICENSE file found in the top-level +# directory of this distribution. +# +# No part of python-bitcoin-utils, including this file, may be copied, modified, +# propagated, or distributed except according to the terms contained in the +# LICENSE file. + +from bitcoinutils.setup import setup +from bitcoinutils.keys import PrivateKey +from bitcoinutils.address import UnifiedAddress +from bitcoinutils.constants import P2PKH_ADDRESS, P2WPKH_ADDRESS_V0, P2SH_ADDRESS, P2TR_ADDRESS_V1 + +def main(): + # always remember to setup the network + setup('testnet') + + # create a private key (deterministically) + priv = PrivateKey(secret_exponent=1) + print("\nPrivate key WIF:", priv.to_wif()) + + # get the public key + pub = priv.get_public_key() + print("Public key:", pub.to_hex()) + + # create different address types from the public key + p2pkh_addr = pub.get_address() + p2wpkh_addr = pub.get_segwit_address() + p2tr_addr = pub.get_taproot_address() + + print("\n--- Original Addresses ---") + print(f"P2PKH Address: {p2pkh_addr.to_string()}") + print(f"P2WPKH Address: {p2wpkh_addr.to_string()}") + print(f"P2TR Address: {p2tr_addr.to_string()}") + + # Create unified addresses from existing addresses + unified_p2pkh = UnifiedAddress(p2pkh_addr) + unified_p2wpkh = UnifiedAddress(p2wpkh_addr) + unified_p2tr = UnifiedAddress(p2tr_addr) + + print("\n--- Unified Address Creation ---") + print(f"From P2PKH: {unified_p2pkh}") + print(f"From P2WPKH: {unified_p2wpkh}") + print(f"From P2TR: {unified_p2tr}") + + # Create from address strings + print("\n--- Create from Address Strings ---") + unified_from_str = UnifiedAddress.from_address(p2pkh_addr.to_string()) + print(f"Detected type: {unified_from_str.address_type}") + print(f"Address: {unified_from_str.to_string()}") + + # Address conversion + print("\n--- Address Conversion ---") + + # P2PKH to P2WPKH + p2wpkh_converted = unified_p2pkh.to_address_type(P2WPKH_ADDRESS_V0) + print(f"P2PKH to P2WPKH: {p2wpkh_converted}") + + # P2PKH to P2SH-P2WPKH (nested SegWit) + p2sh_p2wpkh = unified_p2pkh.to_address_type(P2SH_ADDRESS) + print(f"P2PKH to P2SH-P2WPKH: {p2sh_p2wpkh}") + + # P2WPKH to P2PKH + p2pkh_converted = unified_p2wpkh.to_address_type(P2PKH_ADDRESS) + print(f"P2WPKH to P2PKH: {p2pkh_converted}") + + print("\n--- Invalid Conversions ---") + try: + # P2PKH to P2TR (invalid conversion) + unified_p2pkh.to_address_type(P2TR_ADDRESS_V1) + except ValueError as e: + print(f"P2PKH to P2TR error: {e}") + + try: + # P2TR to P2PKH (invalid conversion) + unified_p2tr.to_address_type(P2PKH_ADDRESS) + except ValueError as e: + print(f"P2TR to P2PKH error: {e}") + + # Script Pub Key access + print("\n--- Script Pub Key Access ---") + p2pkh_script = unified_p2pkh.to_script_pub_key() + print(f"P2PKH Script: {p2pkh_script}") + + p2wpkh_script = unified_p2wpkh.to_script_pub_key() + print(f"P2WPKH Script: {p2wpkh_script}") + + p2tr_script = unified_p2tr.to_script_pub_key() + print(f"P2TR Script: {p2tr_script}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tests/test_unified_address.py b/tests/test_unified_address.py new file mode 100644 index 0000000..13b062a --- /dev/null +++ b/tests/test_unified_address.py @@ -0,0 +1,143 @@ +# Copyright (C) 2018-2025 The python-bitcoin-utils developers +# +# This file is part of python-bitcoin-utils +# +# It is subject to the license terms in the LICENSE file found in the top-level +# directory of this distribution. +# +# No part of python-bitcoin-utils, including this file, may be copied, modified, +# propagated, or distributed except according to the terms contained in the +# LICENSE file. + +import unittest + +from bitcoinutils.setup import setup +from bitcoinutils.address import UnifiedAddress +from bitcoinutils.keys import PrivateKey, P2pkhAddress, P2shAddress, P2wpkhAddress, P2trAddress +from bitcoinutils.script import Script +from bitcoinutils.constants import ( + P2PKH_ADDRESS, P2SH_ADDRESS, P2WPKH_ADDRESS_V0, P2WSH_ADDRESS_V0, P2TR_ADDRESS_V1 +) + +class TestUnifiedAddress(unittest.TestCase): + @classmethod + def setUpClass(cls): + setup('testnet') + + # Create keys for testing + cls.private_key = PrivateKey.from_wif('cTALNpTpRbbxTCJ2A5Vq88UxT44w1PE2cYqiB3n4hRvzyCev1Wwo') + cls.public_key = cls.private_key.get_public_key() + + # Create sample addresses for testing + cls.p2pkh_address = cls.public_key.get_address() + cls.p2wpkh_address = cls.public_key.get_segwit_address() + cls.p2tr_address = cls.public_key.get_taproot_address() + + # Create a test script + cls.script = Script(['OP_2', cls.public_key.to_hex(), cls.public_key.to_hex(), 'OP_2', 'OP_CHECKMULTISIG']) + cls.p2sh_address = P2shAddress(script=cls.script) + + def test_create_from_string(self): + # Test creation from P2PKH address + p2pkh_address_str = self.p2pkh_address.to_string() + unified_p2pkh = UnifiedAddress.from_address(p2pkh_address_str) + self.assertEqual(unified_p2pkh.address_type, P2PKH_ADDRESS) + self.assertEqual(unified_p2pkh.to_string(), p2pkh_address_str) + + # Test creation from P2SH address + p2sh_address_str = self.p2sh_address.to_string() + unified_p2sh = UnifiedAddress.from_address(p2sh_address_str) + self.assertEqual(unified_p2sh.address_type, P2SH_ADDRESS) + self.assertEqual(unified_p2sh.to_string(), p2sh_address_str) + + # Test creation from P2WPKH address + p2wpkh_address_str = self.p2wpkh_address.to_string() + unified_p2wpkh = UnifiedAddress.from_address(p2wpkh_address_str) + self.assertEqual(unified_p2wpkh.address_type, P2WPKH_ADDRESS_V0) + self.assertEqual(unified_p2wpkh.to_string(), p2wpkh_address_str) + + # Test creation from P2TR address + p2tr_address_str = self.p2tr_address.to_string() + unified_p2tr = UnifiedAddress.from_address(p2tr_address_str) + self.assertEqual(unified_p2tr.address_type, P2TR_ADDRESS_V1) + self.assertEqual(unified_p2tr.to_string(), p2tr_address_str) + + def test_create_from_script(self): + # Test P2SH from script + unified_p2sh = UnifiedAddress.from_script(self.script, P2SH_ADDRESS) + self.assertEqual(unified_p2sh.address_type, P2SH_ADDRESS) + self.assertEqual(unified_p2sh.to_string(), self.p2sh_address.to_string()) + + def test_create_from_hash160(self): + # Test P2PKH from hash160 + hash160 = self.p2pkh_address.to_hash160() + unified_p2pkh = UnifiedAddress.from_hash160(hash160, P2PKH_ADDRESS) + self.assertEqual(unified_p2pkh.address_type, P2PKH_ADDRESS) + self.assertEqual(unified_p2pkh.to_string(), self.p2pkh_address.to_string()) + + def test_create_from_witness_program(self): + # Test P2WPKH from witness program + witness_program = self.p2wpkh_address.to_witness_program() + unified_p2wpkh = UnifiedAddress.from_witness_program(witness_program, P2WPKH_ADDRESS_V0) + self.assertEqual(unified_p2wpkh.address_type, P2WPKH_ADDRESS_V0) + self.assertEqual(unified_p2wpkh.to_string(), self.p2wpkh_address.to_string()) + + # Test P2TR from witness program + witness_program = self.p2tr_address.to_witness_program() + unified_p2tr = UnifiedAddress.from_witness_program(witness_program, P2TR_ADDRESS_V1) + self.assertEqual(unified_p2tr.address_type, P2TR_ADDRESS_V1) + self.assertEqual(unified_p2tr.to_string(), self.p2tr_address.to_string()) + + def test_to_script_pub_key(self): + # Test P2PKH script pub key + unified_p2pkh = UnifiedAddress(self.p2pkh_address) + script_pub_key = unified_p2pkh.to_script_pub_key() + self.assertEqual(script_pub_key.to_hex(), self.p2pkh_address.to_script_pub_key().to_hex()) + + def test_address_type_conversion(self): + # Test P2PKH to P2WPKH conversion + unified_p2pkh = UnifiedAddress(self.p2pkh_address) + unified_p2wpkh = unified_p2pkh.to_address_type(P2WPKH_ADDRESS_V0) + self.assertEqual(unified_p2wpkh.address_type, P2WPKH_ADDRESS_V0) + + # The hash160 of both addresses should be the same + p2pkh_hash160 = self.p2pkh_address.to_hash160() + p2wpkh_wit_prog = unified_p2wpkh.address.to_witness_program() + self.assertEqual(p2pkh_hash160, p2wpkh_wit_prog) + + # Test P2PKH to P2SH-P2WPKH conversion (nested SegWit) + unified_p2sh_p2wpkh = unified_p2pkh.to_address_type(P2SH_ADDRESS) + self.assertEqual(unified_p2sh_p2wpkh.address_type, P2SH_ADDRESS) + + # Test P2WPKH to P2PKH conversion + unified_p2wpkh = UnifiedAddress(self.p2wpkh_address) + unified_p2pkh_back = unified_p2wpkh.to_address_type(P2PKH_ADDRESS) + self.assertEqual(unified_p2pkh_back.address_type, P2PKH_ADDRESS) + self.assertEqual(unified_p2pkh_back.to_string(), unified_p2pkh.to_string()) + + def test_invalid_conversions(self): + # Test invalid conversion: P2PKH to P2TR + unified_p2pkh = UnifiedAddress(self.p2pkh_address) + with self.assertRaises(ValueError): + unified_p2pkh.to_address_type(P2TR_ADDRESS_V1) + + # Test invalid conversion: P2TR to P2PKH + unified_p2tr = UnifiedAddress(self.p2tr_address) + with self.assertRaises(ValueError): + unified_p2tr.to_address_type(P2PKH_ADDRESS) + + def test_equality(self): + # Test equality between UnifiedAddress objects + unified_p2pkh1 = UnifiedAddress(self.p2pkh_address) + unified_p2pkh2 = UnifiedAddress.from_address(self.p2pkh_address.to_string()) + self.assertEqual(unified_p2pkh1, unified_p2pkh2) + + # Test equality with string + self.assertEqual(unified_p2pkh1, self.p2pkh_address.to_string()) + + # Test inequality + unified_p2wpkh = UnifiedAddress(self.p2wpkh_address) + self.assertNotEqual(unified_p2pkh1, unified_p2wpkh) + +if __name__ == '__main__': + unittest.main() \ No newline at end of file