Skip to content

Commit 907bb11

Browse files
fubuloubuantazoey
andauthored
feat: allow configuring Safes for cloud use (#76)
* refactor(Config): use model for cache data file under `~/.ape/safe` * feat(Config): add way to configure required Safes (if they don't exist) * docs: add `pyproject.toml` and environment variable usage to docs * refactor(Account): cache `.address` call on `SafeAccount` * docs(Account): fix note Co-authored-by: antazoey <admin@antazoey.me> * docs(Account): add a note about not overwriting existing Safes * refactor(Account): specify encoding for file --------- Co-authored-by: antazoey <admin@antazoey.me>
1 parent 2ab8bf8 commit 907bb11

4 files changed

Lines changed: 88 additions & 7 deletions

File tree

README.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,19 @@ safe:
6060
default_safe: my-safe
6161
```
6262
63+
or via `pyproject.toml`:
64+
65+
```toml
66+
[tool.ape.safe]
67+
default_safe = "my-safe"
68+
```
69+
70+
To specify via environment variable, do:
71+
72+
```sh
73+
APE_SAFE_DEFAULT_SAFE="my-safe"
74+
```
75+
6376
**NOTE**: Also, to avoid always needing to specify `--network`, you can set a default ecosystem, network, and provider in your config file.
6477
The rest of the guide with not specify `--network` on each command but assume the correct one is set in the config file.
6578
Here is an example:
@@ -152,6 +165,38 @@ txn.add(vault.deposit, amount)
152165
txn(sender=safe,gas=0)
153166
```
154167

168+
## Cloud Usage
169+
170+
To use this plugin in a cloud environment, such as with the [Silverback Platform](https://silverback.apeworx.io), you will need to make sure that you have configured your Safe to exist within the environment.
171+
The easiest way to do this is to use the `require` configuration item.
172+
To specify a required Safe in your `ape-config.yaml` (which adds it into your `~/.ape/safe` folder if it doesn't exist), use:
173+
174+
```yaml
175+
safe:
176+
require:
177+
my-safe:
178+
address: "0x1234...AbCd"
179+
deployed_chain_ids: [1, ...]
180+
```
181+
182+
or in `pyproject.toml`:
183+
184+
```toml
185+
[tool.ape.safe.require."my-safe"]
186+
address = "0x1234...AbCd"
187+
deployed_chain_ids = [1, ...]
188+
```
189+
190+
To specify via environment variable, do:
191+
192+
```sh
193+
APE_SAFE_REQUIRE='{"my-safe":{"address":"0x1234...AbCd","deployed_chain_ids":[1,...]}}'
194+
```
195+
196+
```{notice}
197+
If a safe with the same alias as an entry in `require` exists in your local environment, this will skip adding it, even if the existing alias points to a different address than the one in the config item.
198+
```
199+
155200
## Development
156201

157202
Please see the [contributing guide](CONTRIBUTING.md) to learn more how to contribute to this project.

ape_safe/accounts.py

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from packaging.version import Version
2323

2424
from .client import BaseSafeClient, MockSafeClient, SafeClient, SafeTx, SafeTxConfirmation, SafeTxID
25+
from .config import SafeConfig
2526
from .exceptions import (
2627
ApeSafeError,
2728
NoLocalSigners,
@@ -32,6 +33,7 @@
3233
)
3334
from .factory import SafeFactory
3435
from .packages import PackageType
36+
from .types import SafeCacheData
3537
from .utils import get_safe_tx_hash, order_by_signer
3638

3739
if TYPE_CHECKING:
@@ -42,9 +44,23 @@
4244
class SafeContainer(AccountContainerAPI):
4345
_accounts: dict[str, "SafeAccount"] = {}
4446

47+
@property
48+
def config(self) -> SafeConfig:
49+
return cast(SafeConfig, self.config_manager["safe"])
50+
4551
@property
4652
def _account_files(self) -> Iterator[Path]:
47-
yield from self.data_folder.glob("*.json")
53+
account_files = list(self.data_folder.glob("*.json"))
54+
55+
# NOTE: Make sure these Safes exist in our local cache
56+
for required_safe, safe_cache_data in self.config.require.items():
57+
# NOTE: If alias `required_safe` already exists, skip overwriting it
58+
if required_safe not in map(lambda p: p.stem, account_files):
59+
safe_cache_file = self.data_folder / f"{required_safe}.json"
60+
safe_cache_file.write_text(safe_cache_data.model_dump_json(), encoding="utf-8")
61+
account_files.append(safe_cache_file)
62+
63+
yield from account_files
4864

4965
@property
5066
def aliases(self) -> Iterator[str]:
@@ -204,17 +220,17 @@ def alias(self) -> str:
204220
return self.account_file_path.stem
205221

206222
@property
207-
def account_file(self) -> dict:
208-
return json.loads(self.account_file_path.read_text())
223+
def account_file(self) -> SafeCacheData:
224+
return SafeCacheData.model_validate_json(self.account_file_path.read_text(encoding="utf-8"))
209225

210-
@property
226+
@cached_property
211227
def address(self) -> AddressType:
212228
try:
213229
ecosystem = self.provider.network.ecosystem
214230
except ProviderNotConnectedError:
215231
ecosystem = self.network_manager.ethereum
216232

217-
return ecosystem.decode_address(self.account_file["address"])
233+
return ecosystem.decode_address(self.account_file.address)
218234

219235
@cached_property
220236
def contract(self) -> "ContractInstance":
@@ -280,15 +296,15 @@ def client(self) -> BaseSafeClient:
280296
if self.provider.network.is_local:
281297
return MockSafeClient(contract=self.contract)
282298

283-
elif chain_id in self.account_file["deployed_chain_ids"]:
299+
elif chain_id in self.account_file.deployed_chain_ids:
284300
return SafeClient(
285301
address=self.address, chain_id=self.provider.chain_id, override_url=override_url
286302
)
287303

288304
elif (
289305
self.provider.network.name.endswith("-fork")
290306
and isinstance(self.provider.network, ForkedNetworkAPI)
291-
and self.provider.network.upstream_chain_id in self.account_file["deployed_chain_ids"]
307+
and self.provider.network.upstream_chain_id in self.account_file.deployed_chain_ids
292308
):
293309
return SafeClient(
294310
address=self.address,

ape_safe/config.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
11
from typing import Optional
22

33
from ape.api import PluginConfig
4+
from pydantic_settings import SettingsConfigDict
5+
6+
from .types import SafeCacheData
47

58

69
class SafeConfig(PluginConfig):
710
default_safe: Optional[str] = None
811
"""Alias of the default safe."""
12+
13+
require: dict[str, SafeCacheData] = {}
14+
"""Safes that are required to exist for the project. Useful for cloud-based usage."""
15+
16+
model_config = SettingsConfigDict(env_prefix="APE_SAFE_")

ape_safe/types.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from ape.types.address import AddressType
2+
from pydantic import BaseModel
3+
4+
5+
class SafeCacheData(BaseModel):
6+
"""Model for cached Safe data under `~/.ape/safe/*.json`"""
7+
8+
address: AddressType
9+
"""Address of the Safe"""
10+
11+
deployed_chain_ids: list[int] = []
12+
"""Networks (by chain ID) this Safe is deployed on"""

0 commit comments

Comments
 (0)