Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/ens_overview.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
<https://github.com/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
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Expand Down
75 changes: 60 additions & 15 deletions ens/async_ens.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
normal_name_to_hash,
normalize_name,
raw_name_to_hash,
resolved_address_to_bytes,
)

if TYPE_CHECKING:
Expand Down Expand Up @@ -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 <https://docs.ens.domains/ensip/9/#address-encoding>`_.

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
<https://github.com/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
Expand All @@ -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,
Expand Down
70 changes: 57 additions & 13 deletions ens/ens.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
normal_name_to_hash,
normalize_name,
raw_name_to_hash,
resolved_address_to_bytes,
)

if TYPE_CHECKING:
Expand Down Expand Up @@ -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 <https://docs.ens.domains/ensip/9/#address-encoding>`_.

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
<https://github.com/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,
Expand Down
19 changes: 19 additions & 0 deletions ens/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 {"", "."}

Expand Down
1 change: 1 addition & 0 deletions newsfragments/3854.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add ``address_bytes()`` to ``ENS`` and ``AsyncENS`` for raw multichain ``addr`` resolution per ENSIP-9, without EIP-55 encoding.
68 changes: 68 additions & 0 deletions tests/ens/test_ens.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
(
Expand Down Expand Up @@ -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",
(
Expand Down