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..3837f58b50 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 fully implemented for the Async provider. + 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..9154debb56 100644 --- a/ens/__init__.py +++ b/ens/__init__.py @@ -1,6 +1,12 @@ # flake8: noqa -from .main import ( +from .async_ens import ( + AsyncENS, +) +from .base_ens import ( + BaseENS, +) +from .ens import ( ENS, ) diff --git a/ens/async_ens.py b/ens/async_ens.py new file mode 100644 index 0000000000..3e56350171 --- /dev/null +++ b/ens/async_ens.py @@ -0,0 +1,546 @@ +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, + default, + ens_encode_name, + init_async_web3, + 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 + AsyncBaseProvider, + BaseProvider, + ) + from web3.types import ( # noqa: F401 + Middleware, + TxParams, + ) + + +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"`` + """ + + def __init__( + self, + provider: "AsyncBaseProvider" = cast("AsyncBaseProvider", default), + addr: ChecksumAddress = None, + middlewares: Optional[Sequence[Tuple["Middleware", str]]] = None, + ) -> None: + """ + :param provider: a single provider used to connect to Ethereum + :type provider: instance of `web3.providers.base.BaseProvider` + :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. + """ + self.w3 = init_async_web3(provider, middlewares) + + ens_addr = addr if addr else ENS_MAINNET_ADDR + self.ens = self.w3.eth.contract(abi=abis.ENS, address=ens_addr) + self._resolver_contract = self.w3.eth.contract(abi=abis.RESOLVER) + self._reverse_resolver_contract = self.w3.eth.contract( + abi=abis.REVERSE_RESOLVER + ) + + @classmethod + def fromWeb3(cls, w3: "Web3", addr: ChecksumAddress = None) -> "AsyncENS": + """ + Generate an AsyncENS 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, defaults to the mainnet ENS registry address. + """ + provider = w3.manager.provider + middlewares = w3.middleware_onion.middlewares + return cls( + cast("AsyncBaseProvider", provider), addr=addr, middlewares=middlewares + ) + + 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 82% rename from ens/main.py rename to ens/ens.py index 146448df3a..9efeceae14 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,34 +85,9 @@ 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), + provider: "BaseProvider" = cast("BaseProvider", default), addr: ChecksumAddress = None, middlewares: Optional[Sequence[Tuple['Middleware', str]]] = None, ) -> None: @@ -139,12 +110,14 @@ def fromWeb3(cls, w3: 'Web3', addr: ChecksumAddress = None) -> 'ENS': 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. + :param hex-string addr: the address of the ENS registry on-chain. If not + provided, defaults to the mainnet ENS registry address. """ provider = w3.manager.provider middlewares = w3.middleware_onion.middlewares - return cls(provider, addr=addr, middlewares=middlewares) + return cls( + cast("BaseProvider", provider), addr=addr, middlewares=middlewares + ) def address(self, name: str) -> Optional[ChecksumAddress]: """ @@ -155,48 +128,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 +169,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 +238,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 +252,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 +347,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 +385,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 +457,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 +491,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 +505,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 +518,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..67757659e8 100644 --- a/ens/utils.py +++ b/ens/utils.py @@ -5,6 +5,7 @@ from typing import ( TYPE_CHECKING, Any, + Callable, Collection, Dict, List, @@ -55,11 +56,13 @@ if TYPE_CHECKING: from web3 import Web3 as _Web3 # noqa: F401 from web3.providers import ( # noqa: F401 + AsyncBaseProvider, BaseProvider, ) from web3.types import ( # noqa: F401 ABIFunction, Middleware, + RPCEndpoint, ) @@ -69,20 +72,21 @@ def Web3() -> Type['_Web3']: def init_web3( - provider: 'BaseProvider' = cast('BaseProvider', default), - middlewares: Optional[Sequence[Tuple['Middleware', str]]] = None, -) -> '_Web3': + provider: "BaseProvider" = cast("BaseProvider", default), + 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) -def customize_web3(w3: '_Web3') -> '_Web3': +def customize_web3(w3: "_Web3") -> "_Web3": from web3.middleware import make_stalecheck_middleware if w3.middleware_onion.get('name_to_address'): @@ -93,7 +97,6 @@ def customize_web3(w3: '_Web3') -> '_Web3': make_stalecheck_middleware(ACCEPTABLE_STALE_HOURS * 3600), name='stalecheck' ) - return w3 @@ -154,7 +157,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 +265,43 @@ 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 -- # + + +def init_async_web3( + provider: "AsyncBaseProvider" = cast("AsyncBaseProvider", default), + middlewares: Optional[Sequence[Tuple["Middleware", str]]] = () +) -> "_Web3": + from web3 import Web3 as Web3Main + from web3.eth import AsyncEth as AsyncEthMain + + middlewares = list(middlewares) + for i, (middleware, name) in enumerate(middlewares): + if name == "name_to_address": + middlewares.pop(i) + + if "stalecheck" not in (name for mw, name in middlewares): + middlewares.append((_async_ens_stalecheck_middleware, "stalecheck")) + + if provider is default: + async_w3 = Web3Main( + middlewares=middlewares, ens=None, modules={"eth": (AsyncEthMain)} + ) + else: + async_w3 = Web3Main( + provider, middlewares=middlewares, ens=None, modules={"eth": ( + AsyncEthMain + )}, + ) + + return async_w3 + + +async def _async_ens_stalecheck_middleware( + make_request: Callable[["RPCEndpoint", Any], Any], w3: "_Web3" +) -> "Middleware": + from web3.middleware import async_make_stalecheck_middleware + middleware = await async_make_stalecheck_middleware(ACCEPTABLE_STALE_HOURS * 3600) + return await middleware(make_request, w3) diff --git a/newsfragments/2547.feature.rst b/newsfragments/2547.feature.rst new file mode 100644 index 0000000000..4afeebbcf9 --- /dev/null +++ b/newsfragments/2547.feature.rst @@ -0,0 +1 @@ +Added Async support for ENS \ No newline at end of file diff --git a/tests/core/middleware/test_stalecheck.py b/tests/core/middleware/test_stalecheck.py index f406fa50bf..f0ba75b2ed 100644 --- a/tests/core/middleware/test_stalecheck.py +++ b/tests/core/middleware/test_stalecheck.py @@ -1,10 +1,12 @@ - import pytest +import sys from unittest.mock import ( Mock, patch, ) +import pytest_asyncio + from web3.datastructures import ( AttributeDict, ) @@ -13,7 +15,8 @@ ) from web3.middleware.stalecheck import ( StaleBlockchain, - _isfresh, + _is_fresh, + async_make_stalecheck_middleware, ) @@ -46,32 +49,32 @@ def stub_block(timestamp): def test_is_not_fresh_with_no_block(): - assert not _isfresh(None, 1) + assert not _is_fresh(None, 1) def test_is_not_fresh(now): with patch('time.time', return_value=now): SECONDS_ALLOWED = 2 * 86400 stale = stub_block(now - SECONDS_ALLOWED - 1) - assert not _isfresh(stale, SECONDS_ALLOWED) + assert not _is_fresh(stale, SECONDS_ALLOWED) def test_is_fresh(now): with patch('time.time', return_value=now): SECONDS_ALLOWED = 2 * 86400 stale = stub_block(now - SECONDS_ALLOWED) - assert _isfresh(stale, SECONDS_ALLOWED) + assert _is_fresh(stale, SECONDS_ALLOWED) def test_stalecheck_pass(request_middleware): - with patch('web3.middleware.stalecheck._isfresh', return_value=True): + with patch('web3.middleware.stalecheck._is_fresh', return_value=True): method, params = object(), object() request_middleware(method, params) request_middleware.make_request.assert_called_once_with(method, params) def test_stalecheck_fail(request_middleware, now): - with patch('web3.middleware.stalecheck._isfresh', return_value=False): + with patch('web3.middleware.stalecheck._is_fresh', return_value=False): request_middleware.web3.eth.get_block.return_value = stub_block(now) with pytest.raises(StaleBlockchain): request_middleware('', []) @@ -85,34 +88,130 @@ def test_stalecheck_fail(request_middleware, now): ) def test_stalecheck_ignores_get_by_block_methods(request_middleware, rpc_method): # This is especially critical for get_block('latest') which would cause infinite recursion - with patch('web3.middleware.stalecheck._isfresh', side_effect=[False, True]): + with patch('web3.middleware.stalecheck._is_fresh', side_effect=[False, True]): request_middleware(rpc_method, []) assert not request_middleware.web3.eth.get_block.called -def test_stalecheck_calls_isfresh_with_empty_cache(request_middleware, allowable_delay): - with patch('web3.middleware.stalecheck._isfresh', side_effect=[False, True]) as freshspy: +def test_stalecheck_calls_is_fresh_with_empty_cache(request_middleware, allowable_delay): + with patch( + 'web3.middleware.stalecheck._is_fresh', side_effect=[False, True] + ) as fresh_spy: block = object() request_middleware.web3.eth.get_block.return_value = block request_middleware('', []) - cache_call, live_call = freshspy.call_args_list + cache_call, live_call = fresh_spy.call_args_list assert cache_call[0] == (None, allowable_delay) assert live_call[0] == (block, allowable_delay) def test_stalecheck_adds_block_to_cache(request_middleware, allowable_delay): - with patch('web3.middleware.stalecheck._isfresh', side_effect=[False, True, True]) as freshspy: + with patch( + 'web3.middleware.stalecheck._is_fresh', side_effect=[False, True, True] + ) as fresh_spy: block = object() request_middleware.web3.eth.get_block.return_value = block # cache miss request_middleware('', []) - cache_call, live_call = freshspy.call_args_list - assert freshspy.call_count == 2 + cache_call, live_call = fresh_spy.call_args_list + assert fresh_spy.call_count == 2 assert cache_call == ((None, allowable_delay), ) assert live_call == ((block, allowable_delay), ) # cache hit request_middleware('', []) - assert freshspy.call_count == 3 - assert freshspy.call_args == ((block, allowable_delay), ) + assert fresh_spy.call_count == 3 + assert fresh_spy.call_args == ((block, allowable_delay), ) + + +# -- async -- # + + +min_version = pytest.mark.skipif( + sys.version_info < (3, 8), + reason="AsyncMock requires python3.8 or higher" +) + + +@pytest_asyncio.fixture +async def request_async_middleware(allowable_delay): + from unittest.mock import AsyncMock + middleware = await async_make_stalecheck_middleware(allowable_delay) + make_request, web3 = AsyncMock(), AsyncMock() + initialized = await middleware(make_request, web3) + # for easier mocking, later: + initialized.web3 = web3 + initialized.make_request = make_request + return initialized + + +@pytest.mark.asyncio +@min_version +async def test_async_stalecheck_pass(request_async_middleware): + with patch("web3.middleware.stalecheck._is_fresh", return_value=True): + method, params = object(), object() + await request_async_middleware(method, params) + request_async_middleware.make_request.assert_called_once_with(method, params) + + +@pytest.mark.asyncio +@min_version +async def test_async_stalecheck_fail(request_async_middleware, now): + with patch("web3.middleware.stalecheck._is_fresh", return_value=False): + request_async_middleware.web3.eth.get_block.return_value = stub_block(now) + with pytest.raises(StaleBlockchain): + await request_async_middleware("", []) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("rpc_method", ["eth_getBlockByNumber"]) +@min_version +async def test_async_stalecheck_ignores_get_by_block_methods( + request_async_middleware, rpc_method +): + # This is especially critical for get_block("latest") which would cause + # infinite recursion + with patch("web3.middleware.stalecheck._is_fresh", side_effect=[False, True]): + await request_async_middleware(rpc_method, []) + assert not request_async_middleware.web3.eth.get_block.called + + +@pytest.mark.asyncio +@min_version +async def test_async_stalecheck_calls_is_fresh_with_empty_cache( + request_async_middleware, allowable_delay +): + with patch( + "web3.middleware.stalecheck._is_fresh", side_effect=[False, True] + ) as fresh_spy: + block = object() + request_async_middleware.web3.eth.get_block.return_value = block + await request_async_middleware("", []) + cache_call, live_call = fresh_spy.call_args_list + assert cache_call[0] == (None, allowable_delay) + assert live_call[0] == (block, allowable_delay) + + +@pytest.mark.asyncio +@min_version +async def test_async_stalecheck_adds_block_to_cache( + request_async_middleware, allowable_delay +): + with patch( + "web3.middleware.stalecheck._is_fresh", side_effect=[False, True, True] + ) as fresh_spy: + block = object() + request_async_middleware.web3.eth.get_block.return_value = block + + # cache miss + await request_async_middleware("", []) + cache_call, live_call = fresh_spy.call_args_list + assert fresh_spy.call_count == 2 + assert cache_call == ((None, allowable_delay), ) + assert live_call == ((block, allowable_delay), ) + + # cache hit + await request_async_middleware("", []) + assert fresh_spy.call_count == 3 + assert fresh_spy.call_args == ((block, allowable_delay), ) diff --git a/tests/core/pm-module/test_ens_integration.py b/tests/core/pm-module/test_ens_integration.py index 76dc64a1d6..835290af27 100644 --- a/tests/core/pm-module/test_ens_integration.py +++ b/tests/core/pm-module/test_ens_integration.py @@ -115,7 +115,7 @@ def ens_setup(deployer): @pytest.fixture def ens(ens_setup, mocker): - mocker.patch('web3.middleware.stalecheck._isfresh', return_value=True) + mocker.patch('web3.middleware.stalecheck._is_fresh', return_value=True) ens_setup.w3.eth.default_account = ens_setup.w3.eth.coinbase ens_setup.w3.enable_unstable_package_management_api() return ens_setup diff --git a/tests/ens/conftest.py b/tests/ens/conftest.py index 557d27207f..cfb60171ff 100644 --- a/tests/ens/conftest.py +++ b/tests/ens/conftest.py @@ -1,3 +1,4 @@ +import asyncio import json import pytest @@ -7,8 +8,12 @@ from eth_utils import ( to_checksum_address, ) +import pytest_asyncio -from ens import ENS +from ens import ( + ENS, + AsyncENS, +) from ens.contract_data import ( extended_resolver_abi, extended_resolver_bytecode, @@ -34,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, ) @@ -135,6 +145,13 @@ def ENSRegistryFactory(w3): ) +@pytest.fixture +def ens(ens_setup, mocker): + mocker.patch("web3.middleware.stalecheck._is_fresh", return_value=True) + ens_setup.w3.eth.default_account = ens_setup.w3.eth.coinbase + return ens_setup + + # session scope for performance @pytest.fixture(scope="session") def ens_setup(): @@ -326,13 +343,307 @@ def ens_setup(): return ENS.fromWeb3(w3, ens_contract.address) -@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 - - @pytest.fixture() def TEST_ADDRESS(address_conversion_func): return address_conversion_func("0x000000000000000000000000000000000000dEaD") + + +# -- async -- # + + +@pytest_asyncio.fixture(scope="session") +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_public_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="session") +def event_loop(): + return asyncio.get_event_loop() + + +# add session scope with above session-scoped `event_loop` for better performance +@pytest_asyncio.fixture(scope="session") +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_public_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 AsyncENS.fromWeb3(async_w3, ens_contract.address) + + +@pytest_asyncio.fixture +async def async_ens(async_ens_setup, mocker): + mocker.patch("web3.middleware.stalecheck._is_fresh", return_value=True) + async_ens_setup.w3.eth.default_account = await async_ens_setup.w3.eth.coinbase + return async_ens_setup diff --git a/tests/ens/test_ens.py b/tests/ens/test_ens.py index a3e3e1d04d..209c1c73de 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, + AsyncENS, +) from web3.middleware import ( + async_validation_middleware, pythonic_middleware, ) @@ -10,3 +16,15 @@ 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_fromWeb3_inherits_web3_middlewares(async_w3): + test_middleware = async_validation_middleware + async_w3.middleware_onion.add(test_middleware, "test_middleware") + + ns = AsyncENS.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 cc08d2a004..d498fba8a2 100644 --- a/tests/ens/test_get_text.py +++ b/tests/ens/test_get_text.py @@ -10,17 +10,16 @@ ) from web3 import Web3 - -@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"), - ), +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", GET_TEXT_TEST_CASES) def test_set_text_resolver_not_found(ens, key, expected): with pytest.raises(ResolverNotFound): ens.set_text("tld", key, expected) @@ -35,6 +34,9 @@ def test_set_text_fails_with_bad_address(ens): "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] @@ -56,16 +58,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) @@ -86,3 +79,80 @@ 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", GET_TEXT_TEST_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 2b13ce3069..8f70ded4b3 100644 --- a/tests/ens/test_nameprep.py +++ b/tests/ens/test_nameprep.py @@ -33,3 +33,37 @@ def test_nameprep_basic_unicode(ens): 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 699eb9c5a1..38e75987ed 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 ( @@ -76,6 +79,48 @@ 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): @@ -134,3 +179,52 @@ def test_offchain_resolver_function_call_raises_with_ccip_read_disabled( 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 38ad8ac9a8..444f8bd115 100644 --- a/tests/ens/test_setup_address.py +++ b/tests/ens/test_setup_address.py @@ -11,52 +11,64 @@ to_bytes, ) +from ens import ( + UnauthorizedError, +) from ens.constants import ( EMPTY_ADDR_HEX, ) -from ens.main import ( - UnauthorizedError, -) from web3 import Web3 - """ API at: https://github.com/carver/ens.py/issues/2 """ - -@pytest.mark.parametrize( - "name, namehash_hex", - [ - ( - "tester.eth", - "0x2a7ac1c833d35677c2ff34a908951de142cc1653de6080ad4e38f4c9cc00aafe", - ), - ( - "TESTER.eth", - "0x2a7ac1c833d35677c2ff34a908951de142cc1653de6080ad4e38f4c9cc00aafe", - ), - # handles alternative dot separators - ( - "tester.eth", - "0x2a7ac1c833d35677c2ff34a908951de142cc1653de6080ad4e38f4c9cc00aafe", - ), - ( - "tester。eth", - "0x2a7ac1c833d35677c2ff34a908951de142cc1653de6080ad4e38f4c9cc00aafe", - ), - ( - "tester。eth", - "0x2a7ac1c833d35677c2ff34a908951de142cc1653de6080ad4e38f4c9cc00aafe", - ), - # confirm that set-owner works - ( - "lots.of.subdomains.tester.eth", - "0x0d62a759aa1f1c9680de8603a12a5eb175cd1bfa79426229868eba99f4dce692", - ), - ], +SETUP_ADDRESS_TEST_CASES = ( + ( + "tester.eth", + "0x2a7ac1c833d35677c2ff34a908951de142cc1653de6080ad4e38f4c9cc00aafe", + ), + ( + "TESTER.eth", + "0x2a7ac1c833d35677c2ff34a908951de142cc1653de6080ad4e38f4c9cc00aafe", + ), + # handles alternative dot separators + ( + "tester.eth", + "0x2a7ac1c833d35677c2ff34a908951de142cc1653de6080ad4e38f4c9cc00aafe", + ), + ( + "tester。eth", + "0x2a7ac1c833d35677c2ff34a908951de142cc1653de6080ad4e38f4c9cc00aafe", + ), + ( + "tester。eth", + "0x2a7ac1c833d35677c2ff34a908951de142cc1653de6080ad4e38f4c9cc00aafe", + ), + # confirm that set-owner works + ( + "lots.of.subdomains.tester.eth", + "0x0d62a759aa1f1c9680de8603a12a5eb175cd1bfa79426229868eba99f4dce692", + ), +) +SETUP_ADDRESS_EQUIVALENCE_TEST_CASES = ( + ("TESTER.eth", "tester.eth"), + ("unicÖde.tester.eth", "unicöde.tester.eth"), ) -def test_set_address(ens, name, namehash_hex, TEST_ADDRESS): +SETUP_ADDRESS_NOOP_TEST_CASES = ( + # since the test uses get_transaction_count, + # using a same address converted to bytes and hex will error with same count, + # use two different addresses of each type (hex, bytes) + "0x000000000000000000000000000000000000dEaD", + to_bytes(hexstr="0x5B2063246F2191f18F2675ceDB8b28102e957458"), + EMPTY_ADDR_HEX, + None, + "", +) + + +@pytest.mark.parametrize("name, namehash_hex", SETUP_ADDRESS_TEST_CASES) +def test_setup_address(ens, name, namehash_hex, TEST_ADDRESS): assert ens.address(name) is None owner = ens.owner("tester.eth") @@ -79,12 +91,9 @@ def test_set_address(ens, name, namehash_hex, TEST_ADDRESS): @pytest.mark.parametrize( "name, equivalent", - [ - ("TESTER.eth", "tester.eth"), - ("unicÖde.tester.eth", "unicöde.tester.eth"), - ], + SETUP_ADDRESS_EQUIVALENCE_TEST_CASES, ) -def test_set_address_equivalence(ens, name, equivalent, TEST_ADDRESS): +def test_setup_address_equivalence(ens, name, equivalent, TEST_ADDRESS): assert ens.address(name) is None ens.setup_address(name, TEST_ADDRESS) @@ -96,30 +105,21 @@ def test_set_address_equivalence(ens, name, equivalent, TEST_ADDRESS): @pytest.mark.parametrize( - "set_address", - [ - # since the test uses get_transaction_count, - # using a same address converted to bytes and hex will error with same count, - # use two different addresses of each type (hex, bytes) - "0x000000000000000000000000000000000000dEaD", - to_bytes(hexstr="0x5B2063246F2191f18F2675ceDB8b28102e957458"), - EMPTY_ADDR_HEX, - None, - "", - ], + "setup_address", + SETUP_ADDRESS_NOOP_TEST_CASES, ) -def test_set_address_noop(ens, set_address): +def test_setup_address_noop(ens, setup_address): eth = ens.w3.eth owner = ens.owner("tester.eth") - ens.setup_address("noop.tester.eth", set_address) + ens.setup_address("noop.tester.eth", setup_address) starting_transactions = eth.get_transaction_count(owner) # do not issue transaction if address is already set - ens.setup_address("noop.tester.eth", set_address) + ens.setup_address("noop.tester.eth", setup_address) assert eth.get_transaction_count(owner) == starting_transactions -def test_set_address_unauthorized(ens, TEST_ADDRESS): +def test_setup_address_unauthorized(ens, TEST_ADDRESS): with pytest.raises(UnauthorizedError): ens.setup_address("eth", TEST_ADDRESS) @@ -150,7 +150,7 @@ def getowner(name): ) -def test_set_resolver_leave_default(ens, TEST_ADDRESS): +def test_setup_resolver_leave_default(ens, TEST_ADDRESS): owner = ens.owner("tester.eth") ens.setup_address("leave-default-resolver.tester.eth", TEST_ADDRESS) eth = ens.w3.eth @@ -164,3 +164,111 @@ 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 +@pytest.mark.parametrize("name, namehash_hex", SETUP_ADDRESS_TEST_CASES) +async def test_async_setup_address(async_ens, name, namehash_hex, TEST_ADDRESS): + assert await async_ens.address(name) is None + owner = await async_ens.owner("tester.eth") + + await async_ens.setup_address(name, TEST_ADDRESS) + assert is_same_address(await async_ens.address(name), TEST_ADDRESS) + + namehash = Web3.toBytes(hexstr=HexStr(namehash_hex)) + normal_name = async_ens.nameprep(name) + + # check that the correct namehash is set: + resolver = await async_ens.resolver(normal_name) + assert is_same_address(await resolver.caller.addr(namehash), TEST_ADDRESS) + + # check that the correct owner is set: + assert await async_ens.owner(name) == owner + + # teardown + await async_ens.setup_address(name, None) + assert await async_ens.address(name) is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "name, equivalent", + SETUP_ADDRESS_EQUIVALENCE_TEST_CASES, +) +async def test_async_setup_address_equivalence( + async_ens, name, equivalent, TEST_ADDRESS +): + assert await async_ens.address(name) is None + + await async_ens.setup_address(name, TEST_ADDRESS) + assert is_same_address(await async_ens.address(name), TEST_ADDRESS) + assert is_same_address(await async_ens.address(equivalent), TEST_ADDRESS) + + # teardown + await async_ens.setup_address(name, None) + assert await async_ens.address(name) is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("setup_address", SETUP_ADDRESS_NOOP_TEST_CASES) +async def test_async_setup_address_noop(async_ens, setup_address): + eth = async_ens.w3.eth + owner = await async_ens.owner("tester.eth") + await async_ens.setup_address("noop.tester.eth", setup_address) + starting_transactions = await eth.get_transaction_count(owner) + + # do not issue transaction if address is already set + await async_ens.setup_address("noop.tester.eth", setup_address) + assert await eth.get_transaction_count(owner) == starting_transactions + + +@pytest.mark.asyncio +async def test_async_setup_address_unauthorized(async_ens, TEST_ADDRESS): + with pytest.raises(UnauthorizedError): + await async_ens.setup_address("eth", TEST_ADDRESS) + + +@pytest.mark.asyncio +async def test_async_setup_address_default_address_to_owner(async_ens): + assert await async_ens.address("default.tester.eth") is None + owner = await async_ens.owner("tester.eth") + + await async_ens.setup_address("default.tester.eth") + assert await async_ens.address("default.tester.eth") == owner + + +@pytest.mark.asyncio +async def test_async_first_owner_upchain_identify(async_ens): + # _first_owner should auto-select the name owner to send the transaction + # from + addr = "0x5B2063246F2191f18F2675ceDB8b28102e957458" + + async def mock_async_get_owner(name): + return addr if name == "cdefghi.eth" else None + + with patch.object(async_ens, "owner", side_effect=mock_async_get_owner): + assert await async_ens._first_owner("abcdefg.bcdefgh.cdefghi.eth") == ( + addr, + ["abcdefg", "bcdefgh"], + "cdefghi.eth", + ) + + +@pytest.mark.asyncio +async def test_async_setup_resolver_leave_default(async_ens, TEST_ADDRESS): + owner = await async_ens.owner("tester.eth") + await async_ens.setup_address("leave-default-resolver.tester.eth", TEST_ADDRESS) + eth = async_ens.w3.eth + num_transactions = await eth.get_transaction_count(owner) + + await async_ens.setup_address( + "leave-default-resolver.tester.eth", + "0x5B2063246F2191f18F2675ceDB8b28102e957458", + ) + + # 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 await eth.get_transaction_count(owner) == num_transactions + 1 diff --git a/tests/ens/test_setup_name.py b/tests/ens/test_setup_name.py index 336ab3ca5f..738ab35819 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) @@ -120,6 +119,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): @@ -148,3 +148,116 @@ 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 9ffb086743..455eaa11e8 100644 --- a/tests/ens/test_utils.py +++ b/tests/ens/test_utils.py @@ -2,16 +2,24 @@ from eth_utils import ( ValidationError, + is_integer, to_bytes, ) from ens.utils import ( ens_encode_name, + init_async_web3, init_web3, ) +from web3.eth import ( + AsyncEth, +) +from web3.providers.eth_tester import ( + AsyncEthereumTesterProvider, +) -def test_init_adds_middlewares(): +def test_init_web3_adds_expected_middlewares(): w3 = init_web3() middlewares = map(str, w3.manager.middleware_onion) assert "stalecheck_middleware" in next(middlewares) @@ -115,3 +123,31 @@ 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_init_async_web3_adds_expected_async_middlewares(): + async_w3 = init_async_web3() + middlewares = map(str, async_w3.manager.middleware_onion) + assert "stalecheck_middleware" in next(middlewares) + + +@pytest.mark.asyncio +async def test_init_async_web3_adds_async_eth(): + async_w3 = init_async_web3() + assert isinstance(async_w3.eth, AsyncEth) + + +@pytest.mark.asyncio +async def test_init_async_web3_with_provider_argument_adds_async_eth(): + async_w3 = init_async_web3(AsyncEthereumTesterProvider()) + + assert isinstance(async_w3.provider, AsyncEthereumTesterProvider) + assert isinstance(async_w3.eth, AsyncEth) + + latest_block = await async_w3.eth.get_block("latest") + assert latest_block + assert is_integer(latest_block["number"]) diff --git a/tests/ens/test_wildcard_resolution.py b/tests/ens/test_wildcard_resolution.py index 098947db7f..23f8b1660d 100644 --- a/tests/ens/test_wildcard_resolution.py +++ b/tests/ens/test_wildcard_resolution.py @@ -16,3 +16,31 @@ def test_wildcard_resolution_with_extended_resolver_for_parent_ens_domain(ens): # 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_async_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/_utils/method_formatters.py b/web3/_utils/method_formatters.py index 976e9d7e88..8f3bbbda41 100644 --- a/web3/_utils/method_formatters.py +++ b/web3/_utils/method_formatters.py @@ -95,6 +95,7 @@ from web3.types import ( BlockIdentifier, CallOverrideParams, + Formatters, RPCEndpoint, RPCResponse, TReturn, @@ -566,9 +567,12 @@ def apply_list_to_array_formatter(formatter: Any) -> Callable[..., Any]: ] -ABI_REQUEST_FORMATTERS = abi_request_formatters(STANDARD_NORMALIZERS, RPC_ABIS) +ABI_REQUEST_FORMATTERS: Formatters = abi_request_formatters( + STANDARD_NORMALIZERS, RPC_ABIS +) -# the first 4 bytes of keccak hash for: "OffchainLookup(address,string[],bytes,bytes4,bytes)" +# the first 4 bytes of keccak hash for: +# "OffchainLookup(address,string[],bytes,bytes4,bytes)" OFFCHAIN_LOOKUP_FUNC_SELECTOR = "0x556f1830" OFFCHAIN_LOOKUP_FIELDS = { "sender": "address", diff --git a/web3/_utils/normalizers.py b/web3/_utils/normalizers.py index 993890ac2d..f287cf9995 100644 --- a/web3/_utils/normalizers.py +++ b/web3/_utils/normalizers.py @@ -212,17 +212,19 @@ def abi_ens_resolver(w3: "Web3", type_str: TypeStr, val: Any) -> Tuple[TypeStr, f"Could not look up name {val!r} because no web3" " connection available" ) - elif w3.ens is None: + + _ens = cast(ENS, w3.ens) + if _ens is None: raise InvalidAddress( f"Could not look up name {val!r} because ENS is" " set to None" ) - elif int(w3.net.version) != 1 and not isinstance(w3.ens, StaticENS): + elif int(w3.net.version) != 1 and not isinstance(_ens, StaticENS): raise InvalidAddress( f"Could not look up name {val!r} because web3 is" " not connected to mainnet" ) else: - return type_str, validate_name_has_address(w3.ens, val) + return type_str, validate_name_has_address(_ens, val) else: return type_str, val diff --git a/web3/contract.py b/web3/contract.py index 61d9dd944f..7ac9f5bf31 100644 --- a/web3/contract.py +++ b/web3/contract.py @@ -148,6 +148,7 @@ ) if TYPE_CHECKING: + from ens import ENS # noqa: F401 from web3 import Web3 # noqa: F401 ACCEPTABLE_EMPTY_STRINGS = ["0x", b"0x", "", b""] @@ -630,32 +631,35 @@ def __init__(self, address: Optional[ChecksumAddress] = None) -> None: """Create a new smart contract proxy object. :param address: Contract address as 0x hex string""" - if self.w3 is None: + + _w3 = self.w3 + if _w3 is None: raise AttributeError( "The `Contract` class has not been initialized. Please use the " "`web3.contract` interface to create your contract class." ) if address: - self.address = normalize_address(self.w3.ens, address) + _ens = cast("ENS", _w3.ens) + self.address = normalize_address(_ens, address) if not self.address: raise TypeError( "The address argument is required to instantiate a contract." ) - self.functions = ContractFunctions(self.abi, self.w3, self.address) - self.caller = ContractCaller(self.abi, self.w3, self.address) - self.events = ContractEvents(self.abi, self.w3, self.address) + self.functions = ContractFunctions(self.abi, _w3, self.address) + self.caller = ContractCaller(self.abi, _w3, self.address) + self.events = ContractEvents(self.abi, _w3, self.address) self.fallback = Contract.get_fallback_function( self.abi, - self.w3, + _w3, ContractFunction, self.address, ) self.receive = Contract.get_receive_function( self.abi, - self.w3, + _w3, ContractFunction, self.address, ) @@ -738,6 +742,7 @@ def __init__(self, address: Optional[ChecksumAddress] = None) -> None: """Create a new smart contract proxy object. :param address: Contract address as 0x hex string""" + if self.w3 is None: raise AttributeError( "The `Contract` class has not been initialized. Please use the " diff --git a/web3/main.py b/web3/main.py index e6b537b5e0..4ad845ba4f 100644 --- a/web3/main.py +++ b/web3/main.py @@ -1,4 +1,9 @@ import decimal + +from ens import ( + AsyncENS, + ENS, +) from eth_abi.codec import ( ABICodec, ) @@ -24,6 +29,7 @@ ) from typing import ( Any, + Coroutine, Dict, List, Optional, @@ -45,7 +51,6 @@ combomethod, ) -from ens import ENS from web3._utils.abi import ( build_default_registry, build_strict_registry, @@ -96,6 +101,7 @@ ParityPersonal, ) from web3.providers import ( + AsyncBaseProvider, BaseProvider, ) from web3.providers.eth_tester import ( @@ -127,6 +133,7 @@ if TYPE_CHECKING: from web3.pm import PM # noqa: F401 + from web3._utils.empty import Empty # noqa: F401 def get_default_modules() -> Dict[str, Union[Type[Module], Sequence[Any]]]: @@ -237,13 +244,13 @@ def toChecksumAddress(value: Union[AnyAddress, str, bytes]) -> ChecksumAddress: def __init__( self, - provider: Optional[BaseProvider] = None, + provider: Optional[Union[BaseProvider, AsyncBaseProvider]] = None, 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: Union[ENS, AsyncENS, "Empty"] = empty, ) -> None: self.manager = self.RequestManager(self, provider, middlewares) # this codec gets used in the module initialization, @@ -265,11 +272,11 @@ def middleware_onion(self) -> MiddlewareOnion: return self.manager.middleware_onion @property - def provider(self) -> BaseProvider: + def provider(self) -> Union[BaseProvider, AsyncBaseProvider]: return self.manager.provider @provider.setter - def provider(self, provider: BaseProvider) -> None: + def provider(self, provider: Union[BaseProvider, AsyncBaseProvider]) -> None: self.manager.provider = provider @property @@ -337,21 +344,21 @@ def attach_modules( """ _attach_modules(self, modules) - def isConnected(self) -> bool: + def isConnected(self) -> Union[bool, Coroutine[Any, Any, bool]]: return self.provider.isConnected() def is_encodable(self, _type: TypeStr, value: Any) -> bool: return self.codec.is_encodable(_type, value) @property - def ens(self) -> ENS: - if self._ens is cast(ENS, empty): - return ENS.fromWeb3(self) - else: - return self._ens + def ens(self) -> Union[ENS, AsyncENS, "Empty"]: + if self._ens is empty: + return AsyncENS.fromWeb3(self) if self.eth.is_async else ENS.fromWeb3(self) + + return self._ens @ens.setter - def ens(self, new_ens: ENS) -> None: + def ens(self, new_ens: Union[ENS, AsyncENS, "Empty"]) -> None: self._ens = new_ens @property diff --git a/web3/manager.py b/web3/manager.py index ade79fa9e3..a18b2f5730 100644 --- a/web3/manager.py +++ b/web3/manager.py @@ -10,6 +10,7 @@ Sequence, Tuple, Union, + cast, ) import uuid from uuid import UUID @@ -46,7 +47,6 @@ ) from web3.providers import ( AutoProvider, - BaseProvider, ) from web3.types import ( # noqa: F401 Middleware, @@ -57,6 +57,10 @@ if TYPE_CHECKING: from web3 import Web3 # noqa: F401 + from web3.providers import ( # noqa: F401 + AsyncBaseProvider, + BaseProvider, + ) NULL_RESPONSES = [None, HexBytes("0x"), "0x"] @@ -91,7 +95,7 @@ class RequestManager: def __init__( self, w3: "Web3", - provider: Optional[BaseProvider] = None, + provider: Optional[Union["BaseProvider", "AsyncBaseProvider"]] = None, middlewares: Optional[Sequence[Tuple[Middleware, str]]] = None, ) -> None: self.w3 = w3 @@ -111,11 +115,11 @@ def __init__( _provider = None @property - def provider(self) -> BaseProvider: + def provider(self) -> Union["BaseProvider", "AsyncBaseProvider"]: return self._provider @provider.setter - def provider(self, provider: BaseProvider) -> None: + def provider(self, provider: Union["BaseProvider", "AsyncBaseProvider"]) -> None: self._provider = provider @staticmethod @@ -141,7 +145,8 @@ def default_middlewares(w3: "Web3") -> List[Tuple[Middleware, str]]: def _make_request( self, method: Union[RPCEndpoint, Callable[..., RPCEndpoint]], params: Any ) -> RPCResponse: - request_func = self.provider.request_func(self.w3, self.middleware_onion) + provider = cast("BaseProvider", self.provider) + request_func = provider.request_func(self.w3, self.middleware_onion) self.logger.debug(f"Making request. Method: {method}") return request_func(method, params) 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/filter.py b/web3/middleware/filter.py index 54e4f76c28..f19bfd73da 100644 --- a/web3/middleware/filter.py +++ b/web3/middleware/filter.py @@ -368,7 +368,8 @@ def middleware(method: RPCEndpoint, params: Any) -> RPCResponse: if method == RPC.eth_getFilterChanges: return {"result": next(_filter.filter_changes)} elif method == RPC.eth_getFilterLogs: - # type ignored b/c logic prevents RequestBlocks which doesn't implement get_logs + # type ignored b/c logic prevents RequestBlocks which + # doesn't implement get_logs return {"result": _filter.get_logs()} # type: ignore else: raise NotImplementedError(method) diff --git a/web3/middleware/stalecheck.py b/web3/middleware/stalecheck.py index db90584feb..0dab5bc9b5 100644 --- a/web3/middleware/stalecheck.py +++ b/web3/middleware/stalecheck.py @@ -5,12 +5,14 @@ Callable, Collection, Dict, + Optional, ) from web3.exceptions import ( StaleBlockchain, ) from web3.types import ( + AsyncMiddleware, BlockData, Middleware, RPCEndpoint, @@ -20,16 +22,13 @@ if TYPE_CHECKING: from web3 import Web3 # noqa: F401 -SKIP_STALECHECK_FOR_METHODS = { - "eth_getBlockByNumber", -} +SKIP_STALECHECK_FOR_METHODS = ("eth_getBlockByNumber",) -def _isfresh(block: BlockData, allowable_delay: int) -> bool: +def _is_fresh(block: BlockData, allowable_delay: int) -> bool: if block and (time.time() - block["timestamp"] <= allowable_delay): return True - else: - return False + return False def make_stalecheck_middleware( @@ -39,8 +38,9 @@ def make_stalecheck_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. + 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 @@ -54,15 +54,13 @@ def make_stalecheck_middleware( def stalecheck_middleware( make_request: Callable[[RPCEndpoint, Any], Any], w3: "Web3" ) -> Callable[[RPCEndpoint, Any], RPCResponse]: - cache: Dict[str, BlockData] = {"latest": None} + cache: Dict[str, Optional[BlockData]] = {"latest": None} def middleware(method: RPCEndpoint, params: Any) -> RPCResponse: if method not in skip_stalecheck_for_methods: - if _isfresh(cache["latest"], allowable_delay): - pass - else: + if not _is_fresh(cache["latest"], allowable_delay): latest = w3.eth.get_block("latest") - if _isfresh(latest, allowable_delay): + if _is_fresh(latest, allowable_delay): cache["latest"] = latest else: raise StaleBlockchain(latest, allowable_delay) @@ -72,3 +70,47 @@ def middleware(method: RPCEndpoint, params: Any) -> RPCResponse: return middleware return stalecheck_middleware + + +# -- async -- # + + +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: `async_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" + ) -> AsyncMiddleware: + cache: Dict[str, Optional[BlockData]] = {"latest": None} + + async def middleware(method: RPCEndpoint, params: Any) -> RPCResponse: + if method not in skip_stalecheck_for_methods: + if not _is_fresh(cache["latest"], allowable_delay): + latest = await w3.eth.get_block("latest") # type: ignore + if _is_fresh(latest, allowable_delay): + cache["latest"] = latest + else: + raise StaleBlockchain(latest, allowable_delay) + + return await make_request(method, params) + + return middleware + + return stalecheck_middleware diff --git a/web3/middleware/validation.py b/web3/middleware/validation.py index b402ebc66e..6a94b5f533 100644 --- a/web3/middleware/validation.py +++ b/web3/middleware/validation.py @@ -104,7 +104,11 @@ def _transaction_param_validator(web3_chain_id: int) -> Callable[..., Any]: is_not_null, apply_formatters_to_dict(BLOCK_VALIDATORS) ) -METHODS_TO_VALIDATE = [RPC.eth_sendTransaction, RPC.eth_estimateGas, RPC.eth_call] +METHODS_TO_VALIDATE = [ + RPC.eth_sendTransaction, + RPC.eth_estimateGas, + RPC.eth_call, +] def _chain_id_validator(web3_chain_id: int) -> Callable[..., Any]: diff --git a/web3/pm.py b/web3/pm.py index b1f897b4c4..dd3f0c2a0c 100644 --- a/web3/pm.py +++ b/web3/pm.py @@ -33,6 +33,7 @@ to_tuple, ) +from ens import ENS from ethpm import ( ASSETS_DIR, Package, @@ -62,9 +63,6 @@ from web3.module import ( Module, ) -from web3.types import ( - ENS, -) # Package Management is still in alpha, and its API is likely to change, so it # is not automatically available on a web3 instance. To use the `PM` module, @@ -383,7 +381,8 @@ def set_registry(self, address: Union[Address, ChecksumAddress, ENS]) -> None: self.registry = SimpleRegistry(cast(ChecksumAddress, address), self.w3) elif is_ens_name(address): self._validate_set_ens() - addr_lookup = self.w3.ens.address(str(address)) + ens = cast(ENS, self.w3.ens) + addr_lookup = ens.address(str(address)) if not addr_lookup: raise NameNotFound( f"No address found after ENS lookup for name: {address!r}." diff --git a/web3/providers/__init__.py b/web3/providers/__init__.py index 701c59b5c3..d9c3952dec 100644 --- a/web3/providers/__init__.py +++ b/web3/providers/__init__.py @@ -1,3 +1,6 @@ +from .async_base import ( # noqa: F401 + AsyncBaseProvider, +) from .base import ( # noqa: F401 BaseProvider, JSONBaseProvider, diff --git a/web3/providers/eth_tester/main.py b/web3/providers/eth_tester/main.py index 7ad2455deb..4559372f9c 100644 --- a/web3/providers/eth_tester/main.py +++ b/web3/providers/eth_tester/main.py @@ -51,6 +51,8 @@ class AsyncEthereumTesterProvider(AsyncBaseProvider): ) def __init__(self) -> None: + super().__init__() + # do not import eth_tester until runtime, it is not a default dependency from eth_tester import EthereumTester from web3.providers.eth_tester.defaults import API_ENDPOINTS diff --git a/web3/tools/benchmark/main.py b/web3/tools/benchmark/main.py index a173c76699..9b77045351 100644 --- a/web3/tools/benchmark/main.py +++ b/web3/tools/benchmark/main.py @@ -76,7 +76,7 @@ def build_web3_http(endpoint_uri: str) -> Web3: async def build_async_w3_http(endpoint_uri: str) -> Web3: await wait_for_aiohttp(endpoint_uri) _w3 = Web3( - AsyncHTTPProvider(endpoint_uri), # type: ignore + AsyncHTTPProvider(endpoint_uri), middlewares=[ async_gas_price_strategy_middleware, async_buffered_gas_estimate_middleware,