From 0264f701cda81945227cb5693e0e47e911d9a907 Mon Sep 17 00:00:00 2001 From: Yash Date: Mon, 1 Jun 2026 16:42:36 +0200 Subject: [PATCH] feat(ens): add address_bytes() method for raw multichain address resolution Introduces the address_bytes() method in both ENS and AsyncENS classes to retrieve raw address bytes per ENSIP-9 without EIP-55 encoding. This allows for proper handling of non-Ethereum coin types. The existing address() method is updated to delegate to address_bytes() for coin type lookups. Tests are added to ensure functionality for various coin types, including Bitcoin. --- docs/ens_overview.rst | 7 ++++ ens/async_ens.py | 75 +++++++++++++++++++++++++++------- ens/ens.py | 70 +++++++++++++++++++++++++------ ens/utils.py | 19 +++++++++ newsfragments/3854.feature.rst | 1 + tests/ens/test_ens.py | 68 ++++++++++++++++++++++++++++++ 6 files changed, 212 insertions(+), 28 deletions(-) create mode 100644 newsfragments/3854.feature.rst diff --git a/docs/ens_overview.rst b/docs/ens_overview.rst index 5a9dcd0fae..9bf533b8b7 100644 --- a/docs/ens_overview.rst +++ b/docs/ens_overview.rst @@ -170,6 +170,13 @@ the ``coin_type`` keyword argument. eth_address = ns.address('ens.eth', coin_type=60) # ETH is coin_type 60 assert eth_address == '0xFe89cc7aBB2C4183683ab71653C4cdc9B02D44b7' +For non-Ethereum coin types, use :meth:`~ens.ENS.address_bytes` to obtain the +raw ENSIP-9 binary record from the resolver, then encode it with a chain-specific +library (for example, `ensdomains/address-encoder +`_). :meth:`~ens.ENS.address` +applies EIP-55 checksumming and is only appropriate for 20-byte Ethereum +records. + Get the ENS Name for an Address ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/ens/async_ens.py b/ens/async_ens.py index 8a845da92e..3cb3c915d9 100644 --- a/ens/async_ens.py +++ b/ens/async_ens.py @@ -58,6 +58,7 @@ normal_name_to_hash, normalize_name, raw_name_to_hash, + resolved_address_to_bytes, ) if TYPE_CHECKING: @@ -147,13 +148,24 @@ def from_web3( return ns - async def address( + async def address_bytes( self, name: str, coin_type: int | None = None, - ) -> ChecksumAddress | None: + ) -> bytes | None: """ - Look up the Ethereum address that `name` currently points to. + Look up the raw address bytes that `name` points to. + + Returns the ENSIP-9 native binary encoding from the resolver without + converting to a checksummed hex string. For Ethereum (``coin_type=60`` + or the default ``addr(node)`` record), this is typically 20 bytes. For + other chains (for example Bitcoin or Solana), byte length and format + follow `ENSIP-9 `_. + + Use :meth:`address` when you need an EIP-55 checksummed Ethereum address. + For other coin types, encode the returned bytes with a chain-specific + library such as `ensdomains/address-encoder + `_. :param str name: an ENS name to look up :param int coin_type: if provided, look up the address for this coin type @@ -164,27 +176,60 @@ async def address( ) if coin_type is None: - return cast(ChecksumAddress, await self._resolve(name, "addr")) - else: - node = raw_name_to_hash(name) normal_name = normalize_name(name) + node = self.namehash(normal_name) dns_name = dns_encode_name(normal_name) - calldata = self._resolver_contract.encode_abi( - "addr", args=[node, coin_type] - ) + calldata = self._resolver_contract.encode_abi("addr", args=[node]) try: result, resolver_addr = await self._universal_resolver.caller.resolve( dns_name, calldata ) except ContractLogicError: return None - if not result: + if not result or result == b"": return None - decoded = self.w3.codec.decode(["bytes"], result) - address_as_bytes = decoded[0] - if is_none_or_zero_address(address_as_bytes): - return None - return to_checksum_address(address_as_bytes) + decoded_result = self._decode_ensip10_resolve_data( + result, self._resolver_contract, "addr" + ) + return resolved_address_to_bytes(decoded_result) + + node = raw_name_to_hash(name) + normal_name = normalize_name(name) + dns_name = dns_encode_name(normal_name) + calldata = self._resolver_contract.encode_abi("addr", args=[node, coin_type]) + try: + result, resolver_addr = await self._universal_resolver.caller.resolve( + dns_name, calldata + ) + except ContractLogicError: + return None + if not result: + return None + decoded = self.w3.codec.decode(["bytes"], result) + address_as_bytes = decoded[0] + if is_none_or_zero_address(address_as_bytes): + return None + return address_as_bytes + + async def address( + self, + name: str, + coin_type: int | None = None, + ) -> ChecksumAddress | None: + """ + Look up the Ethereum address that `name` currently points to. + + :param str name: an ENS name to look up + :param int coin_type: if provided, look up the address for this coin type + :raises InvalidName: if `name` has invalid syntax + """ + if coin_type is None: + return cast(ChecksumAddress, await self._resolve(name, "addr")) + + address_as_bytes = await self.address_bytes(name, coin_type=coin_type) + if address_as_bytes is None: + return None + return to_checksum_address(address_as_bytes) async def setup_address( self, diff --git a/ens/ens.py b/ens/ens.py index ad9605c881..59e78ef157 100644 --- a/ens/ens.py +++ b/ens/ens.py @@ -57,6 +57,7 @@ normal_name_to_hash, normalize_name, raw_name_to_hash, + resolved_address_to_bytes, ) if TYPE_CHECKING: @@ -138,40 +139,83 @@ def from_web3(cls, w3: "Web3", addr: ChecksumAddress = None) -> "ENS": return ns - def address( + def address_bytes( self, name: str, coin_type: int | None = None, - ) -> ChecksumAddress | None: + ) -> bytes | None: """ - Look up the Ethereum address that `name` currently points to. + Look up the raw address bytes that `name` points to. + + Returns the ENSIP-9 native binary encoding from the resolver without + converting to a checksummed hex string. For Ethereum (``coin_type=60`` + or the default ``addr(node)`` record), this is typically 20 bytes. For + other chains (for example Bitcoin or Solana), byte length and format + follow `ENSIP-9 `_. + + Use :meth:`address` when you need an EIP-55 checksummed Ethereum address. + For other coin types, encode the returned bytes with a chain-specific + library such as `ensdomains/address-encoder + `_. :param str name: an ENS name to look up :param int coin_type: if provided, look up the address for this coin type :raises InvalidName: if `name` has invalid syntax - :raises ResolverNotFound: if no resolver found for `name` """ from web3.exceptions import ( ContractLogicError, ) if coin_type is None: - return cast(ChecksumAddress, self._resolve(name, "addr")) - else: - dns_name, calldata = self._prepare_resolve_call(name, "addr", [coin_type]) + dns_name, calldata = self._prepare_resolve_call(name, "addr") try: result, resolver_addr = self._universal_resolver.caller.resolve( dns_name, calldata ) except ContractLogicError: return None - if not result: + if not result or result == b"": return None - decoded = self.w3.codec.decode(["bytes"], result) - address_as_bytes = decoded[0] - if is_none_or_zero_address(address_as_bytes): - return None - return to_checksum_address(address_as_bytes) + decoded_result = self._decode_ensip10_resolve_data( + result, self._resolver_contract, "addr" + ) + return resolved_address_to_bytes(decoded_result) + + dns_name, calldata = self._prepare_resolve_call(name, "addr", [coin_type]) + try: + result, resolver_addr = self._universal_resolver.caller.resolve( + dns_name, calldata + ) + except ContractLogicError: + return None + if not result: + return None + decoded = self.w3.codec.decode(["bytes"], result) + address_as_bytes = decoded[0] + if is_none_or_zero_address(address_as_bytes): + return None + return address_as_bytes + + def address( + self, + name: str, + coin_type: int | None = None, + ) -> ChecksumAddress | None: + """ + Look up the Ethereum address that `name` currently points to. + + :param str name: an ENS name to look up + :param int coin_type: if provided, look up the address for this coin type + :raises InvalidName: if `name` has invalid syntax + :raises ResolverNotFound: if no resolver found for `name` + """ + if coin_type is None: + return cast(ChecksumAddress, self._resolve(name, "addr")) + + address_as_bytes = self.address_bytes(name, coin_type=coin_type) + if address_as_bytes is None: + return None + return to_checksum_address(address_as_bytes) def setup_address( self, diff --git a/ens/utils.py b/ens/utils.py index afbbf91f24..c0185f3ea5 100644 --- a/ens/utils.py +++ b/ens/utils.py @@ -278,6 +278,25 @@ def is_none_or_zero_address(addr: Address | ChecksumAddress | HexAddress) -> boo return not addr or addr == EMPTY_ADDR_HEX or addr == b"\x00" * 20 +def resolved_address_to_bytes( + value: Address | ChecksumAddress | HexAddress | bytes | str | None, +) -> bytes | None: + """ + Convert a resolver ``addr`` return value to raw bytes (ENSIP-9 on-chain form). + """ + if value is None or is_none_or_zero_address(value): + return None + if isinstance(value, bytes): + return value + if isinstance(value, str): + if value.startswith(("0x", "0X")): + return bytes.fromhex(value[2:]) + return bytes.fromhex(value) + raise ENSValueError( + f"Cannot convert resolved address of type {type(value)!r} to bytes" + ) + + def is_empty_name(name: str) -> bool: return name is None or name.strip() in {"", "."} diff --git a/newsfragments/3854.feature.rst b/newsfragments/3854.feature.rst new file mode 100644 index 0000000000..1e3ab42454 --- /dev/null +++ b/newsfragments/3854.feature.rst @@ -0,0 +1 @@ +Add ``address_bytes()`` to ``ENS`` and ``AsyncENS`` for raw multichain ``addr`` resolution per ENSIP-9, without EIP-55 encoding. diff --git a/tests/ens/test_ens.py b/tests/ens/test_ens.py index 41268e5b77..314eff3ffc 100644 --- a/tests/ens/test_ens.py +++ b/tests/ens/test_ens.py @@ -202,6 +202,56 @@ def test_ens_address_lookup_with_coin_type(ens): assert returned_address == address +# ENSIP-9 Bitcoin P2PKH on-chain encoding (25 bytes) +BITCOIN_ADDR_BYTES = bytes.fromhex( + "76a91462e907b15cbf27d5425399ebf6f0fb50ebb88f1888ac" +) + + +def test_ens_address_bytes_with_coin_type(ens): + name = "tester.eth" + coin_type = 0 + expected_node = raw_name_to_hash(name) + + encoded_result = ens.w3.codec.encode(["bytes"], [BITCOIN_ADDR_BYTES]) + mock_ur_caller = MagicMock() + mock_ur_caller.resolve.return_value = (encoded_result, ens.w3.eth.accounts[0]) + + with patch.object(ens._universal_resolver, "caller", mock_ur_caller): + returned_bytes = ens.address_bytes(name, coin_type=coin_type) + + assert returned_bytes == BITCOIN_ADDR_BYTES + mock_ur_caller.resolve.assert_called_once() + _, calldata = mock_ur_caller.resolve.call_args.args + decoded_node, decoded_coin_type = ens.w3.codec.decode( + ["bytes32", "uint256"], bytes.fromhex(calldata[2:])[4:] + ) + assert decoded_node == expected_node + assert decoded_coin_type == coin_type + + +def test_ens_address_bytes_bitcoin_does_not_require_checksum(ens): + encoded_result = ens.w3.codec.encode(["bytes"], [BITCOIN_ADDR_BYTES]) + mock_ur_caller = MagicMock() + mock_ur_caller.resolve.return_value = (encoded_result, ens.w3.eth.accounts[0]) + + with patch.object(ens._universal_resolver, "caller", mock_ur_caller): + # ``address()`` still applies EIP-55 encoding and fails for non-EVM bytes + with pytest.raises(ValueError): + ens.address("tester.eth", coin_type=0) + assert ens.address_bytes("tester.eth", coin_type=0) == BITCOIN_ADDR_BYTES + + +def test_ens_address_delegates_to_address_bytes_for_coin_type(ens): + name = "tester.eth" + coin_type = 60 + raw_bytes = bytes.fromhex(ens.w3.eth.accounts[0][2:]) + + with patch.object(ens, "address_bytes", return_value=raw_bytes) as mock_bytes: + assert ens.address(name, coin_type=coin_type) == ens.w3.eth.accounts[0] + mock_bytes.assert_called_once_with(name, coin_type=coin_type) + + @pytest.mark.parametrize( "ur_resolve_outcome", ( @@ -404,6 +454,24 @@ async def test_async_ens_address_lookup_with_coin_type(async_ens): assert returned_address == address +@pytest.mark.asyncio +async def test_async_ens_address_bytes_with_coin_type(async_ens): + name = "tester.eth" + coin_type = 0 + accounts = await async_ens.w3.eth.accounts + expected_node = raw_name_to_hash(name) + + encoded_result = async_ens.w3.codec.encode(["bytes"], [BITCOIN_ADDR_BYTES]) + mock_ur_caller = AsyncMock() + mock_ur_caller.resolve.return_value = (encoded_result, accounts[0]) + + with patch.object(async_ens._universal_resolver, "caller", mock_ur_caller): + returned_bytes = await async_ens.address_bytes(name, coin_type=coin_type) + + assert returned_bytes == BITCOIN_ADDR_BYTES + mock_ur_caller.resolve.assert_called_once() + + @pytest.mark.parametrize( "ur_resolve_outcome", (