Skip to content

Commit 29ebda3

Browse files
authored
feat: support modules (#84)
* feat(Account): add support for managing modules * test: add tests for module support * fix(Modules): Safe ModuleGuard must be obtained from storage slot * feat(CLI): add Safe Modules subcommand * refactor(Account): better error display when version not detected * fix(CLI): different error shows when not connected now * fix(Account): missing signers when using forked mainnet * refactor(CLI): remove `--verbose` flag from modules cli * feat(CLI): add `enable`/`disable` to modules cli * refactor(CLI): use Safe argument instead of option * refactor(Modules,CLI): add `propose` kwargs/flag for modules * refactor(CLI): move types out of type checking pass so that click works * refactor(CLI): remove typing from modules cli, causes issue w/ Click * tests(MultiSend): refactor how we use the multisend fixture a bit * test: stop testing 3.9, it has weird issues * docs(CLI): add page for modules CLI * feat(Modules): add `remove_guard` method to reset Module Guard * docs(Modules): added module userguide * refactor(CLI): remove unnecessary `@network_option` * docs(CLI): removed unnecessary comma
1 parent 7365589 commit 29ebda3

File tree

14 files changed

+1822
-19
lines changed

14 files changed

+1822
-19
lines changed

.github/workflows/test.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@ jobs:
6060
strategy:
6161
matrix:
6262
os: [ubuntu-latest, macos-latest] # eventually add `windows-latest`
63-
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
63+
# NOTE: Don't test 3.9, it has weird issues on CI
64+
python-version: ["3.10", "3.11", "3.12", "3.13"]
6465

6566
env:
6667
GITHUB_ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }}

ape_safe/_cli/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import click
22

33
from ape_safe._cli.delegates import delegates
4+
from ape_safe._cli.modules import modules
45
from ape_safe._cli.pending import pending
56
from ape_safe._cli.safe_mgmt import _list, add, all_txns, remove
67

@@ -18,4 +19,5 @@ def cli():
1819
cli.add_command(remove)
1920
cli.add_command(all_txns)
2021
cli.add_command(pending)
22+
cli.add_command(modules)
2123
cli.add_command(delegates)

ape_safe/_cli/modules.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import click
2+
from ape.cli import ConnectedProviderCommand, account_option, ape_cli_context
3+
from ape.types import AddressType
4+
5+
from ape_safe._cli.click_ext import safe_argument
6+
7+
8+
@click.group()
9+
def modules():
10+
"""
11+
Commands for handling safe modules
12+
"""
13+
14+
15+
@modules.command("list", cls=ConnectedProviderCommand)
16+
@safe_argument
17+
def _list(safe):
18+
"""List all modules enabled for SAFE"""
19+
for module in safe.modules:
20+
click.echo(repr(module))
21+
22+
23+
@modules.command(cls=ConnectedProviderCommand)
24+
@safe_argument
25+
def guard(safe):
26+
"""Show module guard (if enabled) for SAFE"""
27+
if guard := safe.modules.guard:
28+
click.echo(f"Guard: {guard}")
29+
30+
else:
31+
click.secho("No Module Guard set", fg="red")
32+
33+
34+
@modules.command(cls=ConnectedProviderCommand)
35+
@ape_cli_context()
36+
@account_option()
37+
@safe_argument
38+
@click.option("--propose", is_flag=True, default=False)
39+
@click.argument("module")
40+
def enable(cli_ctx, safe, account, module, propose):
41+
"""
42+
Enable MODULE for SAFE
43+
44+
**WARNING**: This is a potentially destructive action and may make your safe vulnerable.
45+
"""
46+
module = cli_ctx.conversion_manager.convert(module, AddressType)
47+
safe.modules.enable(module, submitter=account, propose=propose)
48+
49+
50+
@modules.command(cls=ConnectedProviderCommand)
51+
@ape_cli_context()
52+
@account_option()
53+
@safe_argument
54+
@click.option("--propose", is_flag=True, default=False)
55+
@click.argument("module")
56+
def disable(cli_ctx, safe, account, module, propose):
57+
"""
58+
Disable MODULE for SAFE
59+
60+
**WARNING**: This is a potentially destructive action and may impact operations of your safe.
61+
"""
62+
module = cli_ctx.conversion_manager.convert(module, AddressType)
63+
safe.modules.disable(module, submitter=account, propose=propose)

ape_safe/_cli/safe_mgmt.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
from ape_safe._cli.click_ext import SafeCliContext, safe_argument, safe_cli_ctx
1313
from ape_safe.client import SafeClient
14+
from ape_safe.exceptions import NoVersionDetected
1415

1516

1617
@click.command(name="list")
@@ -49,7 +50,7 @@ def _list(cli_ctx: SafeCliContext, network, provider, verbose):
4950
output: str = ""
5051
try:
5152
extras.append(f"version: '{safe.version}'")
52-
except (ChainError, ProviderNotConnectedError):
53+
except (ChainError, ProviderNotConnectedError, NoVersionDetected):
5354
# Not connected to the network where safe is deployed
5455
extras.append("version: (not connected)")
5556

ape_safe/accounts.py

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import itertools
12
import json
23
import os
34
from collections.abc import Iterable, Iterator, Mapping
@@ -10,7 +11,6 @@
1011
from ape.contracts import ContractCall
1112
from ape.exceptions import ContractNotFoundError, ProviderNotConnectedError
1213
from ape.logging import logger
13-
from ape.managers.accounts import AccountManager, TestAccountManager
1414
from ape.types import AddressType, HexBytes, MessageSignature
1515
from ape.utils import ZERO_ADDRESS, cached_property
1616
from ape_ethereum.proxies import ProxyInfo, ProxyType
@@ -28,10 +28,12 @@
2828
NoLocalSigners,
2929
NotASigner,
3030
NotEnoughSignatures,
31+
NoVersionDetected,
3132
SafeClientException,
3233
handle_safe_logic_error,
3334
)
3435
from .factory import SafeFactory
36+
from .modules import SafeModuleManager
3537
from .packages import PackageType
3638
from .types import SafeCacheData
3739
from .utils import get_safe_tx_hash, order_by_signer
@@ -200,6 +202,7 @@ def __dir__(self) -> list[str]:
200202
return [
201203
"contract",
202204
"fallback_handler",
205+
"modules",
203206
"guard",
204207
"set_guard",
205208
"version",
@@ -278,6 +281,10 @@ def fallback_handler(self) -> Optional["ContractInstance"]:
278281
self.chain_manager.contracts.instance_at(address) if address != ZERO_ADDRESS else None
279282
)
280283

284+
@cached_property
285+
def modules(self) -> SafeModuleManager:
286+
return SafeModuleManager(self)
287+
281288
@property
282289
def guard(self) -> Optional["ContractInstance"]:
283290
slot = keccak(text="guard_manager.guard.address")
@@ -338,12 +345,8 @@ def version(self) -> Version:
338345
if isinstance(version := ContractCall(VERSION_ABI, address=self.address)(), str):
339346
return Version(version)
340347

341-
# NOTE: If `eth_call` returns nothing, it will be rendered as randomly
342-
raise ContractNotFoundError(
343-
self.address,
344-
bool(self.provider.network.explorer),
345-
self.provider.network_choice,
346-
)
348+
# NOTE: If `eth_call` returns nothing, the safe is likely not on the correct network
349+
raise NoVersionDetected(self.address)
347350

348351
@property
349352
def signers(self) -> list[AddressType]:
@@ -500,20 +503,24 @@ def local_signers(self) -> list[AccountAPI]:
500503
# NOTE: Is not ordered by signing order
501504
# TODO: Skip per user config
502505
# TODO: Order per user config
503-
container: Union[AccountManager, TestAccountManager]
506+
accounts: Iterable[AccountAPI]
504507
if self.network_manager.active_provider and self.provider.network.is_dev:
505-
container = self.account_manager.test_accounts
508+
accounts = itertools.chain(
509+
iter(self.account_manager),
510+
iter(self.account_manager.test_accounts),
511+
)
512+
506513
else:
507-
container = self.account_manager
514+
accounts = iter(self.account_manager)
508515

509516
# Ensure the contract is available before continuing.
510517
# Else, return an empty list
511518
try:
512-
_ = self.contract
519+
signers = self.signers
513520
except ContractNotFoundError:
514521
return []
515522

516-
return list(container[address] for address in self.signers if address in container)
523+
return list(filter(lambda a: a in signers, accounts))
517524

518525
@handle_safe_logic_error()
519526
def create_execute_transaction(

ape_safe/exceptions.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
from contextlib import ContextDecorator
22
from typing import TYPE_CHECKING, Optional
33

4-
from ape.exceptions import AccountsError, ApeException, ContractLogicError, SignatureError
4+
from ape.exceptions import (
5+
AccountsError,
6+
ApeException,
7+
ContractLogicError,
8+
DecodingError,
9+
SignatureError,
10+
)
511

612
if TYPE_CHECKING:
713
from ape.types import AddressType
@@ -18,6 +24,14 @@ class ApeSafeError(ApeSafeException, AccountsError):
1824
"""
1925

2026

27+
class NoVersionDetected(ApeSafeException, DecodingError):
28+
def __init__(self, safe: "AddressType"):
29+
super().__init__(
30+
f"Could not detect `VERSION()` for {safe}.\n\n"
31+
"**Are you sure you are on the right network?**"
32+
)
33+
34+
2135
class NotASigner(ApeSafeException):
2236
def __init__(self, signer: "AddressType"):
2337
super().__init__(f"{signer} is not a valid signer.")

ape_safe/modules.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
from typing import TYPE_CHECKING, Optional, Union
2+
3+
from ape.types import AddressType
4+
from ape.utils import ZERO_ADDRESS, ManagerAccessMixin
5+
from eth_utils import to_checksum_address
6+
7+
if TYPE_CHECKING:
8+
from collections.abc import Iterator
9+
10+
from ape.api import AccountAPI, ReceiptAPI
11+
from ape.contracts import ContractInstance
12+
13+
from ape_safe.accounts import SafeAccount
14+
from ape_safe.client import SafeTxID
15+
16+
17+
class SafeModuleManager(ManagerAccessMixin):
18+
SENTINEL: AddressType = "0x0000000000000000000000000000000000000001"
19+
PAGE_SIZE: int = 100
20+
21+
def __init__(self, safe: "SafeAccount"):
22+
self._safe = safe
23+
24+
def __repr__(self) -> str:
25+
return f"<{self.__class__.__qualname__} safe={self._safe.address}"
26+
27+
def __contains__(self, module: Union[str, AddressType, "ContractInstance"]) -> bool:
28+
return self._safe.contract.isModuleEnabled(module)
29+
30+
def enable(
31+
self,
32+
module: Union[str, AddressType, "ContractInstance"],
33+
submitter: Union["AccountAPI", AddressType, str, None] = None,
34+
propose: bool = False,
35+
**txn_kwargs,
36+
) -> Union["ReceiptAPI", "SafeTxID"]:
37+
if propose:
38+
txn = self._safe.contract.enableModule.as_transaction(module, **txn_kwargs)
39+
return self._safe.propose(txn=txn, submitter=submitter)
40+
41+
else:
42+
return self._safe.contract.enableModule(
43+
module,
44+
sender=self._safe,
45+
submitter=submitter,
46+
**txn_kwargs,
47+
)
48+
49+
def __iter__(self) -> "Iterator[ContractInstance]":
50+
start_module = self.SENTINEL
51+
while True:
52+
page, start_module = self._safe.contract.getModulesPaginated(
53+
start_module, self.PAGE_SIZE
54+
)
55+
yield from map(self.chain_manager.contracts.instance_at, page)
56+
57+
if start_module == self.SENTINEL:
58+
break
59+
60+
def _get_previous_module(
61+
self, module: Union[str, AddressType, "ContractInstance"]
62+
) -> "AddressType":
63+
prev_module = self.SENTINEL
64+
65+
for next_module in self:
66+
if next_module == module:
67+
return prev_module
68+
69+
prev_module = next_module
70+
71+
raise AssertionError(f"Module {module} not in Safe modules for {self._safe}")
72+
73+
def disable(
74+
self,
75+
module: Union[str, AddressType, "ContractInstance"],
76+
submitter: Union["AccountAPI", AddressType, str, None] = None,
77+
propose: bool = False,
78+
**txn_kwargs,
79+
) -> Union["ReceiptAPI", "SafeTxID"]:
80+
if propose:
81+
txn = self._safe.contract.disableModule.as_transaction(
82+
self._get_previous_module(module),
83+
module,
84+
**txn_kwargs,
85+
)
86+
return self._safe.propose(txn=txn, submitter=submitter)
87+
88+
else:
89+
return self._safe.contract.disableModule(
90+
self._get_previous_module(module),
91+
module,
92+
sender=self._safe,
93+
**txn_kwargs,
94+
)
95+
96+
@property
97+
def guard(self) -> Optional["ContractInstance"]:
98+
if (
99+
module_guard_address := to_checksum_address(
100+
self.provider.get_storage(
101+
self._safe.contract.address,
102+
# NOTE: `keccak256("module_manager.module_guard.address")`
103+
"0xb104e0b93118902c651344349b610029d694cfdec91c589c91ebafbcd0289947",
104+
)[12:]
105+
)
106+
) == ZERO_ADDRESS:
107+
return None
108+
109+
return self.chain_manager.contracts.instance_at(module_guard_address)
110+
111+
def set_guard(
112+
self,
113+
guard: Union[str, AddressType, "ContractInstance"],
114+
submitter: Union["AccountAPI", AddressType, str, None] = None,
115+
propose: bool = False,
116+
**txn_kwargs,
117+
) -> Union["ReceiptAPI", "SafeTxID"]:
118+
if propose:
119+
txn = self._safe.contract.setModuleGuard.as_transaction(guard, **txn_kwargs)
120+
return self._safe.propose(txn=txn, submitter=submitter)
121+
122+
else:
123+
return self._safe.contract.setModuleGuard(guard, sender=self._safe, **txn_kwargs)
124+
125+
def remove_guard(self, **txn_kwargs) -> Union["ReceiptAPI", "SafeTxID"]:
126+
return self.set_guard(ZERO_ADDRESS, **txn_kwargs)

docs/commands/modules.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
Modules
2+
*******
3+
4+
.. click:: ape_safe._cli.modules:modules
5+
:prog: ape safe modules
6+
:nested: full

docs/index.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
- transactions
55
- safe_management
66
- multisend
7+
- modules
78
:commands:
89
- mgmt
910
- pending
1011
- delegates
12+
- modules

0 commit comments

Comments
 (0)