diff --git a/docs/userguides/testing.md b/docs/userguides/testing.md index 1bdb9b9456..439b662111 100644 --- a/docs/userguides/testing.md +++ b/docs/userguides/testing.md @@ -135,7 +135,7 @@ test: hd_path: "m/44'/60'/0'/0/{}" ``` -If you are using a fork-provider, such as [Hardhat](https://github.com/ApeWorX/ape-hardhat), you can use impersonated accounts by accessing random addresses off the fixture: +If you are using a provider that supports impersonating accounts, such as [Foundry](https://github.com/ApeWorX/ape-foundry), use the address as the key in the test-accounts manager: ```python @pytest.fixture @@ -143,6 +143,14 @@ def vitalik(accounts): return accounts["0xab5801a7d398351b8be11c439e05c5b3259aec9b"] ``` +You can also call `accounts.impersonate_account()` for improved readability and performance. + +```python +@pytest.fixture +def vitalik(accounts): + return accounts.impersonate_account("0xab5801a7d398351b8be11c439e05c5b3259aec9b") +``` + Using a fork-provider such as [Hardhat](https://github.com/ApeWorX/ape-hardhat), when using a contract instance as the sender in a transaction, it will be automatically impersonated: ```python diff --git a/src/ape/api/accounts.py b/src/ape/api/accounts.py index a3510fdef2..fe50a0f393 100644 --- a/src/ape/api/accounts.py +++ b/src/ape/api/accounts.py @@ -621,10 +621,10 @@ class ImpersonatedAccount(AccountAPI): An account to use that does not require signing. """ + raw_address: AddressType """ The field-address of the account. """ - raw_address: AddressType @property def address(self) -> AddressType: diff --git a/src/ape/api/providers.py b/src/ape/api/providers.py index 12d772bf51..6f7b09679a 100644 --- a/src/ape/api/providers.py +++ b/src/ape/api/providers.py @@ -722,6 +722,15 @@ def unlock_account(self, address: AddressType) -> bool: # type: ignore[empty-bo bool: ``True`` if successfully unlocked account and ``False`` otherwise. """ + @raises_not_implemented + def relock_account(self, address: AddressType): + """ + Stop impersonating an account. + + Args: + address (:class:`~ape.types.address.AddressType`): The address to relock. + """ + @raises_not_implemented def get_transaction_trace( # type: ignore[empty-body] self, txn_hash: Union[HexBytes, str] diff --git a/src/ape/contracts/base.py b/src/ape/contracts/base.py index 4941e7e9cf..924f4f058c 100644 --- a/src/ape/contracts/base.py +++ b/src/ape/contracts/base.py @@ -361,6 +361,7 @@ def __call__(self, *args, **kwargs) -> ReceiptAPI: if "sender" in kwargs and isinstance(kwargs["sender"], AccountAPI): return kwargs["sender"].call(txn, **kwargs) + txn = self.provider.prepare_transaction(txn) return ( self.provider.send_private_transaction(txn) if private diff --git a/src/ape/managers/accounts.py b/src/ape/managers/accounts.py index 94e1c78afc..1815d263a6 100644 --- a/src/ape/managers/accounts.py +++ b/src/ape/managers/accounts.py @@ -12,7 +12,7 @@ TestAccountAPI, TestAccountContainerAPI, ) -from ape.exceptions import ConversionError +from ape.exceptions import AccountsError, ConversionError from ape.managers.base import BaseManager from ape.types import AddressType from ape.utils import ManagerAccessMixin, cached_property, log_instead_of_fail, singledispatchmethod @@ -109,28 +109,51 @@ def __getitem_str(self, account_str: str): if account.address == account_id: return account - can_impersonate = False - err_message = message_fmt.format("address", account_id) try: - if self.network_manager.active_provider: - can_impersonate = self.provider.unlock_account(account_id) - # else: fall through to `IndexError` + return self.impersonate_account(account_id) + except AccountsError as err: + err_message = message_fmt.format("address", account_id) + raise KeyError(f"{str(err).rstrip('.')}:\n{err_message}") from err + + def __contains__(self, address: AddressType) -> bool: # type: ignore + return any(address in container for container in self.containers.values()) + + def impersonate_account(self, address: AddressType) -> ImpersonatedAccount: + """ + Impersonate an account for testing purposes. + + Args: + address (AddressType): The address to impersonate. + """ + try: + result = self.provider.unlock_account(address) except NotImplementedError as err: - raise KeyError( - f"Your provider does not support impersonating accounts:\n{err_message}" - ) from err + raise AccountsError("Your provider does not support impersonating accounts.") from err - if not can_impersonate: - raise KeyError(err_message) + if result: + if address in self._impersonated_accounts: + return self._impersonated_accounts[address] - if account_id not in self._impersonated_accounts: - acct = ImpersonatedAccount(raw_address=account_id) - self._impersonated_accounts[account_id] = acct + account = ImpersonatedAccount(raw_address=address) + self._impersonated_accounts[address] = account + return account - return self._impersonated_accounts[account_id] + raise AccountsError(f"Unable to unlocked account '{address}'.") - def __contains__(self, address: AddressType) -> bool: # type: ignore - return any(address in container for container in self.containers.values()) + def stop_impersonating(self, address: AddressType): + """ + End the impersonating of an account, if it is being impersonated. + + Args: + address (AddressType): The address to stop impersonating. + """ + if address in self._impersonated_accounts: + del self._impersonated_accounts[address] + + try: + self.provider.relock_account(address) + except NotImplementedError: + pass def generate_test_account(self, container_name: str = "test") -> TestAccountAPI: return self.containers[container_name].generate_account() diff --git a/src/ape/managers/chain.py b/src/ape/managers/chain.py index ca39fd8af1..ae6d8363ea 100644 --- a/src/ape/managers/chain.py +++ b/src/ape/managers/chain.py @@ -1580,7 +1580,6 @@ def pending_timestamp(self) -> int: from ape import chain chain.pending_timestamp += 3600 """ - return self.provider.get_block("pending").timestamp @pending_timestamp.setter diff --git a/src/ape_ethereum/provider.py b/src/ape_ethereum/provider.py index b4f7d14380..3f4361240e 100644 --- a/src/ape_ethereum/provider.py +++ b/src/ape_ethereum/provider.py @@ -917,23 +917,40 @@ def prepare_transaction(self, txn: TransactionAPI) -> TransactionAPI: def send_transaction(self, txn: TransactionAPI) -> ReceiptAPI: vm_err = None + txn_hash = None try: - if txn.signature or not txn.sender: - txn_hash = self.web3.eth.send_raw_transaction(txn.serialize_transaction()).hex() - else: - if txn.sender not in self.web3.eth.accounts: - self.chain_manager.provider.unlock_account(txn.sender) - - # NOTE: Using JSON mode since used as request data. - txn_data = cast(TxParams, txn.model_dump(by_alias=True, mode="json")) - txn_hash = self.web3.eth.send_transaction(txn_data).hex() + if txn.sender is not None and txn.signature is None: + # Missing signature, user likely trying to use an unlocked account. + attempt_send = True + if ( + self.network.is_dev + and txn.sender not in self.account_manager.test_accounts._impersonated_accounts + ): + try: + self.account_manager.test_accounts.impersonate_account(txn.sender) + except NotImplementedError: + # Unable to impersonate. Try sending as raw-tx. + attempt_send = False + + if attempt_send: + # For some reason, some nodes have issues with integer-types. + txn_data = { + k: to_hex(v) if isinstance(v, int) else v + for k, v in txn.model_dump(by_alias=True, mode="json").items() + } + tx_params = cast(TxParams, txn_data) + txn_hash = to_hex(self.web3.eth.send_transaction(tx_params)) + # else: attempt raw tx + + if txn_hash is None: + txn_hash = to_hex(self.web3.eth.send_raw_transaction(txn.serialize_transaction())) except (ValueError, Web3ContractLogicError) as err: vm_err = self.get_virtual_machine_error(err, txn=txn) if txn.raise_on_revert: raise vm_err from err else: - txn_hash = txn.txn_hash.hex() + txn_hash = to_hex(txn.txn_hash) required_confirmations = ( txn.required_confirmations @@ -958,6 +975,14 @@ def send_transaction(self, txn: TransactionAPI) -> ReceiptAPI: self.chain_manager.history.append(receipt) if receipt.failed: + # For some reason, some nodes have issues with integer-types. + if isinstance(txn_dict.get("type"), int): + txn_dict["type"] = to_hex(txn_dict["type"]) + + # NOTE: For some reason, some providers have issues with + # `nonce`, it's not needed anyway. + txn_dict.pop("nonce", None) + # NOTE: Using JSON mode since used as request data. txn_params = cast(TxParams, txn_dict) diff --git a/tests/performance/test_project.py b/tests/performance/test_project.py index a720107e51..870b9b0071 100644 --- a/tests/performance/test_project.py +++ b/tests/performance/test_project.py @@ -7,4 +7,8 @@ def test_get_contract(benchmark, project_with_contracts): ) stats = benchmark.stats median = stats.get("median") - assert median < 0.0002 + + # NOTE: At one point, this was average '0.0007'. + # When I run locally, I tend to get 0.0001. + # In CI, when very busy, it can get slower + assert median < 0.00030