diff --git a/src/hiero_sdk_python/crypto/evm_address.py b/src/hiero_sdk_python/crypto/evm_address.py index b776776b9..2b60e2488 100644 --- a/src/hiero_sdk_python/crypto/evm_address.py +++ b/src/hiero_sdk_python/crypto/evm_address.py @@ -1,6 +1,7 @@ from __future__ import annotations from hiero_sdk_python.crypto.key import Key +from hiero_sdk_python.utils.crypto_utils import keccak256 class EvmAddress(Key): @@ -49,6 +50,60 @@ def to_string(self) -> str: def __str__(self) -> str: return self.to_string() + def to_checksum_address(self) -> str: + """Return the EIP-55 checksum address. + Reference: https://eips.ethereum.org/EIPS/eip-55 + """ + lower_address = self.to_string().lower() + + address_hash = keccak256(lower_address.encode("ascii")).hex() + + checksummed = "".join( + char.upper() if char.isalpha() and int(address_hash[index], 16) >= 8 else char + for index, char in enumerate(lower_address) + ) + + return f"0x{checksummed}" + + @staticmethod + def normalize(address: str) -> str: + """Normalize an EVM address to 40 lowercase hex characters without ``0x``.""" + if not isinstance(address, str): + raise TypeError("address must be a string") + addr = address.lower() + if addr.startswith("0x"): + addr = addr[2:] + + if len(addr) != 40 or not all(c in "0123456789abcdef" for c in addr): + raise ValueError("Invalid address") + + return addr + + @staticmethod + def is_valid(address: str) -> bool: + """Return ``True`` when ``address`` is valid lowercase/uppercase hex EVM address.""" + if not isinstance(address, str): + return False + + addr = address.lower() + if addr.startswith("0x"): + addr = addr[2:] + return len(addr) == 40 and all(c in "0123456789abcdef" for c in addr) + + @staticmethod + def is_checksum_valid(address: str) -> bool: + """Return ``True`` only if ``address`` is ``0x``-prefixed and EIP-55 checksummed.""" + if not isinstance(address, str) or not address.startswith("0x"): + return False + + raw = address[2:] + + if not EvmAddress.is_valid(raw): + return False + + checksummed = EvmAddress.from_string(raw).to_checksum_address() + return checksummed == address + def __repr__(self) -> str: return f"" diff --git a/tests/unit/evm_address_test.py b/tests/unit/evm_address_test.py index 1bdfceaa1..c863ca97a 100644 --- a/tests/unit/evm_address_test.py +++ b/tests/unit/evm_address_test.py @@ -10,6 +10,46 @@ pytestmark = pytest.mark.unit +def test_is_valid_accepts_valid_address(): + """Test validation accepts a valid EVM address.""" + assert EvmAddress.is_valid("0x52908400098527886E0F7030069857D2E4169EE7") + + +def test_is_valid_rejects_non_string(): + """Test validation rejects non-string inputs.""" + assert not EvmAddress.is_valid(123) + + +def test_is_checksum_valid_rejects_missing_prefix(): + """Test checksum validation rejects addresses without ``0x`` prefix.""" + assert not EvmAddress.is_checksum_valid("52908400098527886E0F7030069857D2E4169EE7") + + +def test_is_checksum_valid_rejects_invalid_length(): + """Test checksum validation rejects addresses with invalid length.""" + assert not EvmAddress.is_checksum_valid("0x123") + + +def test_is_checksum_valid_rejects_non_hex(): + """Test checksum validation rejects non-hexadecimal addresses.""" + assert not EvmAddress.is_checksum_valid("0xZZZZ8400098527886E0F7030069857D2E4169EE7") + + +def test_is_valid_accepts_lowercase_without_prefix(): + """Test validation accepts lowercase addresses without ``0x``.""" + assert EvmAddress.is_valid("52908400098527886e0f7030069857d2e4169ee7") + + +def test_is_valid_rejects_invalid_length(): + """Test validation rejects addresses with invalid length.""" + assert not EvmAddress.is_valid("123") + + +def test_is_checksum_valid_rejects_non_string(): + """Test checksum validation rejects non-string inputs.""" + assert not EvmAddress.is_checksum_valid(123) + + def test_from_string_without_prefix(): """Test creating EvmAddress from valid 40-character hex string.""" hex_str = "1234567890abcdef1234567890abcdef12345678" @@ -74,3 +114,51 @@ def test_to_proto_key(): with pytest.raises(RuntimeError, match=re.escape("to_proto_key() not implemented for EvmAddress")): address.to_proto_key() + + +def test_to_checksum_address_matches_eip55_reference(): + """Test checksum formatting follows EIP-55.""" + addr = EvmAddress.from_string("0x52908400098527886e0f7030069857d2e4169ee7") + + assert addr.to_checksum_address() == "0x52908400098527886E0F7030069857D2E4169EE7" + + +def test_to_checksum_address_is_stable_for_lowercase_input(): + """Test checksum formatting works for all-lower input.""" + addr = EvmAddress.from_string("de709f2102306220921060314715629080e2fb77") + + assert addr.to_checksum_address() == "0xde709f2102306220921060314715629080e2fb77" + + +def test_checksum_from_mixed_case(): + """Test checksum generation normalizes mixed-case input.""" + addr = EvmAddress.from_string("52908400098527886E0f7030069857d2e4169ee7") + + assert addr.to_checksum_address() == "0x52908400098527886E0F7030069857D2E4169EE7" + + +def test_is_checksum_valid(): + """Test checksum validation accepts valid EIP-55 addresses.""" + assert EvmAddress.is_checksum_valid("0x52908400098527886E0F7030069857D2E4169EE7") + + +def test_invalid_checksum(): + """Test checksum validation rejects incorrectly cased addresses.""" + assert not EvmAddress.is_checksum_valid("0x52908400098527886e0f7030069857d2e4169ee7") + + +def test_invalid_embedded_prefix(): + """Test embedded ``0x`` substrings are rejected as invalid addresses.""" + bad = "52908400098527886e0f70300698" + "0x" + "57d2e4169ee7" + + assert not EvmAddress.is_valid(bad) + + with pytest.raises(ValueError): + EvmAddress.normalize(bad) + + +def test_normalize(): + """Test normalization returns lowercase address without ``0x`` prefix.""" + assert ( + EvmAddress.normalize("0x52908400098527886E0F7030069857D2E4169EE7") == "52908400098527886e0f7030069857d2e4169ee7" + )