Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: improve impersonating accounts features in core #2195

Merged
merged 5 commits into from
Aug 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion docs/userguides/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,14 +135,22 @@ 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
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
Expand Down
2 changes: 1 addition & 1 deletion src/ape/api/accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 9 additions & 0 deletions src/ape/api/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
1 change: 1 addition & 0 deletions src/ape/contracts/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 40 additions & 17 deletions src/ape/managers/accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
1 change: 0 additions & 1 deletion src/ape/managers/chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 35 additions & 10 deletions src/ape_ethereum/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand Down
6 changes: 5 additions & 1 deletion tests/performance/test_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading