Skip to content

Commit 633e329

Browse files
fubuloubuantazoey
andauthored
feat(api): support signing authorizations (#2613)
Co-authored-by: antazoey <[email protected]>
1 parent 1afdbc6 commit 633e329

File tree

15 files changed

+585
-39
lines changed

15 files changed

+585
-39
lines changed

.github/workflows/test.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ jobs:
9595
run: ape test tests/functional -m "not fuzzing" -s --cov=src --cov-append -n auto --dist loadgroup
9696

9797
- name: Run Integration Tests
98-
run: ape test tests/integration/cli/test_test.py -m "not fuzzing" -s --cov=src --cov-append -n auto --dist loadgroup
98+
run: ape test tests/integration -m "not fuzzing" -s --cov=src --cov-append -n auto --dist loadgroup
9999

100100
- name: Run Performance Tests
101101
run: ape test tests/performance -s

docs/userguides/accounts.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,62 @@ assert recovered_signer == account.address
344344
assert account.check_signature(message, signature)
345345
```
346346

347+
### Signing Authorizations (EIP-7702)
348+
349+
If supported by your current ecosystem and account plugins, Ape exposes the capability to set delegate contracts for EOAs via [EIP-7702](https://eips.ethereum.org/EIPS/eip-7702) signed `SetCode` Authorizations.
350+
To sign an Authorization, use `account.sign_authorization` as follows:
351+
352+
```py
353+
from ape_ethereum import Authorization
354+
sig = account.sign_authorization(contract)
355+
auth = Authorization.from_signature(
356+
address=contract.address,
357+
chain_id=<chain ID>,
358+
nonce=account.nonce,
359+
signature=sig,
360+
)
361+
# NOTE: Will execute `methodWithAuth` from `contract` *after* executing authorization
362+
# NOTE: Make sure to increment the `nonce=` kwarg, as the authorization consumes current nonce
363+
contract.methodWithAuth(..., authorizations=[auth], sender=account, nonce=account.nonce + 1)
364+
```
365+
366+
```{caution}
367+
Authorizations are **extremely dangerous** and can lead to full account compromise via signature.
368+
**DO NOT SIGN** an authorization unless you know what you are doing.
369+
```
370+
371+
```{note}
372+
Not all account plugins support signing Authorizations.
373+
For instance, most hardware wallets may reject signing EIP-7702 Authorizations unless the delegate has been previously whitelisted in their firmware.
374+
Also, smart contract wallets (such as Safe) cannot sign these message types as only EOAs can sign them.
375+
```
376+
377+
Once set, you can see if a delegate contract has been set for your EOA account via `account.delegate`.
378+
Ape exposes a [convienence subcommand](../commands/accounts.html#accounts-auth) group under `ape accounts` for use in managing your delegations.
379+
380+
Ape also exposes the following convienent higher-level API for working with authorizations in a scripting or testing context:
381+
382+
```py
383+
assert not account.delegate
384+
385+
# Set `account`'s `.delegate` to `contract`
386+
account.set_delegate(contract)
387+
assert account.delegate == contract
388+
389+
# Remove `account`'s `.delegate`
390+
account.remove_delegate()
391+
assert not account.delegate
392+
393+
# Temporarily use `contract` as `delegate` (and reset upon exiting context)
394+
# NOTE: Calls `.set_delegate(contract)` when entering context
395+
with account.delegate_to(contract) as proxy:
396+
# NOTE: `proxy` is `Contract(account, contract_type=contract.contract_type)`
397+
assert contract.contract_type == proxy.contract_type
398+
proxy.methodWithAuth(..., sender=account)
399+
# NOTE: calls `remove_delegate()` when exiting context
400+
assert not account.delegate
401+
```
402+
347403
## Automation
348404

349405
If you use your keyfile accounts in automation, such as CI/CD, you may need to programmatically unlock them and enable auto-sign.

src/ape/api/accounts.py

Lines changed: 146 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import os
22
from abc import abstractmethod
33
from collections.abc import Iterator
4+
from contextlib import contextmanager
45
from functools import cached_property
56
from pathlib import Path
67
from typing import TYPE_CHECKING, Any, Optional, Union
@@ -18,6 +19,7 @@
1819
from ape.exceptions import (
1920
AccountsError,
2021
AliasAlreadyInUseError,
22+
APINotImplementedError,
2123
ConversionError,
2224
MethodNonPayableError,
2325
MissingDeploymentBytecodeError,
@@ -60,9 +62,14 @@ def __dir__(self) -> list[str]:
6062
self.__class__.call.__name__,
6163
self.__class__.deploy.__name__,
6264
self.__class__.prepare_transaction.__name__,
65+
self.__class__.sign_authorization.__name__,
6366
self.__class__.sign_message.__name__,
6467
self.__class__.sign_transaction.__name__,
6568
self.__class__.transfer.__name__,
69+
self.__class__.delegate.fget.__name__, # type: ignore[attr-defined]
70+
self.__class__.set_delegate.__name__,
71+
self.__class__.remove_delegate.__name__,
72+
self.__class__.delegate_to.__name__,
6673
]
6774

6875
@property
@@ -95,16 +102,49 @@ def sign_raw_msghash(self, msghash: "HexBytes") -> Optional[MessageSignature]:
95102
Args:
96103
msghash (:class:`~eth_pydantic_types.HexBytes`):
97104
The message hash to sign. Plugins may or may not support this operation.
98-
Default implementation is to raise ``NotImplementedError``.
105+
Default implementation is to raise ``APINotImplementedError``.
99106
100107
Returns:
101108
:class:`~ape.types.signatures.MessageSignature` (optional):
102109
The signature corresponding to the message.
103110
"""
104-
raise NotImplementedError(
111+
raise APINotImplementedError(
105112
f"Raw message signing is not supported by '{self.__class__.__name__}'"
106113
)
107114

115+
def sign_authorization(
116+
self,
117+
address: Any,
118+
chain_id: Optional[int] = None,
119+
nonce: Optional[int] = None,
120+
) -> Optional[MessageSignature]:
121+
"""
122+
Sign an `EIP-7702 <https://eips.ethereum.org/EIPS/eip-7702>`__ Authorization.
123+
124+
Args:
125+
address (Any): A delegate address to sign the authorization for.
126+
chain_id (Optional[int]):
127+
The chain ID that the authorization should be valid for.
128+
A value of ``0`` means that the authorization is valid for **any chain**.
129+
Default tells implementation to use the currently connected network's ``chain_id``.
130+
nonce (Optional[int]):
131+
The nonce to use to sign authorization with. Defaults to account's current nonce.
132+
133+
Returns:
134+
:class:`~ape.types.signatures.MessageSignature` (optional):
135+
The signature corresponding to the message.
136+
137+
```{caution}
138+
This action has the capability to be extremely destructive to the signer, and might lead to
139+
full account compromise. All implementations are recommended to ensure that the signer be
140+
made aware of the severity and impact of this action through some callout.
141+
```
142+
"""
143+
144+
raise APINotImplementedError(
145+
f"Authorization signing is not supported by '{self.__class__.__name__}'"
146+
)
147+
108148
@abstractmethod
109149
def sign_message(self, msg: Any, **signer_options) -> Optional[MessageSignature]:
110150
"""
@@ -438,6 +478,107 @@ def get_deployment_address(self, nonce: Optional[int] = None) -> AddressType:
438478
nonce = self.nonce if nonce is None else nonce
439479
return ecosystem.get_deployment_address(self.address, nonce)
440480

481+
def set_delegate(self, contract: Union[BaseAddress, AddressType, str], **txn_kwargs):
482+
"""
483+
Have the account class override the value of its ``delegate``. For plugins that support
484+
this feature, the way they choose to handle it can vary. For example, it could be a call to
485+
upgrade itself using some built-in method for a smart wallet (with default txn args) e.g.
486+
the Safe smart wallet (https://github.com/ApeWorX/ape-safe), or it could be to use an EIP-
487+
7702-like feature available on the network to set a delegate for that account. However if a
488+
plugin chooses to handle it, the resulting action (if successful) should make sure that the
489+
value that ``self.delegate`` returns is the same as ``contract`` after it is completed.
490+
491+
By default, this method raises ``APINotImplementedError`` signaling that support is not
492+
available for this feature. Calling this may result in other errors if implemented.
493+
494+
Args:
495+
contract (`:class:~ape.contracts.ContractInstance`):
496+
The contract instance to override the delegate with.
497+
**txn_kwargs: Additional transaction kwargs passed to
498+
:meth:`~ape.api.networks.EcosystemAPI.create_transaction`, such as ``gas``
499+
``max_fee``, or ``max_priority_fee``. For a list of available transaction
500+
kwargs, see :class:`~ape.api.transactions.TransactionAPI`.
501+
"""
502+
raise APINotImplementedError
503+
504+
def remove_delegate(self, **txn_kwargs):
505+
"""
506+
Has the account class remove the override for the value of its ``delegate``. For plugins
507+
that support this feature, the way they choose to handle it can vary. For example, on a
508+
network using an EIP7702-like feature available it will reset the delegate to empty.
509+
However, if a plugin chooses to handle it, the resulting action (if successful) should
510+
make sure that the value that ``self.delegate`` returns ``None`` after it is completed.
511+
512+
By default, this method raises ``APINotImplementedError`` signaling that support is not
513+
available for this feature. Calling this may result in other errors if implemented.
514+
515+
Args:
516+
**txn_kwargs: Additional transaction kwargs passed to
517+
:meth:`~ape.api.networks.EcosystemAPI.create_transaction`, such as ``gas``
518+
``max_fee``, or ``max_priority_fee``. For a list of available transaction
519+
kwargs, see :class:`~ape.api.transactions.TransactionAPI`.
520+
"""
521+
raise APINotImplementedError
522+
523+
@contextmanager
524+
def delegate_to(
525+
self,
526+
new_delegate: Union[BaseAddress, AddressType, str],
527+
set_txn_kwargs: Optional[dict] = None,
528+
reset_txn_kwargs: Optional[dict] = None,
529+
**txn_kwargs,
530+
) -> Iterator[BaseAddress]:
531+
"""
532+
Temporarily override the value of ``delegate`` for the account inside of a context manager,
533+
and yields a contract instance object whose interface matches that of ``new_delegate``.
534+
This is useful for ensuring that delegation is only temporarily extended to an account when
535+
doing a critical action temporarily, such as using an EIP7702 delegate module.
536+
537+
Args:
538+
new_delegate (`:class:~ape.contracts.ContractInstance`):
539+
The contract instance to override the `delegate` with.
540+
set_txn_kwargs (dict | None): Additional transaction kwargs passed to
541+
:meth:`~ape.api.networks.EcosystemAPI.create_transaction` for the
542+
:meth:`AccountAPI.set_delegate` method, such as ``gas``, ``max_fee``, or
543+
``max_priority_fee``. Overrides the values provided via ``txn_kwargs``. For a list of
544+
available transaction kwargs, see :class:`~ape.api.transactions.TransactionAPI`.
545+
reset_txn_kwargs (dict | None): Additional transaction kwargs passed to
546+
:meth:`~ape.api.networks.EcosystemAPI.create_transaction` for the
547+
:meth:`AccountAPI.remove_delegate` method, such as ``gas``, ``max_fee``, or
548+
``max_priority_fee``. Overrides the values provided via ``txn_kwargs``. For a list of
549+
available transaction kwargs, see :class:`~ape.api.transactions.TransactionAPI`.
550+
**txn_kwargs: Additional transaction kwargs passed to
551+
:meth:`~ape.api.networks.EcosystemAPI.create_transaction`, such as ``gas``
552+
``max_fee``, or ``max_priority_fee``. For a list of available transaction
553+
kwargs, see :class:`~ape.api.transactions.TransactionAPI`.
554+
555+
Returns:
556+
`:class:~ape.contracts.ContractInstance`:
557+
The contract instance of this account with the interface of `contract`.
558+
"""
559+
set_txn_kwargs = {**txn_kwargs, **(set_txn_kwargs or {})}
560+
existing_delegate = self.delegate
561+
562+
self.set_delegate(new_delegate, **set_txn_kwargs)
563+
564+
# NOTE: Do not cache this type as it is temporary
565+
from ape.contracts import ContractInstance
566+
567+
# This is helpful for using it immediately to send things as self
568+
with self.account_manager.use_sender(self):
569+
if isinstance(new_delegate, ContractInstance):
570+
# NOTE: Do not cache this
571+
yield ContractInstance(self.address, contract_type=new_delegate.contract_type)
572+
573+
else:
574+
yield self
575+
576+
reset_txn_kwargs = {**txn_kwargs, **(reset_txn_kwargs or {})}
577+
if existing_delegate:
578+
self.set_delegate(existing_delegate, **reset_txn_kwargs)
579+
else:
580+
self.remove_delegate(**reset_txn_kwargs)
581+
441582

442583
class AccountContainerAPI(BaseInterfaceModel):
443584
"""
@@ -534,7 +675,7 @@ def append(self, account: AccountAPI):
534675
self.__setitem__(account.address, account)
535676

536677
def __setitem__(self, address: AddressType, account: AccountAPI):
537-
raise NotImplementedError("Must define this method to use `container.append(acct)`.")
678+
raise APINotImplementedError("Must define this method to use `container.append(acct)`.")
538679

539680
def remove(self, account: AccountAPI):
540681
"""
@@ -563,7 +704,7 @@ def __delitem__(self, address: AddressType):
563704
Args:
564705
address (:class:`~ape.types.address.AddressType`): The address of the account to delete.
565706
"""
566-
raise NotImplementedError("Must define this method to use `container.remove(acct)`.")
707+
raise APINotImplementedError("Must define this method to use `container.remove(acct)`.")
567708

568709
def __contains__(self, address: AddressType) -> bool:
569710
"""
@@ -689,7 +830,7 @@ def address(self) -> AddressType:
689830
return self.raw_address
690831

691832
def sign_message(self, msg: Any, **signer_options) -> Optional[MessageSignature]:
692-
raise NotImplementedError("This account cannot sign messages")
833+
raise APINotImplementedError("This account cannot sign messages")
693834

694835
def sign_transaction(self, txn: TransactionAPI, **signer_options) -> Optional[TransactionAPI]:
695836
# Returns input transaction unsigned (since it doesn't have access to the key)

src/ape/api/address.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
from abc import abstractmethod
22
from functools import cached_property
3-
from typing import TYPE_CHECKING, Any
4-
5-
from eth_pydantic_types import HexBytes
3+
from typing import TYPE_CHECKING, Any, Optional
64

75
from ape.exceptions import AccountsError, ConversionError
86
from ape.types.address import AddressType
@@ -171,15 +169,34 @@ def codesize(self) -> int:
171169
The number of bytes in the smart contract.
172170
"""
173171

174-
return len(self.code)
172+
code = self.code
173+
return len(code) if isinstance(code, bytes) else len(bytes.fromhex(code.lstrip("0x")))
175174

176175
@property
177176
def is_contract(self) -> bool:
178177
"""
179178
``True`` when there is code associated with the address.
180179
"""
181180

182-
return len(HexBytes(self.code)) > 0
181+
return self.codesize > 0
182+
183+
@property
184+
def delegate(self) -> Optional["BaseAddress"]:
185+
"""
186+
Check and see if Account has a "delegate" contract, which is a contract that this account
187+
delegates functionality to. This could be from many contexts, such as a Smart Wallet like
188+
Safe (https://github.com/ApeWorX/ape-safe) which has a Singleton class it forwards to, or
189+
an EOA using an EIP7702-style delegate. Returning ``None`` means that the account does not
190+
have a delegate.
191+
192+
The default behavior is to use `:class:~ape.managers.ChainManager.get_delegate` to check if
193+
the account has a proxy, such as ``SafeProxy`` for ``ape-safe`` or an EIP7702 delegate.
194+
195+
Returns:
196+
Optional[`:class:~ape.contracts.ContractInstance`]:
197+
The contract instance of the delegate contract (if available).
198+
"""
199+
return self.chain_manager.get_delegate(self.address)
183200

184201
@cached_property
185202
def history(self) -> "AccountHistory":

src/ape/managers/chain.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from rich.box import SIMPLE
1010
from rich.table import Table
1111

12-
from ape.api.address import BaseAddress
12+
from ape.api.address import Address, BaseAddress
1313
from ape.api.providers import BlockAPI
1414
from ape.api.query import (
1515
AccountTransactionQuery,
@@ -22,6 +22,7 @@
2222
APINotImplementedError,
2323
BlockNotFoundError,
2424
ChainError,
25+
ContractNotFoundError,
2526
ProviderNotConnectedError,
2627
QueryEngineError,
2728
TransactionNotFoundError,
@@ -1016,3 +1017,18 @@ def get_code(
10161017
code = self.provider.get_code(address)
10171018
self._code[network.ecosystem.name][network.name][address] = code
10181019
return code
1020+
1021+
def get_delegate(self, address: AddressType) -> Optional[BaseAddress]:
1022+
ecosystem = self.provider.network.ecosystem
1023+
1024+
if not (proxy_info := ecosystem.get_proxy_info(address)):
1025+
return None
1026+
1027+
# NOTE: Do this every time because it can change?
1028+
self.contracts.cache_proxy_info(address, proxy_info)
1029+
1030+
try:
1031+
return self.contracts.instance_at(proxy_info.target)
1032+
1033+
except ContractNotFoundError:
1034+
return Address(proxy_info.target)

0 commit comments

Comments
 (0)