diff --git a/docs/ens.rst b/docs/ens.rst index d82e125f13..372326a838 100644 --- a/docs/ens.rst +++ b/docs/ens.rst @@ -7,10 +7,16 @@ ENS API Continue below for the detailed specs on each method and class in the ens module. -ens\.main module +ens\.ens module ---------------- -.. automodule:: ens.main +.. automodule:: ens.ens + :members: + +ens\.async_ens module +--------------------- + +.. automodule:: ens.async_ens :members: ens\.exceptions module diff --git a/docs/ens_overview.rst b/docs/ens_overview.rst index 94c33a0198..a0bbecd887 100644 --- a/docs/ens_overview.rst +++ b/docs/ens_overview.rst @@ -13,7 +13,7 @@ an address from a name, set up your own address, and more. Setup ----- -Create an :class:`~ens.main.ENS` object (named ``ns`` below) in one of three ways: +Create an :class:`~ens.ENS` object (named ``ns`` below) in one of three ways: 1. Automatic detection 2. Specify an instance or list of :ref:`providers` @@ -93,7 +93,7 @@ Set Up Your Name and Address Link a Name to an Address ^^^^^^^^^^^^^^^^^^^^^^^^^ -You can set up your name so that :meth:`~ens.main.ENS.address` will show the address it points to. In order to do so, +You can set up your name so that :meth:`~ens.ENS.address` will show the address it points to. In order to do so, you must already be the owner of the domain (or its parent). .. code-block:: python @@ -123,7 +123,7 @@ You can claim arbitrarily deep subdomains. Link an Address to a Name ^^^^^^^^^^^^^^^^^^^^^^^^^ -You can set up your address so that :meth:`~ens.main.ENS.name` will show the name that points to it. +You can set up your address so that :meth:`~ens.ENS.name` will show the name that points to it. This is like Caller ID. It enables you and others to take an account and determine what name points to it. Sometimes this is referred to as "reverse" resolution. The ENS Reverse Resolver is used for this functionality. @@ -132,15 +132,15 @@ this is referred to as "reverse" resolution. The ENS Reverse Resolver is used fo ns.setup_name('jasoncarver.eth', '0x5B2063246F2191f18F2675ceDB8b28102e957458') -If you don't supply the address, :meth:`~ens.main.ENS.setup_name` will assume you want the -address returned by :meth:`~ens.main.ENS.address`. +If you don't supply the address, :meth:`~ens.ENS.setup_name` will assume you want the +address returned by :meth:`~ens.ENS.address`. .. code-block:: python ns.setup_name('jasoncarver.eth') -If the name doesn't already point to an address, :meth:`~ens.main.ENS.setup_name` will -call :meth:`~ens.main.ENS.setup_address` for you. +If the name doesn't already point to an address, :meth:`~ens.ENS.setup_name` will +call :meth:`~ens.ENS.setup_address` for you. Wait for the transaction to be mined, then: @@ -195,7 +195,7 @@ Working With Resolvers Get the Resolver for an ENS Record ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -You can get the resolver for an ENS name via the :meth:`~ens.main.ENS.resolver` method. +You can get the resolver for an ENS name via the :meth:`~ens.ENS.resolver` method. .. code-block:: python diff --git a/docs/providers.rst b/docs/providers.rst index d89069ea6b..7d649b56e6 100644 --- a/docs/providers.rst +++ b/docs/providers.rst @@ -474,7 +474,12 @@ Contract is fully implemented for the Async provider. The only documented except the moment is where :class:`ENS` is needed for address lookup. All addresses that are passed to Async contract should not be :class:`ENS` addresses. +ENS +^^^^^^^^ +ENS is implemented for the Async provider. Use the :class:`AsyncENSFactory` class to create a :class:`AsyncENS` object. + Supported Middleware ^^^^^^^^^^^^^^^^^^^^ - :meth:`Gas Price Strategy ` - :meth:`Buffered Gas Estimate Middleware ` +- :meth:`Stalecheck Middleware ` diff --git a/ens/__init__.py b/ens/__init__.py index c3a463a5da..2b40849ccc 100644 --- a/ens/__init__.py +++ b/ens/__init__.py @@ -1,6 +1,11 @@ # flake8: noqa -from .main import ( +from .async_ens import ( + AsyncENS, + AsyncENSFactory, +) + +from .ens import ( ENS, ) diff --git a/ens/async_ens.py b/ens/async_ens.py new file mode 100644 index 0000000000..156f138c62 --- /dev/null +++ b/ens/async_ens.py @@ -0,0 +1,542 @@ +from copy import ( + deepcopy, +) +from typing import ( + TYPE_CHECKING, + Optional, + Sequence, + Tuple, + Union, + cast, +) + +from eth_typing import ( + Address, + ChecksumAddress, + HexAddress, + HexStr, +) +from eth_utils import ( + is_address, + is_binary_address, + is_checksum_address, + to_checksum_address, +) +from eth_utils.toolz import ( + merge, +) +from hexbytes import ( + HexBytes, +) + +from ens import abis +from ens.base_ens import ( + BaseENS, +) +from ens.constants import ( + EMPTY_ADDR_HEX, + ENS_MAINNET_ADDR, + EXTENDED_RESOLVER_INTERFACE_ID, + GET_TEXT_INTERFACE_ID, + REVERSE_REGISTRAR_DOMAIN, +) +from ens.exceptions import ( + AddressMismatch, + ResolverNotFound, + UnauthorizedError, + UnownedName, + UnsupportedFunction, +) +from ens.utils import ( + address_in, + address_to_reverse_domain, + async_init_web3, + default, + ens_encode_name, + is_empty_name, + is_none_or_zero_address, + label_to_hash, + normal_name_to_hash, + normalize_name, + raw_name_to_hash, +) + +if TYPE_CHECKING: + from web3 import Web3 # noqa: F401 + from web3.contract import ( # noqa: F401 + AsyncContract, + ) + from web3.providers import ( # noqa: F401 + BaseProvider, + ) + from web3.types import ( # noqa: F401 + Middleware, + TxParams, + ) + + +class AsyncENSFactory: + + @classmethod + async def factory( + cls, + provider: 'BaseProvider' = cast('BaseProvider', default), + addr: ChecksumAddress = None, + middlewares: Optional[Sequence[Tuple['Middleware', str]]] = None, + ) -> 'AsyncENS': + async_ens: AsyncENS = AsyncENS() + async_ens.w3 = await async_init_web3(provider, middlewares) + + ens_addr = addr if addr else ENS_MAINNET_ADDR + async_ens.ens = async_ens.w3.eth.contract(abi=abis.ENS, address=ens_addr) + async_ens._resolver_contract = async_ens.w3.eth.contract(abi=abis.RESOLVER) + async_ens._reverse_resolver_contract = async_ens.w3.eth.contract(abi=abis.REVERSE_RESOLVER) + + return async_ens + + @classmethod + async def fromWeb3(cls, w3: 'Web3', addr: ChecksumAddress = None) -> 'AsyncENS': + """ + Generate an ENS instance with web3 + + :param `web3.Web3` w3: to infer connection information + :param hex-string addr: the address of the ENS registry on-chain. If not provided, + ENS.py will default to the mainnet ENS registry address. + """ + provider = w3.manager.provider + middlewares = w3.middleware_onion.middlewares + return await AsyncENSFactory.factory(provider, addr=addr, middlewares=middlewares) + + +class AsyncENS(BaseENS): + """ + Quick access to common Ethereum Name Service functions, + like getting the address for a name. + + Unless otherwise specified, all addresses are assumed to be a `str` in + `checksum format `_, + like: ``"0x314159265dD8dbb310642f98f50C066173C1259b"`` + """ + + async def address(self, name: str) -> Optional[ChecksumAddress]: + """ + Look up the Ethereum address that `name` currently points to. + + :param str name: an ENS name to look up + :raises InvalidName: if `name` has invalid syntax + """ + return cast(ChecksumAddress, await self._resolve(name, 'addr')) + + async def setup_address( + self, + name: str, + address: Union[Address, ChecksumAddress, HexAddress] = cast(ChecksumAddress, default), + transact: Optional["TxParams"] = None, + ) -> Optional[HexBytes]: + """ + Set up the name to point to the supplied address. + The sender of the transaction must own the name, or + its parent name. + + Example: If the caller owns ``parentname.eth`` with no subdomains + and calls this method with ``sub.parentname.eth``, + then ``sub`` will be created as part of this call. + + :param str name: ENS name to set up + :param str address: name will point to this address, in checksum format. If ``None``, + erase the record. If not specified, name will point to the owner's address. + :param dict transact: the transaction configuration, like in + :meth:`~web3.eth.Eth.send_transaction` + :raises InvalidName: if ``name`` has invalid syntax + :raises UnauthorizedError: if ``'from'`` in `transact` does not own `name` + """ + if not transact: + transact = {} + transact = deepcopy(transact) + owner = await self.setup_owner(name, transact=transact) + await self._assert_control(owner, name) + if is_none_or_zero_address(address): + address = None + elif address is default: + address = owner + elif is_binary_address(address): + address = to_checksum_address(cast(str, address)) + elif not is_checksum_address(address): + raise ValueError("You must supply the address in checksum format") + if await self.address(name) == address: + return None + if address is None: + address = EMPTY_ADDR_HEX + transact['from'] = owner + + resolver: 'AsyncContract' = await self._set_resolver( + name, transact=transact + ) + return await resolver.functions.setAddr( # type: ignore + raw_name_to_hash(name), address + ).transact(transact) + + async def name(self, address: ChecksumAddress) -> Optional[str]: + """ + Look up the name that the address points to, using a + reverse lookup. Reverse lookup is opt-in for name owners. + + :param address: + :type address: hex-string + """ + reversed_domain = address_to_reverse_domain(address) + name = await self._resolve(reversed_domain, fn_name='name') + + # To be absolutely certain of the name, via reverse resolution, the address must match in + # the forward resolution + return name if to_checksum_address(address) == await self.address(name) else None + + async def setup_name( + self, + name: str, + address: Optional[ChecksumAddress] = None, + transact: Optional["TxParams"] = None, + ) -> HexBytes: + """ + Set up the address for reverse lookup, aka "caller ID". + After successful setup, the method :meth:`~ens.ENS.name` will return + `name` when supplied with `address`. + + :param str name: ENS name that address will point to + :param str address: to set up, in checksum format + :param dict transact: the transaction configuration, like in + :meth:`~web3.eth.send_transaction` + :raises AddressMismatch: if the name does not already point to the address + :raises InvalidName: if `name` has invalid syntax + :raises UnauthorizedError: if ``'from'`` in `transact` does not own `name` + :raises UnownedName: if no one owns `name` + """ + if not transact: + transact = {} + transact = deepcopy(transact) + if not name: + await self._assert_control(address, 'the reverse record') + return await self._setup_reverse(None, address, transact=transact) + else: + resolved = await self.address(name) + if is_none_or_zero_address(address): + address = resolved + elif resolved and address != resolved and resolved != EMPTY_ADDR_HEX: + raise AddressMismatch( + f"Could not set address {address!r} to point to name, " + f"because the name resolves to {resolved!r}. " + "To change the name for an existing address, call " + "setup_address() first." + ) + if is_none_or_zero_address(address): + address = await self.owner(name) + if is_none_or_zero_address(address): + raise UnownedName("claim subdomain using setup_address() first") + if is_binary_address(address): + address = to_checksum_address(address) + if not is_checksum_address(address): + raise ValueError("You must supply the address in checksum format") + await self._assert_control(address, name) + if not resolved: + await self.setup_address(name, address, transact=transact) + return await self._setup_reverse(name, address, transact=transact) + + async def owner(self, name: str) -> ChecksumAddress: + """ + Get the owner of a name. Note that this may be different from the + deed holder in the '.eth' registrar. Learn more about the difference + between deed and name ownership in the ENS `Managing Ownership docs + `_ + + :param str name: ENS name to look up + :return: owner address + :rtype: str + """ + node = raw_name_to_hash(name) + return await self.ens.caller.owner(node) + + async def setup_owner( + self, + name: str, + new_owner: ChecksumAddress = cast(ChecksumAddress, default), + transact: Optional["TxParams"] = None, + ) -> Optional[ChecksumAddress]: + """ + Set the owner of the supplied name to `new_owner`. + + For typical scenarios, you'll never need to call this method directly, + simply call :meth:`setup_name` or :meth:`setup_address`. This method does *not* + set up the name to point to an address. + + If `new_owner` is not supplied, then this will assume you + want the same owner as the parent domain. + + If the caller owns ``parentname.eth`` with no subdomains + and calls this method with ``sub.parentname.eth``, + then ``sub`` will be created as part of this call. + + :param str name: ENS name to set up + :param new_owner: account that will own `name`. If ``None``, set owner to empty addr. + If not specified, name will point to the parent domain owner's address. + :param dict transact: the transaction configuration, like in + :meth:`~web3.eth.Eth.send_transaction` + :raises InvalidName: if `name` has invalid syntax + :raises UnauthorizedError: if ``'from'`` in `transact` does not own `name` + :returns: the new owner's address + """ + if not transact: + transact = {} + transact = deepcopy(transact) + (super_owner, unowned, owned) = await self._first_owner(name) + if new_owner is default: + new_owner = super_owner + elif not new_owner: + new_owner = ChecksumAddress(EMPTY_ADDR_HEX) + else: + new_owner = to_checksum_address(new_owner) + current_owner = await self.owner(name) + if new_owner == EMPTY_ADDR_HEX and not current_owner: + return None + elif current_owner == new_owner: + return current_owner + else: + await self._assert_control(super_owner, name, owned) + await self._claim_ownership(new_owner, unowned, owned, super_owner, transact=transact) + return new_owner + + async def resolver(self, name: str) -> Optional['AsyncContract']: + """ + Get the resolver for an ENS name. + + :param str name: The ENS name + """ + normal_name = normalize_name(name) + resolver = await self._get_resolver(normal_name) + return resolver[0] + + async def reverser(self, target_address: ChecksumAddress) -> Optional['AsyncContract']: + reversed_domain = address_to_reverse_domain(target_address) + return await self.resolver(reversed_domain) + + async def get_text(self, name: str, key: str) -> str: + """ + Get the value of a text record by key from an ENS name. + + :param str name: ENS name to look up + :param str key: ENS name's text record key + :return: ENS name's text record value + :rtype: str + :raises UnsupportedFunction: If the resolver does not support the "0x59d1d43c" interface id + :raises ResolverNotFound: If no resolver is found for the provided name + """ + node = raw_name_to_hash(name) + normal_name = normalize_name(name) + + r = await self.resolver(normal_name) + if r: + if await _async_resolver_supports_interface(r, GET_TEXT_INTERFACE_ID): + return await r.caller.text(node, key) + else: + raise UnsupportedFunction( + f"Resolver for name {name} does not support `text` function." + ) + else: + raise ResolverNotFound( + f"No resolver found for name `{name}`. It is likely the name contains an " + "unsupported top level domain (tld)." + ) + + async def set_text( + self, + name: str, + key: str, + value: str, + transact: "TxParams" = None, + ) -> HexBytes: + """ + Set the value of a text record of an ENS name. + + :param str name: ENS name + :param str key: Name of the attribute to set + :param str value: Value to set the attribute to + :param dict transact: The transaction configuration, like in + :meth:`~web3.eth.Eth.send_transaction` + :return: Transaction hash + :rtype: HexBytes + :raises UnsupportedFunction: If the resolver does not support the "0x59d1d43c" interface id + :raises ResolverNotFound: If no resolver is found for the provided name + """ + if not transact: + transact = {} + + owner = await self.owner(name) + node = raw_name_to_hash(name) + normal_name = normalize_name(name) + + transaction_dict = merge({'from': owner}, transact) + + r = await self.resolver(normal_name) + if r: + if await _async_resolver_supports_interface(r, GET_TEXT_INTERFACE_ID): + return await r.functions.setText( # type: ignore + node, key, value + ).transact(transaction_dict) + else: + raise UnsupportedFunction( + f"Resolver for name `{name}` does not support `text` function" + ) + else: + raise ResolverNotFound( + f"No resolver found for name `{name}`. It is likely the name contains an " + "unsupported top level domain (tld)." + ) + + async def _get_resolver( + self, + normal_name: str, + fn_name: str = 'addr', + ) -> Tuple[Optional['AsyncContract'], str]: + current_name = normal_name + + # look for a resolver, starting at the full name and taking the parent each time that no + # resolver is found + while True: + if is_empty_name(current_name): + # if no resolver found across all iterations, current_name will eventually be the + # empty string '' which returns here + return None, current_name + + resolver_addr = await self.ens.caller.resolver(normal_name_to_hash(current_name)) + if not is_none_or_zero_address(resolver_addr): + # if resolver found, return it + resolver = cast('AsyncContract', self._type_aware_resolver(resolver_addr, fn_name)) + return resolver, current_name + + # set current_name to parent and try again + current_name = self.parent(current_name) + + async def _set_resolver( + self, + name: str, + resolver_addr: Optional[ChecksumAddress] = None, + transact: Optional["TxParams"] = None, + ) -> 'AsyncContract': + if not transact: + transact = {} + transact = deepcopy(transact) + if is_none_or_zero_address(resolver_addr): + resolver_addr = await self.address('resolver.eth') + namehash = raw_name_to_hash(name) + if await self.ens.caller.resolver(namehash) != resolver_addr: + await self.ens.functions.setResolver( # type: ignore + namehash, + resolver_addr + ).transact(transact) + return cast('AsyncContract', self._resolver_contract(address=resolver_addr)) + + async def _resolve( + self, name: str, + fn_name: str = 'addr', + ) -> Optional[Union[ChecksumAddress, str]]: + normal_name = normalize_name(name) + + resolver, current_name = await self._get_resolver(normal_name, fn_name) + if not resolver: + return None + + node = self.namehash(normal_name) + + # handle extended resolver case + if await _async_resolver_supports_interface(resolver, EXTENDED_RESOLVER_INTERFACE_ID): + contract_func_with_args = (fn_name, [node]) + + calldata = resolver.encodeABI(*contract_func_with_args) + contract_call_result = await resolver.caller.resolve( + ens_encode_name(normal_name), calldata + ) + result = self._decode_ensip10_resolve_data( + contract_call_result, resolver, fn_name + ) + return to_checksum_address(result) if is_address(result) else result + elif normal_name == current_name: + lookup_function = getattr(resolver.functions, fn_name) + result = await lookup_function(node).call() + if is_none_or_zero_address(result): + return None + return to_checksum_address(result) if is_address(result) else result + return None + + async def _assert_control( + self, + account: ChecksumAddress, + name: str, + parent_owned: Optional[str] = None, + ) -> None: + if not address_in(account, await self.w3.eth.accounts): # type: ignore + raise UnauthorizedError( + f"in order to modify {name!r}, you must control account" + f" {account!r}, which owns {parent_owned or name!r}" + ) + + async def _first_owner(self, name: str) -> Tuple[Optional[ChecksumAddress], Sequence[str], str]: + """ + Takes a name, and returns the owner of the deepest subdomain that has an owner + + :returns: (owner or None, list(unowned_subdomain_labels), first_owned_domain) + """ + owner = None + unowned = [] + pieces = normalize_name(name).split('.') + while pieces and is_none_or_zero_address(owner): + name = '.'.join(pieces) + owner = await self.owner(name) + if is_none_or_zero_address(owner): + unowned.append(pieces.pop(0)) + return (owner, unowned, name) + + async def _claim_ownership( + self, + owner: ChecksumAddress, + unowned: Sequence[str], + owned: str, + old_owner: Optional[ChecksumAddress] = None, + transact: Optional["TxParams"] = None, + ) -> None: + if not transact: + transact = {} + transact = deepcopy(transact) + transact['from'] = old_owner or owner + for label in reversed(unowned): + await self.ens.functions.setSubnodeOwner( # type: ignore + raw_name_to_hash(owned), + label_to_hash(label), + owner + ).transact(transact) + owned = f"{label}.{owned}" + + async def _setup_reverse( + self, + name: Optional[str], + address: ChecksumAddress, + transact: Optional["TxParams"] = None, + ) -> HexBytes: + name = normalize_name(name) if name else '' + if not transact: + transact = {} + transact = deepcopy(transact) + transact['from'] = address + reverse_registrar = await self._reverse_registrar() + return await reverse_registrar.functions.setName(name).transact(transact) # type: ignore + + async def _reverse_registrar(self) -> 'AsyncContract': + addr = await self.ens.caller.owner(normal_name_to_hash(REVERSE_REGISTRAR_DOMAIN)) + return self.w3.eth.contract(address=addr, abi=abis.REVERSE_REGISTRAR) + + +async def _async_resolver_supports_interface( + resolver: 'AsyncContract', + interface_id: HexStr, +) -> bool: + if not any('supportsInterface' in repr(func) for func in resolver.all_functions()): + return False + return await resolver.caller.supportsInterface(interface_id) diff --git a/ens/base_ens.py b/ens/base_ens.py new file mode 100644 index 0000000000..ba88e7a567 --- /dev/null +++ b/ens/base_ens.py @@ -0,0 +1,109 @@ +from functools import ( + wraps, +) +from typing import ( + TYPE_CHECKING, + Any, + Type, + Union, +) + +from eth_typing import ( + ChecksumAddress, +) +from hexbytes import ( + HexBytes, +) + +from ens.utils import ( + address_to_reverse_domain, + get_abi_output_types, + is_valid_name, + label_to_hash, + normalize_name, + raw_name_to_hash, +) + +if TYPE_CHECKING: + from web3 import Web3 # noqa: F401 + from web3.contract import ( # noqa: F401 + AsyncContract, + Contract, + ) + + +class BaseENS: + w3: 'Web3' = None + ens: Union['Contract', 'AsyncContract'] = None + _resolver_contract: Union[Type['Contract'], Type['AsyncContract']] = None + _reverse_resolver_contract: Union[Type['Contract'], Type['AsyncContract']] = None + + @staticmethod + @wraps(label_to_hash) + def labelhash(label: str) -> HexBytes: + return label_to_hash(label) + + @staticmethod + @wraps(raw_name_to_hash) + def namehash(name: str) -> HexBytes: + return raw_name_to_hash(name) + + @staticmethod + @wraps(normalize_name) + def nameprep(name: str) -> str: + return normalize_name(name) + + @staticmethod + @wraps(is_valid_name) + def is_valid_name(name: str) -> bool: + return is_valid_name(name) + + @staticmethod + @wraps(address_to_reverse_domain) + def reverse_domain(address: ChecksumAddress) -> str: + return address_to_reverse_domain(address) + + @staticmethod + def parent(name: str) -> str: + """ + Part of ENSIP-10. Returns the parent of a given ENS name, or the empty string if the ENS + name does not have a parent. + + e.g. + - parent('1.foo.bar.eth') = 'foo.bar.eth' + - parent('foo.bar.eth') = 'bar.eth' + - parent('foo.eth') = 'eth' + - parent('eth') is defined as the empty string '' + + :param name: an ENS name + :return: the parent for the provided ENS name + :rtype: str + """ + if not name: + return '' + + labels = name.split('.') + return '' if len(labels) == 1 else '.'.join(labels[1:]) + + def _decode_ensip10_resolve_data( + self, + contract_call_result: bytes, + extended_resolver: Union['Contract', 'AsyncContract'], + fn_name: str, + ) -> Any: + func = extended_resolver.get_function_by_name(fn_name) + output_types = get_abi_output_types(func.abi) + decoded = self.w3.codec.decode_abi(output_types, contract_call_result) + + # if decoding a single value, return that value - else, return the tuple + return decoded[0] if len(decoded) == 1 else decoded + + def _type_aware_resolver( + self, + address: ChecksumAddress, + func: str, + ) -> Union['Contract', 'AsyncContract']: + return ( + self._reverse_resolver_contract(address=address) if func == 'name' else + self._resolver_contract(address=address) + ) diff --git a/ens/main.py b/ens/ens.py similarity index 83% rename from ens/main.py rename to ens/ens.py index 146448df3a..3d16bc0931 100644 --- a/ens/main.py +++ b/ens/ens.py @@ -1,12 +1,8 @@ from copy import ( deepcopy, ) -from functools import ( - wraps, -) from typing import ( TYPE_CHECKING, - Any, Optional, Sequence, Tuple, @@ -34,6 +30,9 @@ ) from ens import abis +from ens.base_ens import ( + BaseENS, +) from ens.constants import ( EMPTY_ADDR_HEX, ENS_MAINNET_ADDR, @@ -53,11 +52,9 @@ address_to_reverse_domain, default, ens_encode_name, - get_abi_output_types, init_web3, is_empty_name, is_none_or_zero_address, - is_valid_name, label_to_hash, normal_name_to_hash, normalize_name, @@ -67,7 +64,6 @@ if TYPE_CHECKING: from web3 import Web3 # noqa: F401 from web3.contract import ( # noqa: F401 - AsyncContract, Contract, ) from web3.providers import ( # noqa: F401 @@ -79,7 +75,7 @@ ) -class ENS: +class ENS(BaseENS): """ Quick access to common Ethereum Name Service functions, like getting the address for a name. @@ -89,31 +85,6 @@ class ENS: like: ``"0x314159265dD8dbb310642f98f50C066173C1259b"`` """ - @staticmethod - @wraps(label_to_hash) - def labelhash(label: str) -> HexBytes: - return label_to_hash(label) - - @staticmethod - @wraps(raw_name_to_hash) - def namehash(name: str) -> HexBytes: - return raw_name_to_hash(name) - - @staticmethod - @wraps(normalize_name) - def nameprep(name: str) -> str: - return normalize_name(name) - - @staticmethod - @wraps(is_valid_name) - def is_valid_name(name: str) -> bool: - return is_valid_name(name) - - @staticmethod - @wraps(address_to_reverse_domain) - def reverse_domain(address: ChecksumAddress) -> str: - return address_to_reverse_domain(address) - def __init__( self, provider: 'BaseProvider' = cast('BaseProvider', default), @@ -126,6 +97,7 @@ def __init__( :param hex-string addr: the address of the ENS registry on-chain. If not provided, ENS.py will default to the mainnet ENS registry address. """ + super() self.w3 = init_web3(provider, middlewares) ens_addr = addr if addr else ENS_MAINNET_ADDR @@ -155,48 +127,11 @@ def address(self, name: str) -> Optional[ChecksumAddress]: """ return cast(ChecksumAddress, self._resolve(name, 'addr')) - def name(self, address: ChecksumAddress) -> Optional[str]: - """ - Look up the name that the address points to, using a - reverse lookup. Reverse lookup is opt-in for name owners. - - :param address: - :type address: hex-string - """ - reversed_domain = address_to_reverse_domain(address) - name = self._resolve(reversed_domain, fn_name='name') - - # To be absolutely certain of the name, via reverse resolution, the address must match in - # the forward resolution - return name if to_checksum_address(address) == self.address(name) else None - - @staticmethod - def parent(name: str) -> str: - """ - Part of ENSIP-10. Returns the parent of a given ENS name, or the empty string if the ENS - name does not have a parent. - - e.g. - - parent('1.foo.bar.eth') = 'foo.bar.eth' - - parent('foo.bar.eth') = 'bar.eth' - - parent('foo.eth') = 'eth' - - parent('eth') is defined as the empty string '' - - :param name: an ENS name - :return: the parent for the provided ENS name - :rtype: str - """ - if not name: - return '' - - labels = name.split('.') - return '' if len(labels) == 1 else '.'.join(labels[1:]) - def setup_address( self, name: str, address: Union[Address, ChecksumAddress, HexAddress] = cast(ChecksumAddress, default), - transact: Optional["TxParams"] = None + transact: Optional["TxParams"] = None, ) -> Optional[HexBytes]: """ Set up the name to point to the supplied address. @@ -233,18 +168,34 @@ def setup_address( if address is None: address = EMPTY_ADDR_HEX transact['from'] = owner - resolver: Union['Contract', 'AsyncContract'] = self._set_resolver(name, transact=transact) + + resolver: 'Contract' = self._set_resolver(name, transact=transact) return resolver.functions.setAddr(raw_name_to_hash(name), address).transact(transact) + def name(self, address: ChecksumAddress) -> Optional[str]: + """ + Look up the name that the address points to, using a + reverse lookup. Reverse lookup is opt-in for name owners. + + :param address: + :type address: hex-string + """ + reversed_domain = address_to_reverse_domain(address) + name = self._resolve(reversed_domain, fn_name='name') + + # To be absolutely certain of the name, via reverse resolution, the address must match in + # the forward resolution + return name if to_checksum_address(address) == self.address(name) else None + def setup_name( self, name: str, address: Optional[ChecksumAddress] = None, - transact: Optional["TxParams"] = None + transact: Optional["TxParams"] = None, ) -> HexBytes: """ Set up the address for reverse lookup, aka "caller ID". - After successful setup, the method :meth:`~ens.main.ENS.name` will return + After successful setup, the method :meth:`~ens.ENS.name` will return `name` when supplied with `address`. :param str name: ENS name that address will point to @@ -286,20 +237,6 @@ def setup_name( self.setup_address(name, address, transact=transact) return self._setup_reverse(name, address, transact=transact) - def resolver(self, name: str) -> Optional[Union['Contract', 'AsyncContract']]: - """ - Get the resolver for an ENS name. - - :param str name: The ENS name - """ - normal_name = normalize_name(name) - return self._get_resolver(normal_name)[0] - - def reverser(self, - target_address: ChecksumAddress) -> Optional[Union['Contract', 'AsyncContract']]: - reversed_domain = address_to_reverse_domain(target_address) - return self.resolver(reversed_domain) - def owner(self, name: str) -> ChecksumAddress: """ Get the owner of a name. Note that this may be different from the @@ -314,6 +251,68 @@ def owner(self, name: str) -> ChecksumAddress: node = raw_name_to_hash(name) return self.ens.caller.owner(node) + def setup_owner( + self, + name: str, + new_owner: ChecksumAddress = cast(ChecksumAddress, default), + transact: Optional["TxParams"] = None, + ) -> Optional[ChecksumAddress]: + """ + Set the owner of the supplied name to `new_owner`. + + For typical scenarios, you'll never need to call this method directly, + simply call :meth:`setup_name` or :meth:`setup_address`. This method does *not* + set up the name to point to an address. + + If `new_owner` is not supplied, then this will assume you + want the same owner as the parent domain. + + If the caller owns ``parentname.eth`` with no subdomains + and calls this method with ``sub.parentname.eth``, + then ``sub`` will be created as part of this call. + + :param str name: ENS name to set up + :param new_owner: account that will own `name`. If ``None``, set owner to empty addr. + If not specified, name will point to the parent domain owner's address. + :param dict transact: the transaction configuration, like in + :meth:`~web3.eth.Eth.send_transaction` + :raises InvalidName: if `name` has invalid syntax + :raises UnauthorizedError: if ``'from'`` in `transact` does not own `name` + :returns: the new owner's address + """ + if not transact: + transact = {} + transact = deepcopy(transact) + (super_owner, unowned, owned) = self._first_owner(name) + if new_owner is default: + new_owner = super_owner + elif not new_owner: + new_owner = ChecksumAddress(EMPTY_ADDR_HEX) + else: + new_owner = to_checksum_address(new_owner) + current_owner = self.owner(name) + if new_owner == EMPTY_ADDR_HEX and not current_owner: + return None + elif current_owner == new_owner: + return current_owner + else: + self._assert_control(super_owner, name, owned) + self._claim_ownership(new_owner, unowned, owned, super_owner, transact=transact) + return new_owner + + def resolver(self, name: str) -> Optional['Contract']: + """ + Get the resolver for an ENS name. + + :param str name: The ENS name + """ + normal_name = normalize_name(name) + return self._get_resolver(normal_name)[0] + + def reverser(self, target_address: ChecksumAddress) -> Optional['Contract']: + reversed_domain = address_to_reverse_domain(target_address) + return self.resolver(reversed_domain) + def get_text(self, name: str, key: str) -> str: """ Get the value of a text record by key from an ENS name. @@ -347,7 +346,7 @@ def set_text( name: str, key: str, value: str, - transact: "TxParams" = None + transact: "TxParams" = None, ) -> HexBytes: """ Set the value of a text record of an ENS name. @@ -385,54 +384,48 @@ def set_text( "unsupported top level domain (tld)." ) - def setup_owner( + def _get_resolver( self, - name: str, - new_owner: ChecksumAddress = cast(ChecksumAddress, default), - transact: Optional["TxParams"] = None - ) -> Optional[ChecksumAddress]: - """ - Set the owner of the supplied name to `new_owner`. + normal_name: str, + fn_name: str = 'addr', + ) -> Tuple[Optional['Contract'], str]: + current_name = normal_name - For typical scenarios, you'll never need to call this method directly, - simply call :meth:`setup_name` or :meth:`setup_address`. This method does *not* - set up the name to point to an address. + # look for a resolver, starting at the full name and taking the parent each time that no + # resolver is found + while True: + if is_empty_name(current_name): + # if no resolver found across all iterations, current_name will eventually be the + # empty string '' which returns here + return None, current_name - If `new_owner` is not supplied, then this will assume you - want the same owner as the parent domain. + resolver_addr = self.ens.caller.resolver(normal_name_to_hash(current_name)) + if not is_none_or_zero_address(resolver_addr): + # if resolver found, return it + resolver = cast('Contract', self._type_aware_resolver(resolver_addr, fn_name)) + return resolver, current_name - If the caller owns ``parentname.eth`` with no subdomains - and calls this method with ``sub.parentname.eth``, - then ``sub`` will be created as part of this call. + # set current_name to parent and try again + current_name = self.parent(current_name) - :param str name: ENS name to set up - :param new_owner: account that will own `name`. If ``None``, set owner to empty addr. - If not specified, name will point to the parent domain owner's address. - :param dict transact: the transaction configuration, like in - :meth:`~web3.eth.Eth.send_transaction` - :raises InvalidName: if `name` has invalid syntax - :raises UnauthorizedError: if ``'from'`` in `transact` does not own `name` - :returns: the new owner's address - """ + def _set_resolver( + self, + name: str, + resolver_addr: Optional[ChecksumAddress] = None, + transact: Optional["TxParams"] = None, + ) -> 'Contract': if not transact: transact = {} transact = deepcopy(transact) - (super_owner, unowned, owned) = self._first_owner(name) - if new_owner is default: - new_owner = super_owner - elif not new_owner: - new_owner = ChecksumAddress(EMPTY_ADDR_HEX) - else: - new_owner = to_checksum_address(new_owner) - current_owner = self.owner(name) - if new_owner == EMPTY_ADDR_HEX and not current_owner: - return None - elif current_owner == new_owner: - return current_owner - else: - self._assert_control(super_owner, name, owned) - self._claim_ownership(new_owner, unowned, owned, super_owner, transact=transact) - return new_owner + if is_none_or_zero_address(resolver_addr): + resolver_addr = self.address('resolver.eth') + namehash = raw_name_to_hash(name) + if self.ens.caller.resolver(namehash) != resolver_addr: + self.ens.functions.setResolver( + namehash, + resolver_addr + ).transact(transact) + return cast('Contract', self._resolver_contract(address=resolver_addr)) def _resolve(self, name: str, fn_name: str = 'addr') -> Optional[Union[ChecksumAddress, str]]: normal_name = normalize_name(name) @@ -463,8 +456,12 @@ def _resolve(self, name: str, fn_name: str = 'addr') -> Optional[Union[ChecksumA return to_checksum_address(result) if is_address(result) else result return None - def _assert_control(self, account: ChecksumAddress, name: str, - parent_owned: Optional[str] = None) -> None: + def _assert_control( + self, + account: ChecksumAddress, + name: str, + parent_owned: Optional[str] = None, + ) -> None: if not address_in(account, self.w3.eth.accounts): raise UnauthorizedError( f"in order to modify {name!r}, you must control account" @@ -493,7 +490,7 @@ def _claim_ownership( unowned: Sequence[str], owned: str, old_owner: Optional[ChecksumAddress] = None, - transact: Optional["TxParams"] = None + transact: Optional["TxParams"] = None, ) -> None: if not transact: transact = {} @@ -507,61 +504,11 @@ def _claim_ownership( ).transact(transact) owned = f"{label}.{owned}" - def _set_resolver( - self, - name: str, - resolver_addr: Optional[ChecksumAddress] = None, - transact: Optional["TxParams"] = None - ) -> Union['Contract', 'AsyncContract']: - if not transact: - transact = {} - transact = deepcopy(transact) - if is_none_or_zero_address(resolver_addr): - resolver_addr = self.address('resolver.eth') - namehash = raw_name_to_hash(name) - if self.ens.caller.resolver(namehash) != resolver_addr: - self.ens.functions.setResolver( - namehash, - resolver_addr - ).transact(transact) - return self._resolver_contract(address=resolver_addr) - - def _get_resolver( - self, - normal_name: str, - fn_name: str = 'addr' - ) -> Tuple[Optional[Union['Contract', 'AsyncContract']], str]: - current_name = normal_name - - # look for a resolver, starting at the full name and taking the parent each time that no - # resolver is found - while True: - if is_empty_name(current_name): - # if no resolver found across all iterations, current_name will eventually be the - # empty string '' which returns here - return None, current_name - - resolver_addr = self.ens.caller.resolver(normal_name_to_hash(current_name)) - if not is_none_or_zero_address(resolver_addr): - # if resolver found, return it - return self._type_aware_resolver(resolver_addr, fn_name), current_name - - # set current_name to parent and try again - current_name = self.parent(current_name) - - def _decode_ensip10_resolve_data( - self, contract_call_result: bytes, - extended_resolver: Union['Contract', 'AsyncContract'], fn_name: str, - ) -> Any: - func = extended_resolver.get_function_by_name(fn_name) - output_types = get_abi_output_types(func.abi) - decoded = self.w3.codec.decode_abi(output_types, contract_call_result) - - # if decoding a single value, return that value - else, return the tuple - return decoded[0] if len(decoded) == 1 else decoded - def _setup_reverse( - self, name: str, address: ChecksumAddress, transact: Optional["TxParams"] = None + self, + name: Optional[str], + address: ChecksumAddress, + transact: Optional["TxParams"] = None, ) -> HexBytes: name = normalize_name(name) if name else '' if not transact: @@ -570,21 +517,12 @@ def _setup_reverse( transact['from'] = address return self._reverse_registrar().functions.setName(name).transact(transact) - def _type_aware_resolver(self, - address: ChecksumAddress, - func: str) -> Union['Contract', 'AsyncContract']: - return ( - self._reverse_resolver_contract(address=address) if func == 'name' else - self._resolver_contract(address=address) - ) - - def _reverse_registrar(self) -> Union['Contract', 'AsyncContract']: + def _reverse_registrar(self) -> 'Contract': addr = self.ens.caller.owner(normal_name_to_hash(REVERSE_REGISTRAR_DOMAIN)) return self.w3.eth.contract(address=addr, abi=abis.REVERSE_REGISTRAR) -def _resolver_supports_interface(resolver: Union['Contract', 'AsyncContract'], - interface_id: HexStr) -> bool: +def _resolver_supports_interface(resolver: 'Contract', interface_id: HexStr) -> bool: if not any('supportsInterface' in repr(func) for func in resolver.all_functions()): return False return resolver.caller.supportsInterface(interface_id) diff --git a/ens/exceptions.py b/ens/exceptions.py index 3e254cffde..791b68cc25 100644 --- a/ens/exceptions.py +++ b/ens/exceptions.py @@ -35,7 +35,7 @@ class UnownedName(Exception): Raised if you are trying to modify a name that no one owns. If working on a subdomain, make sure the subdomain gets created - first with :meth:`~ens.main.ENS.setup_address`. + first with :meth:`~ens.ENS.setup_address`. """ pass diff --git a/ens/utils.py b/ens/utils.py index e56ef75d7c..976b93b3d1 100644 --- a/ens/utils.py +++ b/ens/utils.py @@ -70,14 +70,15 @@ def Web3() -> Type['_Web3']: def init_web3( provider: 'BaseProvider' = cast('BaseProvider', default), - middlewares: Optional[Sequence[Tuple['Middleware', str]]] = None, + middlewares: Optional[Sequence[Tuple['Middleware', str]]] = None ) -> '_Web3': from web3 import Web3 as Web3Main + from web3.eth import Eth as EthMain if provider is default: - w3 = Web3Main(ens=None) + w3 = Web3Main(ens=None, modules={"eth": (EthMain)}) else: - w3 = Web3Main(provider, middlewares, ens=None) + w3 = Web3Main(provider, middlewares, ens=None, modules={"eth": (EthMain)}) return customize_web3(w3) @@ -93,7 +94,6 @@ def customize_web3(w3: '_Web3') -> '_Web3': make_stalecheck_middleware(ACCEPTABLE_STALE_HOURS * 3600), name='stalecheck' ) - return w3 @@ -154,7 +154,7 @@ def is_valid_name(name: str) -> bool: `_ :param str name: the dot-separated ENS name - :returns: True if ``name`` is set, and :meth:`~ens.main.ENS.nameprep` will not raise InvalidName + :returns: True if ``name`` is set, and :meth:`~ens.ENS.nameprep` will not raise InvalidName """ if not name: return False @@ -262,3 +262,35 @@ def get_abi_output_types(abi: 'ABIFunction') -> List[str]: [] if abi['type'] == 'fallback' else [collapse_if_tuple(cast(Dict[str, Any], arg)) for arg in abi['outputs']] ) + + +# -- async -- # + + +async def async_init_web3( + provider: 'BaseProvider' = cast('BaseProvider', default), + middlewares: Optional[Sequence[Tuple['Middleware', str]]] = None +) -> '_Web3': + from web3 import Web3 as Web3Main + from web3.eth import AsyncEth as AsyncEthMain + + if provider is default: + async_w3 = Web3Main(ens=None, modules={"eth": (AsyncEthMain)}) + else: + async_w3 = Web3Main(provider, middlewares, ens=None, modules={"eth": (AsyncEthMain)}) + + return await async_customize_web3(async_w3) + + +async def async_customize_web3(async_w3: '_Web3') -> '_Web3': + from web3.middleware import async_make_stalecheck_middleware + + if async_w3.middleware_onion.get('name_to_address'): + async_w3.middleware_onion.remove('name_to_address') + + if not async_w3.middleware_onion.get('stalecheck'): + async_w3.middleware_onion.add( + await async_make_stalecheck_middleware(ACCEPTABLE_STALE_HOURS * 3600), + name='stalecheck' + ) + return async_w3 diff --git a/newsfragments/2501.feature.rst b/newsfragments/2501.feature.rst new file mode 100644 index 0000000000..4afeebbcf9 --- /dev/null +++ b/newsfragments/2501.feature.rst @@ -0,0 +1 @@ +Added Async support for ENS \ No newline at end of file diff --git a/tests/ens/conftest.py b/tests/ens/conftest.py index be7a7d3350..7d48d5aea8 100644 --- a/tests/ens/conftest.py +++ b/tests/ens/conftest.py @@ -1,4 +1,4 @@ - +import asyncio import json import pytest @@ -8,8 +8,12 @@ from eth_utils import ( to_checksum_address, ) +import pytest_asyncio -from ens import ENS +from ens import ( + ENS, + AsyncENSFactory, +) from ens.contract_data import ( extended_resolver_abi, extended_resolver_bytecode, @@ -35,9 +39,14 @@ ) from web3 import Web3 from web3.contract import ( + AsyncContract, Contract, ) +from web3.eth import ( + AsyncEth, +) from web3.providers.eth_tester import ( + AsyncEthereumTesterProvider, EthereumTesterProvider, ) @@ -134,8 +143,15 @@ def ENSRegistryFactory(w3): ) -# session scope for performance -@pytest.fixture(scope="session") +@pytest.fixture +def ens(ens_setup, mocker): + mocker.patch('web3.middleware.stalecheck._isfresh', return_value=True) + ens_setup.w3.eth.default_account = ens_setup.w3.eth.coinbase + return ens_setup + + +# module scope for performance +@pytest.fixture(scope="module") def ens_setup(): w3 = Web3(EthereumTesterProvider(EthereumTester())) @@ -337,11 +353,319 @@ def ens_setup(): return ENS.fromWeb3(w3, ens_contract.address) -@pytest.fixture -def ens(ens_setup, mocker): +# -- async -- # + + +@pytest_asyncio.fixture(scope="module") +def async_w3(): + provider = AsyncEthereumTesterProvider() + _async_w3 = Web3( + provider, + modules={'eth': [AsyncEth]}, + middlewares=provider.middlewares + ) + return _async_w3 + + +async def async_deploy(async_w3, Factory, from_address, args=None): + args = args or [] + factory = Factory(async_w3) + deploy_txn = await factory.constructor(*args).transact({'from': from_address}) + deploy_receipt = await async_w3.eth.wait_for_transaction_receipt(deploy_txn) + assert deploy_receipt is not None + return factory(address=deploy_receipt['contractAddress']) + + +def async_default_reverse_resolver(async_w3): + return async_w3.eth.contract( + bytecode=reverse_resolver_bytecode, + bytecode_runtime=reverse_resolver_bytecode_runtime, + abi=reverse_resolver_abi, + ContractFactoryClass=AsyncContract, + ) + + +def async_reverse_registrar(async_w3): + return async_w3.eth.contract( + bytecode=reverse_registrar_bytecode, + bytecode_runtime=reverse_registrar_bytecode_runtime, + abi=reverse_registrar_abi, + ContractFactoryClass=AsyncContract, + ) + + +def async_pubilc_resolver_factory(async_w3): + return async_w3.eth.contract( + bytecode=resolver_bytecode, + bytecode_runtime=resolver_bytecode_runtime, + abi=resolver_abi, + ContractFactoryClass=AsyncContract, + ) + + +def async_simple_resolver(async_w3): + return async_w3.eth.contract( + bytecode=simple_resolver_bytecode, + bytecode_runtime=simple_resolver_bytecode_runtime, + abi=simple_resolver_abi, + ContractFactoryClass=AsyncContract, + ) + + +def async_extended_resolver(async_w3): + return async_w3.eth.contract( + bytecode=extended_resolver_bytecode, + bytecode_runtime=extended_resolver_bytecode_runtime, + abi=extended_resolver_abi, + ContractFactoryClass=AsyncContract, + ) + + +def async_offchain_resolver(async_w3): + return async_w3.eth.contract( + bytecode=offchain_resolver_bytecode, + bytecode_runtime=offchain_resolver_bytecode_runtime, + abi=offchain_resolver_abi, + ContractFactoryClass=AsyncContract, + ) + + +def async_ENS_factory(async_w3): + return async_w3.eth.contract( + bytecode="6060604052341561000f57600080fd5b60008080526020527fad3228b676f7d3cd4284a5443f17f1962b36e491b30a40b2405849e597ba5fb58054600160a060020a033316600160a060020a0319909116179055610501806100626000396000f300606060405236156100805763ffffffff7c01000000000000000000000000000000000000000000000000000000006000350416630178b8bf811461008557806302571be3146100b757806306ab5923146100cd57806314ab9038146100f457806316a25cbd146101175780631896f70a1461014a5780635b0fc9c31461016c575b600080fd5b341561009057600080fd5b61009b60043561018e565b604051600160a060020a03909116815260200160405180910390f35b34156100c257600080fd5b61009b6004356101ac565b34156100d857600080fd5b6100f2600435602435600160a060020a03604435166101c7565b005b34156100ff57600080fd5b6100f260043567ffffffffffffffff60243516610289565b341561012257600080fd5b61012d600435610355565b60405167ffffffffffffffff909116815260200160405180910390f35b341561015557600080fd5b6100f2600435600160a060020a036024351661038c565b341561017757600080fd5b6100f2600435600160a060020a0360243516610432565b600090815260208190526040902060010154600160a060020a031690565b600090815260208190526040902054600160a060020a031690565b600083815260208190526040812054849033600160a060020a039081169116146101f057600080fd5b8484604051918252602082015260409081019051908190039020915083857fce0457fe73731f824cc272376169235128c118b49d344817417c6d108d155e8285604051600160a060020a03909116815260200160405180910390a3506000908152602081905260409020805473ffffffffffffffffffffffffffffffffffffffff1916600160a060020a03929092169190911790555050565b600082815260208190526040902054829033600160a060020a039081169116146102b257600080fd5b827f1d4f9bbfc9cab89d66e1a1562f2233ccbf1308cb4f63de2ead5787adddb8fa688360405167ffffffffffffffff909116815260200160405180910390a250600091825260208290526040909120600101805467ffffffffffffffff90921674010000000000000000000000000000000000000000027fffffffff0000000000000000ffffffffffffffffffffffffffffffffffffffff909216919091179055565b60009081526020819052604090206001015474010000000000000000000000000000000000000000900467ffffffffffffffff1690565b600082815260208190526040902054829033600160a060020a039081169116146103b557600080fd5b827f335721b01866dc23fbee8b6b2c7b1e14d6f05c28cd35a2c934239f94095602a083604051600160a060020a03909116815260200160405180910390a250600091825260208290526040909120600101805473ffffffffffffffffffffffffffffffffffffffff1916600160a060020a03909216919091179055565b600082815260208190526040902054829033600160a060020a0390811691161461045b57600080fd5b827fd4735d920b0f87494915f556dd9b54c8f309026070caea5c737245152564d26683604051600160a060020a03909116815260200160405180910390a250600091825260208290526040909120805473ffffffffffffffffffffffffffffffffffffffff1916600160a060020a039092169190911790555600a165627a7a7230582050975b6c54a16d216b563f4c4960d6ebc5881eb1ec73c2ef1f87920a251159530029", # noqa: E501 + bytecode_runtime="606060405236156100805763ffffffff7c01000000000000000000000000000000000000000000000000000000006000350416630178b8bf811461008557806302571be3146100b757806306ab5923146100cd57806314ab9038146100f457806316a25cbd146101175780631896f70a1461014a5780635b0fc9c31461016c575b600080fd5b341561009057600080fd5b61009b60043561018e565b604051600160a060020a03909116815260200160405180910390f35b34156100c257600080fd5b61009b6004356101ac565b34156100d857600080fd5b6100f2600435602435600160a060020a03604435166101c7565b005b34156100ff57600080fd5b6100f260043567ffffffffffffffff60243516610289565b341561012257600080fd5b61012d600435610355565b60405167ffffffffffffffff909116815260200160405180910390f35b341561015557600080fd5b6100f2600435600160a060020a036024351661038c565b341561017757600080fd5b6100f2600435600160a060020a0360243516610432565b600090815260208190526040902060010154600160a060020a031690565b600090815260208190526040902054600160a060020a031690565b600083815260208190526040812054849033600160a060020a039081169116146101f057600080fd5b8484604051918252602082015260409081019051908190039020915083857fce0457fe73731f824cc272376169235128c118b49d344817417c6d108d155e8285604051600160a060020a03909116815260200160405180910390a3506000908152602081905260409020805473ffffffffffffffffffffffffffffffffffffffff1916600160a060020a03929092169190911790555050565b600082815260208190526040902054829033600160a060020a039081169116146102b257600080fd5b827f1d4f9bbfc9cab89d66e1a1562f2233ccbf1308cb4f63de2ead5787adddb8fa688360405167ffffffffffffffff909116815260200160405180910390a250600091825260208290526040909120600101805467ffffffffffffffff90921674010000000000000000000000000000000000000000027fffffffff0000000000000000ffffffffffffffffffffffffffffffffffffffff909216919091179055565b60009081526020819052604090206001015474010000000000000000000000000000000000000000900467ffffffffffffffff1690565b600082815260208190526040902054829033600160a060020a039081169116146103b557600080fd5b827f335721b01866dc23fbee8b6b2c7b1e14d6f05c28cd35a2c934239f94095602a083604051600160a060020a03909116815260200160405180910390a250600091825260208290526040909120600101805473ffffffffffffffffffffffffffffffffffffffff1916600160a060020a03909216919091179055565b600082815260208190526040902054829033600160a060020a0390811691161461045b57600080fd5b827fd4735d920b0f87494915f556dd9b54c8f309026070caea5c737245152564d26683604051600160a060020a03909116815260200160405180910390a250600091825260208290526040909120805473ffffffffffffffffffffffffffffffffffffffff1916600160a060020a039092169190911790555600a165627a7a7230582050975b6c54a16d216b563f4c4960d6ebc5881eb1ec73c2ef1f87920a251159530029", # noqa: E501 + abi=json.loads('[{"constant":true,"inputs":[{"name":"node","type":"bytes32"}],"name":"resolver","outputs":[{"name":"","type":"address"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"name":"node","type":"bytes32"}],"name":"owner","outputs":[{"name":"","type":"address"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"name":"node","type":"bytes32"},{"name":"label","type":"bytes32"},{"name":"owner","type":"address"}],"name":"setSubnodeOwner","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"name":"node","type":"bytes32"},{"name":"ttl","type":"uint64"}],"name":"setTTL","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[{"name":"node","type":"bytes32"}],"name":"ttl","outputs":[{"name":"","type":"uint64"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"name":"node","type":"bytes32"},{"name":"resolver","type":"address"}],"name":"setResolver","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"name":"node","type":"bytes32"},{"name":"owner","type":"address"}],"name":"setOwner","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"inputs":[],"payable":false,"stateMutability":"nonpayable","type":"constructor"},{"anonymous":false,"inputs":[{"indexed":true,"name":"node","type":"bytes32"},{"indexed":true,"name":"label","type":"bytes32"},{"indexed":false,"name":"owner","type":"address"}],"name":"NewOwner","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"name":"node","type":"bytes32"},{"indexed":false,"name":"owner","type":"address"}],"name":"Transfer","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"name":"node","type":"bytes32"},{"indexed":false,"name":"resolver","type":"address"}],"name":"NewResolver","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"name":"node","type":"bytes32"},{"indexed":false,"name":"ttl","type":"uint64"}],"name":"NewTTL","type":"event"}]'), # noqa: E501 + ContractFactoryClass=AsyncContract, + ) + + +def async_ENS_registry_factory(async_w3): + return async_w3.eth.contract( + bytecode=registrar_bytecode, + bytecode_runtime=registrar_bytecode_runtime, + abi=registrar_abi, + ContractFactoryClass=AsyncContract, + ) + + +@pytest.fixture(scope="module") +def event_loop(): + return asyncio.get_event_loop() + + +# add module scope with above module-scoped `event_loop` for better performance +@pytest_asyncio.fixture(scope="module") +async def async_ens_setup(async_w3): + async_w3.eth.default_account = await async_w3.eth.coinbase + + # ** Set up ENS contracts ** + + # remove account that creates ENS, so test transactions don't have write access + accounts = await async_w3.eth.accounts + ens_key = accounts.pop() + + # create ENS contract + eth_labelhash = async_w3.keccak(text='eth') + eth_namehash = bytes32(0x93cdeb708b7545dc668eb9280176169d1c33cfd8ed6f04690a0bcc88a93fc4ae) + resolver_namehash = bytes32(0xfdd5d5de6dd63db72bbc2d487944ba13bf775b50a80805fe6fcaba9b0fba88f5) + reverse_tld_namehash = bytes32(0xa097f6721ce401e757d1223a763fef49b8b5f90bb18567ddb86fd205dff71d34) # noqa: E501 + reverser_namehash = bytes32(0x91d1777781884d03a6757a803996e38de2a42967fb37eeaca72729271025a9e2) + ens_contract = await async_deploy(async_w3, async_ENS_factory, ens_key) + + # create public resolver + public_resolver = await async_deploy(async_w3, async_pubilc_resolver_factory, + ens_key, args=[ens_contract.address]) + + # set 'resolver.eth' to resolve to public resolver + await ens_contract.functions.setSubnodeOwner( + b'\0' * 32, + eth_labelhash, + ens_key + ).transact({'from': ens_key}) + + await ens_contract.functions.setSubnodeOwner( + eth_namehash, + async_w3.keccak(text='resolver'), + ens_key + ).transact({'from': ens_key}) + + await ens_contract.functions.setResolver( + resolver_namehash, + public_resolver.address + ).transact({'from': ens_key}) + + await public_resolver.functions.setAddr( + resolver_namehash, + public_resolver.address + ).transact({'from': ens_key}) + + # create .eth registrar + eth_registrar = await async_deploy( + async_w3, + async_ENS_registry_factory, + ens_key, + args=[ens_contract.address, eth_namehash], + ) + + # set '.eth' to resolve to the registrar + await ens_contract.functions.setResolver( + eth_namehash, + public_resolver.address + ).transact({'from': ens_key}) + + await public_resolver.functions.setAddr( + eth_namehash, + eth_registrar.address + ).transact({'from': ens_key}) + + # create reverse resolver + reverse_resolver = await async_deploy(async_w3, async_default_reverse_resolver, + ens_key, args=[ens_contract.address]) + + # create reverse registrar + reverse_registrar = await async_deploy( + async_w3, + async_reverse_registrar, + ens_key, + args=[ens_contract.address, reverse_resolver.address] + ) + + # set 'addr.reverse' to resolve to reverse registrar + await ens_contract.functions.setSubnodeOwner( + b'\0' * 32, + async_w3.keccak(text='reverse'), + ens_key + ).transact({'from': ens_key}) + + await ens_contract.functions.setSubnodeOwner( + reverse_tld_namehash, + async_w3.keccak(text='addr'), + ens_key + ).transact({'from': ens_key}) + + await ens_contract.functions.setResolver( + reverser_namehash, + public_resolver.address + ).transact({'from': ens_key}) + + await public_resolver.functions.setAddr( + reverser_namehash, + reverse_registrar.address + ).transact({'from': ens_key}) + + # set owner of tester.eth to an account controlled by tests + second_accounts = await async_w3.eth.accounts + second_account = second_accounts[2] + + await ens_contract.functions.setSubnodeOwner( + eth_namehash, + async_w3.keccak(text='tester'), + second_account # note that this does not have to be the default, only in the list + ).transact({'from': ens_key}) + + # --- setup simple resolver example --- # + + # create simple resolver + simple_resolver = await async_deploy(async_w3, async_simple_resolver, + ens_key, args=[ens_contract.address]) + + # set owner of simple-resolver.eth to an account controlled by tests + await ens_contract.functions.setSubnodeOwner( + eth_namehash, + async_w3.keccak(text='simple-resolver'), + second_account + ).transact({'from': ens_key}) + + # ns.namehash('simple-resolver.eth') + simple_resolver_namehash = bytes32( + 0x65db4c1c4f4ab9e6917fa7896ce546b1fe03e9341e98187e3917afb60aa9835a + ) + + await ens_contract.functions.setResolver( + simple_resolver_namehash, + simple_resolver.address + ).transact({'from': second_account}) + + # --- setup extended resolver example --- # + + # create extended resolver + extended_resolver = await async_deploy(async_w3, async_extended_resolver, + ens_key, args=[ens_contract.address]) + + # set owner of extended-resolver.eth to an account controlled by tests + await ens_contract.functions.setSubnodeOwner( + eth_namehash, + async_w3.keccak(text='extended-resolver'), + second_account + ).transact({'from': ens_key}) + + # ns.namehash('extended-resolver.eth') + extended_resolver_namehash = bytes32( + 0xf0a378cc2afe91730d0105e67d6bb037cc5b8b6bfec5b5962d9b637ff6497e55 + ) + + await ens_contract.functions.setResolver( + extended_resolver_namehash, + extended_resolver.address + ).transact({'from': second_account}) + + # --- setup offchain resolver example --- # + + # create offchain resolver + offchain_resolver = await async_deploy( + async_w3, async_offchain_resolver, ens_key, + + # use a made up url and mock the call to this endpoint in tests + args=[ + [ + "https://web3.py/gateway/{sender}/{data}.json", # for GET request testing + "https://web3.py/gateway/{sender}.json", # for POST request testing + ], + [to_checksum_address('0x4c40caf7f24a545095299972c381862469b080fb')] + ] + ) + + # set owner of offchainexample.eth to an account controlled by tests + await ens_contract.functions.setSubnodeOwner( + eth_namehash, + async_w3.keccak(text='offchainexample'), + second_account + ).transact({'from': ens_key}) + + # ns.namehash('offchainexample.eth') + offchain_example_namehash = bytes32( + 0x42041b0018edd29d7c17154b0c671acc0502ea0b3693cafbeadf58e6beaaa16c + ) + + await ens_contract.functions.setResolver( + offchain_example_namehash, + offchain_resolver.address + ).transact({'from': second_account}) + + # --- finish setup --- # + + # make the registrar the owner of the 'eth' name + await ens_contract.functions.setSubnodeOwner( + b'\0' * 32, + eth_labelhash, + eth_registrar.address + ).transact({'from': ens_key}) + + # make the reverse registrar the owner of the 'addr.reverse' name + await ens_contract.functions.setSubnodeOwner( + reverse_tld_namehash, + async_w3.keccak(text='addr'), + reverse_registrar.address + ).transact({'from': ens_key}) + + return await AsyncENSFactory.fromWeb3(async_w3, ens_contract.address) + + +@pytest_asyncio.fixture +async def async_ens(async_ens_setup, mocker): mocker.patch('web3.middleware.stalecheck._isfresh', return_value=True) - ens_setup.w3.eth.default_account = ens_setup.w3.eth.coinbase - return ens_setup + async_ens_setup.w3.eth.default_account = await async_ens_setup.w3.eth.coinbase + return async_ens_setup @pytest.fixture() diff --git a/tests/ens/test_ens.py b/tests/ens/test_ens.py index ae552f2785..f8e9cbb568 100644 --- a/tests/ens/test_ens.py +++ b/tests/ens/test_ens.py @@ -1,5 +1,11 @@ -from ens import ENS +import pytest + +from ens import ( + ENS, + AsyncENSFactory, +) from web3.middleware import ( + async_validation_middleware, pythonic_middleware, ) @@ -10,3 +16,14 @@ def test_fromWeb3_inherits_web3_middlewares(w3): ns = ENS.fromWeb3(w3) assert ns.w3.middleware_onion.get('test_middleware') == test_middleware + + +# -- async -- # + +@pytest.mark.asyncio +async def test_async_ens_factory_fromWeb3_inherits_web3_middlewares(async_w3): + test_middleware = async_validation_middleware + async_w3.middleware_onion.add(test_middleware, 'test_middleware') + + ns = await AsyncENSFactory.fromWeb3(async_w3) + assert ns.w3.middleware_onion.get('test_middleware') == test_middleware diff --git a/tests/ens/test_get_text.py b/tests/ens/test_get_text.py index db2624efe9..97d29415ce 100644 --- a/tests/ens/test_get_text.py +++ b/tests/ens/test_get_text.py @@ -10,14 +10,23 @@ ) from web3 import Web3 - -@pytest.mark.parametrize('key,expected', ( +SET_TEXT_RESOLVER_NOT_FOUND_CASES = ( + ('avatar', 'tester.jpeg'), + ('email', 'user@example.com'), + ('url', 'http://example.com'), + ('description', 'a test'), + ('notice', 'this contract is a test contract'), +) +GET_TEXT_TEST_CASES = ( ('avatar', 'tester.jpeg'), ('email', 'user@example.com'), ('url', 'http://example.com'), ('description', 'a test'), ('notice', 'this contract is a test contract'), -),) +) + + +@pytest.mark.parametrize('key,expected', SET_TEXT_RESOLVER_NOT_FOUND_CASES) def test_set_text_resolver_not_found(ens, key, expected): with pytest.raises(ResolverNotFound): ens.set_text('tld', key, expected) @@ -30,6 +39,9 @@ def test_set_text_fails_with_bad_address(ens): with pytest.raises(TransactionFailed): ens.set_text('tester.eth', 'url', 'http://example.com', transact={'from': zero_address}) + # teardown + ens.setup_address('tester.eth', None) + def test_set_text_pass_in_transaction_dict(ens): address = ens.w3.eth.accounts[2] @@ -51,13 +63,7 @@ def test_set_text_pass_in_transaction_dict(ens): ens.setup_address('tester.eth', None) -@pytest.mark.parametrize('key,expected', ( - ('avatar', 'tester.jpeg'), - ('email', 'user@example.com'), - ('url', 'http://example.com'), - ('description', 'a test'), - ('notice', 'this contract is a test contract'), -),) +@pytest.mark.parametrize('key,expected', GET_TEXT_TEST_CASES) def test_get_text(ens, key, expected): address = ens.w3.eth.accounts[2] ens.setup_address('tester.eth', address) @@ -78,3 +84,79 @@ def test_get_text_resolver_not_found(ens): def test_get_text_for_resolver_with_unsupported_function(ens): with pytest.raises(UnsupportedFunction, match="does not support `text` function"): ens.get_text('simple-resolver.eth', 'any_key') + + +# -- async -- # + +@pytest.mark.asyncio +@pytest.mark.parametrize('key,expected', SET_TEXT_RESOLVER_NOT_FOUND_CASES) +async def test_async_set_text_resolver_not_found(async_ens, key, expected): + with pytest.raises(ResolverNotFound): + await async_ens.set_text('tld', key, expected) + + +@pytest.mark.asyncio +async def test_async_set_text_fails_with_bad_address(async_ens): + accounts = await async_ens.w3.eth.accounts + address = accounts[2] + await async_ens.setup_address('tester.eth', address) + zero_address = '0x' + '00' * 20 + with pytest.raises(TransactionFailed): + await async_ens.set_text( + 'tester.eth', + 'url', 'http://example.com', + transact={'from': zero_address} + ) + + # teardown + await async_ens.setup_address('tester.eth', None) + + +@pytest.mark.asyncio +async def async_test_set_text_pass_in_transaction_dict(async_ens): + accounts = await async_ens.w3.eth.accounts + address = accounts[2] + + await async_ens.setup_address('tester.eth', address) + await async_ens.set_text('tester.eth', 'url', 'http://example.com', transact={'from': address}) + await async_ens.set_text( + 'tester.eth', + 'avatar', + 'example.jpeg', + transact={ + 'maxFeePerGas': Web3.toWei(100, 'gwei'), + 'maxPriorityFeePerGas': Web3.toWei(100, 'gwei'), + } + ) + assert await async_ens.get_text('tester.eth', 'url') == 'http://example.com' + assert await async_ens.get_text('tester.eth', 'avatar') == 'example.jpeg' + + # teardown + await async_ens.setup_address('tester.eth', None) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('key,expected', GET_TEXT_TEST_CASES) +async def test_async_get_text(async_ens, key, expected): + accounts = await async_ens.w3.eth.accounts + address = accounts[2] + await async_ens.setup_address('tester.eth', address) + owner = await async_ens.owner('tester.eth') + assert address == owner + await async_ens.set_text('tester.eth', key, expected) + assert await async_ens.get_text('tester.eth', key) == expected + + # teardown + await async_ens.setup_address('tester.eth', None) + + +@pytest.mark.asyncio +async def test_async_get_text_resolver_not_found(async_ens): + with pytest.raises(ResolverNotFound): + await async_ens.get_text('tld', 'any_key') + + +@pytest.mark.asyncio +async def test_async_get_text_for_resolver_with_unsupported_function(async_ens): + with pytest.raises(UnsupportedFunction, match="does not support `text` function"): + await async_ens.get_text('simple-resolver.eth', 'any_key') diff --git a/tests/ens/test_nameprep.py b/tests/ens/test_nameprep.py index 4ef7ef2608..04ebe25e03 100644 --- a/tests/ens/test_nameprep.py +++ b/tests/ens/test_nameprep.py @@ -34,3 +34,35 @@ def test_nameprep_std3_rules(ens, url): with pytest.raises(InvalidName, match=f'{url} is an invalid name'): ens.nameprep(url) + + +# -- async -- # + +# note: `nameprep` isn't an async method, but rather test that nameprep is +# available to AsyncENS and passes all tests + +def test_async_nameprep_basic_unicode(async_ens): + assert async_ens.nameprep("öbb.at") == "öbb.at" + assert async_ens.nameprep("Öbb.at") == "öbb.at" + assert async_ens.nameprep("O\u0308bb.at") == "öbb.at" + assert async_ens.nameprep("faß.de") == "faß.de" + assert async_ens.nameprep("fass.de") == "fass.de" + assert async_ens.nameprep("🌈rainbow.eth") == "🌈rainbow.eth" + assert async_ens.nameprep("🐔🐔.tk") == "🐔🐔.tk" + assert async_ens.nameprep("√.com") == "√.com" + assert async_ens.nameprep("ԛәлп.com") == "ԛәлп.com" + assert async_ens.nameprep("test\u200btest.com") == "testtest.com" + assert async_ens.nameprep("-test.com") == "-test.com" + assert async_ens.nameprep("1test.com") == "1test.com" + assert async_ens.nameprep("test.1com") == "test.1com" + + +@pytest.mark.parametrize( + 'url', [ + ('not=std3'), + ('not_std3.eth'), # underscores not allowed + ] +) +def test_async_nameprep_std3_rules(async_ens, url): + with pytest.raises(InvalidName, match=f'{url} is an invalid name'): + async_ens.nameprep(url) diff --git a/tests/ens/test_offchain_resolution.py b/tests/ens/test_offchain_resolution.py index 4143a90dd1..7c815626f4 100644 --- a/tests/ens/test_offchain_resolution.py +++ b/tests/ens/test_offchain_resolution.py @@ -1,5 +1,8 @@ import pytest +from aiohttp import ( + ClientSession, +) import requests from ens.utils import ( @@ -70,6 +73,44 @@ def raise_for_status(): pass # noqa: E704 def json(): return {'not_data': OFFCHAIN_RESOLVER_DATA} # noqa: E704 +class AsyncMockHttpSuccessResponse: + status_code = 200 + + def __init__(self, request_type, *args, **_kwargs): + # validate the expected urls + if request_type == 'get': + assert args[1] == EXPECTED_GET_URL + elif request_type == 'post': + assert args[1] == EXPECTED_POST_URL + + @staticmethod + def raise_for_status(): pass # noqa: E704 + + @staticmethod + async def json(): return {'data': OFFCHAIN_RESOLVER_DATA} # noqa: E704 + + @property + def status(self): + return self.status_code + + +class AsyncMockHttpBadFormatResponse: + status_code = 200 + + def __init__(self, *args): + assert args[1] == EXPECTED_GET_URL + + @staticmethod + def raise_for_status(): pass # noqa: E704 + + @staticmethod + async def json(): return {'not_data': OFFCHAIN_RESOLVER_DATA} # noqa: E704' + + @property + def status(self): + return self.status_code + + def test_offchain_resolution_with_get_request(ens, monkeypatch): # mock GET response with real return data from 'offchainexample.eth' resolver def mock_get(*args, **kwargs): @@ -123,3 +164,48 @@ def test_offchain_resolver_function_call_raises_with_ccip_read_disabled(ens, mon ens_encode_name('offchainexample.eth'), ENCODED_ADDR_CALLDATA, ) + + +# -- async -- # + + +@pytest.mark.asyncio +async def test_async_offchain_resolution_with_get_request(async_ens, monkeypatch): + # mock GET response with real return data from 'offchainexample.eth' resolver + async def mock_get(*args, **kwargs): + return AsyncMockHttpSuccessResponse('get', *args, **kwargs) + + monkeypatch.setattr(ClientSession, 'get', mock_get) + + assert await async_ens.address('offchainexample.eth') == EXPECTED_RESOLVED_ADDRESS + + +@pytest.mark.asyncio +async def test_async_offchain_resolution_with_post_request(async_ens, monkeypatch): + # mock POST response with real return data from 'offchainexample.eth' resolver + async def mock_post(*args, **kwargs): + return AsyncMockHttpSuccessResponse('post', *args, **kwargs) + + monkeypatch.setattr(ClientSession, 'post', mock_post) + + assert await async_ens.address('offchainexample.eth') == EXPECTED_RESOLVED_ADDRESS + + +@pytest.mark.asyncio +async def test_async_offchain_resolution_raises_when_all_supplied_urls_fail(async_ens): + # with no mocked responses, requests to all urls will fail + with pytest.raises(Exception, match='Offchain lookup failed for supplied urls.'): + await async_ens.address('offchainexample.eth') + + +@pytest.mark.asyncio +async def test_async_offchain_resolution_with_improperly_formatted_http_response(async_ens, + monkeypatch): + async def mock_get(*args, **_): + return AsyncMockHttpBadFormatResponse(*args) + + monkeypatch.setattr(ClientSession, 'get', mock_get) + with pytest.raises(ValidationError, match=( + "Improperly formatted response for offchain lookup HTTP request - missing 'data' field." + )): + await async_ens.address('offchainexample.eth') diff --git a/tests/ens/test_setup_address.py b/tests/ens/test_setup_address.py index 4d1ab182fd..ef84276d3d 100644 --- a/tests/ens/test_setup_address.py +++ b/tests/ens/test_setup_address.py @@ -11,12 +11,12 @@ to_bytes, ) +from ens import ( + UnauthorizedError, +) from ens.constants import ( EMPTY_ADDR_HEX, ) -from ens.main import ( - UnauthorizedError, -) from web3 import Web3 @@ -158,3 +158,17 @@ def test_set_resolver_leave_default(ens, TEST_ADDRESS): # should skip setting the owner and setting the default resolver, and only # set the name in the default resolver to point to the new address assert eth.get_transaction_count(owner) == num_transactions + 1 + + +# -- async -- # + +@pytest.mark.asyncio +async def test_async_setup_address(async_ens, TEST_ADDRESS): + await async_ens.setup_address("tester.eth", TEST_ADDRESS) + assert is_same_address(await async_ens.address("tester.eth"), TEST_ADDRESS) + + +@pytest.mark.asyncio +async def test_async_set_address_unauthorized(async_ens, TEST_ADDRESS): + with pytest.raises(UnauthorizedError): + await async_ens.setup_address('eth', TEST_ADDRESS) diff --git a/tests/ens/test_setup_name.py b/tests/ens/test_setup_name.py index f47f48c736..3a5db4a39b 100644 --- a/tests/ens/test_setup_name.py +++ b/tests/ens/test_setup_name.py @@ -4,60 +4,58 @@ HexStr, ) -from ens.main import ( +from ens import ( AddressMismatch, UnauthorizedError, UnownedName, ) from web3 import Web3 - """ API at: https://github.com/carver/ens.py/issues/2 """ +SETUP_NAME_TEST_CASES = [ + ( + "tester.eth", + "tester.eth", + "2a7ac1c833d35677c2ff34a908951de142cc1653de6080ad4e38f4c9cc00aafe", + ), + ( + "TESTER.eth", + "tester.eth", + "2a7ac1c833d35677c2ff34a908951de142cc1653de6080ad4e38f4c9cc00aafe", + ), + ( + "tester.eth", + "tester.eth", + "2a7ac1c833d35677c2ff34a908951de142cc1653de6080ad4e38f4c9cc00aafe", + ), + ( + "tester。eth", + "tester.eth", + "2a7ac1c833d35677c2ff34a908951de142cc1653de6080ad4e38f4c9cc00aafe", + ), + ( + "tester。eth", + "tester.eth", + "2a7ac1c833d35677c2ff34a908951de142cc1653de6080ad4e38f4c9cc00aafe", + ), + # confirm that set-owner works + ( + "lots.of.subdomains.tester.eth", + "lots.of.subdomains.tester.eth", + "0d62a759aa1f1c9680de8603a12a5eb175cd1bfa79426229868eba99f4dce692", + ), +] + @pytest.fixture def TEST_ADDRESS(address_conversion_func): return address_conversion_func("0x000000000000000000000000000000000000dEaD") -@pytest.mark.parametrize( - 'name, normalized_name, namehash_hex', - [ - ( - 'tester.eth', - 'tester.eth', - '2a7ac1c833d35677c2ff34a908951de142cc1653de6080ad4e38f4c9cc00aafe', - ), - ( - 'TESTER.eth', - 'tester.eth', - '2a7ac1c833d35677c2ff34a908951de142cc1653de6080ad4e38f4c9cc00aafe', - ), - ( - 'tester.eth', - 'tester.eth', - '2a7ac1c833d35677c2ff34a908951de142cc1653de6080ad4e38f4c9cc00aafe', - ), - ( - 'tester。eth', - 'tester.eth', - '2a7ac1c833d35677c2ff34a908951de142cc1653de6080ad4e38f4c9cc00aafe', - ), - ( - 'tester。eth', - 'tester.eth', - '2a7ac1c833d35677c2ff34a908951de142cc1653de6080ad4e38f4c9cc00aafe', - ), - # confirm that set-owner works - ( - 'lots.of.subdomains.tester.eth', - 'lots.of.subdomains.tester.eth', - '0d62a759aa1f1c9680de8603a12a5eb175cd1bfa79426229868eba99f4dce692', - ), - ], -) +@pytest.mark.parametrize('name, normalized_name, namehash_hex', SETUP_NAME_TEST_CASES) def test_setup_name(ens, name, normalized_name, namehash_hex): address = ens.w3.eth.accounts[3] assert not ens.name(address) @@ -84,6 +82,7 @@ def test_setup_name(ens, name, normalized_name, namehash_hex): assert ens.address(name) == new_address # forward resolution assert not ens.name(address) + # teardown ens.setup_name(None, address) ens.setup_address(name, None) assert not ens.name(address) @@ -118,6 +117,7 @@ def test_setup_name_default_to_owner(ens): assert not ens.address(name) ens.setup_name(name) assert ens.name(new_owner) == name + ens.setup_name(None, new_owner) def test_setup_name_unowned_exception(ens): @@ -146,3 +146,114 @@ def test_setup_reverse_dict_unmodified(ens): # teardown ens.setup_name(None, address, transact=transact) + + +# -- async -- # + +@pytest.mark.asyncio +@pytest.mark.parametrize('name, normalized_name, namehash_hex', SETUP_NAME_TEST_CASES) +async def test_async_setup_name(async_ens, name, normalized_name, namehash_hex): + accounts = await async_ens.w3.eth.accounts + address = accounts[3] + + assert not await async_ens.name(address) + owner = await async_ens.owner('tester.eth') + + await async_ens.setup_name(name, address) + assert await async_ens.name(address) == normalized_name + + # check that the correct namehash is set: + node = Web3.toBytes(hexstr=HexStr(namehash_hex)) + resolver = await async_ens.resolver(normalized_name) + assert await resolver.caller.addr(node) == address + + # check that the correct owner is set: + assert await async_ens.owner(name) == owner + + # setup name to point to new address + new_address = accounts[4] + await async_ens.setup_address(name, None) + await async_ens.setup_name(name, new_address) + + # validate that ens.name() only returns a name if the forward resolution also returns the + # address + assert await async_ens.name(new_address) == normalized_name # reverse resolution + assert await async_ens.address(name) == new_address # forward resolution + assert not await async_ens.name(address) + + # teardown + await async_ens.setup_name(None, address) + await async_ens.setup_address(name, None) + assert not await async_ens.name(address) + assert not await async_ens.address(name) + + +@pytest.mark.asyncio +async def test_async_setup_name_default_address(async_ens): + name = 'reverse-defaults-to-forward.tester.eth' + owner = await async_ens.owner('tester.eth') + + accounts = await async_ens.w3.eth.accounts + new_resolution = accounts[-1] + + await async_ens.setup_address(name, new_resolution) + assert not await async_ens.name(new_resolution) + assert await async_ens.owner(name) == owner + assert await async_ens.address(name) == new_resolution + await async_ens.setup_name(name) + assert await async_ens.name(new_resolution) == name + await async_ens.setup_name(None, new_resolution) + + +@pytest.mark.asyncio +async def test_async_setup_name_default_to_owner(async_ens): + name = 'reverse-defaults-to-owner.tester.eth' + accounts = await async_ens.w3.eth.accounts + new_owner = accounts[-1] + + await async_ens.setup_owner(name, new_owner) + assert not await async_ens.name(new_owner) + assert await async_ens.owner(name) == new_owner + assert not await async_ens.address(name) + await async_ens.setup_name(name) + assert await async_ens.name(new_owner) == name + + +@pytest.mark.asyncio +async def test_async_setup_reverse_dict_unmodified(async_ens): + # setup + owner = await async_ens.owner('tester.eth') + eth = async_ens.w3.eth + start_count = await eth.get_transaction_count(owner) + + accounts = await eth.accounts + address = accounts[3] + transact = {} + await async_ens.setup_name('tester.eth', address, transact=transact) + + # even though a transaction was issued, the dict argument was not modified + assert await eth.get_transaction_count(owner) > start_count + assert transact == {} + + # teardown + await async_ens.setup_name(None, address, transact=transact) + + +@pytest.mark.asyncio +async def test_async_setup_name_unowned_exception(async_ens): + with pytest.raises(UnownedName): + await async_ens.setup_name('unowned-name.tester.eth') + + +@pytest.mark.asyncio +async def test_async_setup_name_unauthorized(async_ens, TEST_ADDRESS): + with pytest.raises(UnauthorizedError): + await async_ens.setup_name('root-owned-tld', TEST_ADDRESS) + + +@pytest.mark.asyncio +async def test_async_cannot_set_name_on_mismatch_address(async_ens, TEST_ADDRESS): + await async_ens.setup_address('mismatch-reverse.tester.eth', TEST_ADDRESS) + with pytest.raises(AddressMismatch): + await async_ens.setup_name('mismatch-reverse.tester.eth', + '0xBB9bc244D798123fDe783fCc1C72d3Bb8C189413') diff --git a/tests/ens/test_utils.py b/tests/ens/test_utils.py index f384cdb709..f13b9beef7 100644 --- a/tests/ens/test_utils.py +++ b/tests/ens/test_utils.py @@ -6,9 +6,13 @@ ) from ens.utils import ( + async_init_web3, ens_encode_name, init_web3, ) +from web3.eth import ( + AsyncEth, +) def test_init_adds_middlewares(): @@ -96,3 +100,18 @@ def test_ens_encode_name_normalizes_name_before_encoding(): assert ens_encode_name('TESTER.eth') == ens_encode_name('tester.eth') assert ens_encode_name('test\u200btest.com') == ens_encode_name('testtest.com') assert ens_encode_name("O\u0308bb.at") == ens_encode_name("öbb.at") + + +# -- async -- # + +@pytest.mark.asyncio +async def test_async_init_adds_async_middlewares(): + async_w3 = await async_init_web3() + middlewares = map(str, async_w3.manager.middleware_onion) + assert 'stalecheck_middleware' in next(middlewares) + + +@pytest.mark.asyncio +async def test_async_init_adds_async_eth(): + async_w3 = await async_init_web3() + assert isinstance(async_w3.eth, AsyncEth) diff --git a/tests/ens/test_wildcard_resolution.py b/tests/ens/test_wildcard_resolution.py index bf1a658ffe..3b765825de 100644 --- a/tests/ens/test_wildcard_resolution.py +++ b/tests/ens/test_wildcard_resolution.py @@ -10,9 +10,32 @@ def test_wildcard_resolution_with_extended_resolver_for_subdomains(ens, subdomai assert resolved_child_address == '0x000000000000000000000000000000000000dEaD' -def test_wildcard_resolution_with_extended_resolver_for_parent_ens_domain(ens): +def test_async_wildcard_resolution_with_extended_resolver_for_parent_ens_domain(ens): # validate `extended-resolver.eth` by asserting it returns the specified hard-coded address from # `tests/test_contracts/ExtendedResolver.sol` which requires a specific condition to be # met for the parent domain `extended-resolver.eth` resolved_parent_address = ens.address('extended-resolver.eth') assert resolved_parent_address == '0x000000000000000000000000000000000000bEEF' + + +# -- async -- # + + +@pytest.mark.asyncio +@pytest.mark.parametrize('subdomain', ('sub1', 'sub2', 'rändöm', '🌈rainbow', 'faß')) +async def test_async_wildcard_resolution_with_extended_resolver_for_subdomains(async_ens, + subdomain): + # validate subdomains of `extended-resolver.eth` by asserting it returns the specified + # hard-coded address from `tests/test_contracts/ExtendedResolver.sol` which requires + # certain conditions to be met that are specific to subdomains only + resolved_child_address = await async_ens.address(f'{subdomain}.extended-resolver.eth') + assert resolved_child_address == '0x000000000000000000000000000000000000dEaD' + + +@pytest.mark.asyncio +async def test_wildcard_resolution_with_extended_resolver_for_parent_ens_domain(async_ens): + # validate `extended-resolver.eth` by asserting it returns the specified hard-coded address from + # `tests/test_contracts/ExtendedResolver.sol` which requires a specific condition to be + # met for the parent domain `extended-resolver.eth` + resolved_parent_address = await async_ens.address('extended-resolver.eth') + assert resolved_parent_address == '0x000000000000000000000000000000000000bEEF' diff --git a/web3/main.py b/web3/main.py index e73c46a331..d33dd379a8 100644 --- a/web3/main.py +++ b/web3/main.py @@ -235,7 +235,7 @@ def __init__( middlewares: Optional[Sequence[Any]] = None, modules: Optional[Dict[str, Union[Type[Module], Sequence[Any]]]] = None, external_modules: Optional[Dict[str, Union[Type[Module], Sequence[Any]]]] = None, - ens: ENS = cast(ENS, empty) + ens: Optional[ENS] = cast(ENS, empty) ) -> None: self.manager = self.RequestManager(self, provider, middlewares) # this codec gets used in the module initialization, diff --git a/web3/middleware/__init__.py b/web3/middleware/__init__.py index 6028d327dd..0c1fbbc04d 100644 --- a/web3/middleware/__init__.py +++ b/web3/middleware/__init__.py @@ -68,6 +68,7 @@ ) from .stalecheck import ( # noqa: F401 make_stalecheck_middleware, + async_make_stalecheck_middleware, ) from .validation import ( # noqa: F401 async_validation_middleware, diff --git a/web3/middleware/stalecheck.py b/web3/middleware/stalecheck.py index db90584feb..efbf601fd9 100644 --- a/web3/middleware/stalecheck.py +++ b/web3/middleware/stalecheck.py @@ -72,3 +72,45 @@ def middleware(method: RPCEndpoint, params: Any) -> RPCResponse: return middleware return stalecheck_middleware + + +async def async_make_stalecheck_middleware( + allowable_delay: int, + skip_stalecheck_for_methods: Collection[str] = SKIP_STALECHECK_FOR_METHODS, +) -> Middleware: + """ + Use to require that a function will run only of the blockchain is recently updated. + + This middleware takes an argument, so unlike other middleware, you must make the middleware + with a method call. + For example: `make_stalecheck_middleware(60*5)` + + If the latest block in the chain is older than 5 minutes in this example, then the + middleware will raise a StaleBlockchain exception. + """ + if allowable_delay <= 0: + raise ValueError( + "You must set a positive allowable_delay in seconds for this middleware" + ) + + async def stalecheck_middleware( + make_request: Callable[[RPCEndpoint, Any], Any], w3: "Web3" + ) -> Callable[[RPCEndpoint, Any], RPCResponse]: + cache: Dict[str, BlockData] = {"latest": None} + + async def middleware(method: RPCEndpoint, params: Any) -> RPCResponse: + if method not in skip_stalecheck_for_methods: + if _isfresh(cache["latest"], allowable_delay): + pass + else: + latest = await w3.eth.get_block("latest") # type: ignore + if _isfresh(latest, allowable_delay): + cache["latest"] = latest + else: + raise StaleBlockchain(latest, allowable_delay) + + return await make_request(method, params) + + return middleware # type: ignore + + return stalecheck_middleware