Skip to content

Commit 4ebf77b

Browse files
authored
docs: add AGENTS.md (#2755)
AGENTS.md is the canonical agent guide. CLAUDE.md is a symlink to it so both names load the same file.
1 parent cf46dea commit 4ebf77b

2 files changed

Lines changed: 320 additions & 0 deletions

File tree

‎AGENTS.md‎

Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Project Overview
6+
7+
`safe-eth-py` is a Python library (published to PyPI as `safe-eth-py`, previously `gnosis-py`), not a
8+
service. It provides `EthereumClient`, a wrapper over `web3.py` with ERC20/ERC721/tracing/batching
9+
helpers, the `Safe` contract classes and Safe transaction/signature handling, price oracles, clients
10+
for external services (Etherscan, Blockscout, Sourcify, ENS, CowSwap, Safe Transaction Service) and
11+
an optional Django layer.
12+
13+
It is a shared dependency of the Safe Python backends, so any public API change ripples into
14+
`safe-transaction-service`, `safe-queue-service`, `safe-auth-service`, `safe-decoder-service` and
15+
`safe-cli`. Check the consumers before renaming or changing the signature of anything exported.
16+
17+
## Team and Project Context
18+
19+
- **Team**: Platform
20+
- **Repository**: `safe-global/safe-eth-py`
21+
22+
### Linear Guidelines
23+
24+
- Create issues under team **Platform** with the `eth-py` and `Backend` labels
25+
- Use `add-new-address` for new chain address issues (that flow is automated, see below)
26+
- Add PR links as issue attachments/links
27+
- Branches follow the Linear name: `uxio/pla-<number>-<slug>`. Plain `feat/<scope>`, `fix/<scope>`,
28+
`chore/<scope>` branches are also used for work without an issue
29+
30+
## Development Setup
31+
32+
### Initial Setup
33+
```bash
34+
uv sync --group dev --all-extras --frozen
35+
source .venv/bin/activate
36+
pre-commit install -f
37+
```
38+
39+
`--all-extras` pulls the `django` extra, which the test suite needs. `uv.lock` is the source of
40+
truth: always sync `--frozen`, run `uv lock` after editing `pyproject.toml` and commit both.
41+
`[tool.uv] exclude-newer = "7 days"` rejects packages published in the last 7 days, so a brand new
42+
release cannot be locked yet.
43+
44+
### Running Tests
45+
46+
Tests need Postgres (Django test database) and a ganache node on `localhost:8545` started with the
47+
fixed mnemonic (`-d`), because the test mixins deploy Safe and Multicall contracts on it:
48+
49+
```bash
50+
docker compose up -d db ganache
51+
52+
# Run all tests
53+
pytest
54+
55+
# Run a single test file
56+
pytest safe_eth/safe/tests/test_safe.py
57+
58+
# Run a specific test
59+
pytest safe_eth/safe/tests/test_safe.py::TestSafe::test_estimate_tx_gas
60+
61+
# Run with coverage
62+
coverage run --source=safe_eth -m pytest -rxXs
63+
coverage report
64+
65+
# compose up + pytest + compose down
66+
./run_tests.sh
67+
```
68+
69+
`DJANGO_SETTINGS_MODULE=config.settings.test` is set automatically by `pytest-env`
70+
(`[tool.pytest_env]` in `pyproject.toml`), so plain `pytest` works. The `config/` package exists only
71+
to run the Django part of the suite and is not shipped in the wheel.
72+
73+
### Linting and Type Checking
74+
```bash
75+
pre-commit run --all-files # isort, black, flake8, mypy — this is what CI runs
76+
mypy safe_eth
77+
```
78+
79+
### Building the Docs
80+
```bash
81+
./build_docs.sh # sphinx-apidoc + make html, output in docs/build
82+
```
83+
84+
## Architecture
85+
86+
### `safe_eth.eth` — node access
87+
88+
`EthereumClient` wraps `web3.py` and composes domain managers, all built in its `__init__`:
89+
90+
- `.erc20` (`Erc20Manager`), `.erc721` (`Erc721Manager`), `.tracing` (`TracingManager`),
91+
`.batch_call_manager` (`BatchCallManager`) — all subclasses of `EthereumClientManager`
92+
- `.multicall` (`Multicall`), deployed per chain or from `safe_eth/eth/multicall.py` addresses
93+
94+
New node-level features belong in a manager, not on the client itself. `async_ethereum_client.py`
95+
mirrors the sync client for async callers; a feature added to one usually has to be added to both.
96+
97+
Performance rules that hold across the codebase:
98+
- Prefer `batch_call` / multicall over N sequential RPC calls
99+
- Prefer `fast_to_checksum_address` / `fast_is_checksum_address` / `fast_keccak`
100+
(`safe_eth/eth/utils.py`, pysha3-backed and `lru_cache`d) over the `Web3` equivalents
101+
102+
### Contracts are loaded from bundled ABIs
103+
104+
`safe_eth/eth/contracts/__init__.py` holds a `contracts` dict mapping a name to a JSON file under
105+
`abis/`, and generates the `get_<name>_contract(w3, address)` functions dynamically with `setattr` at
106+
import time. The module also declares typed stubs for those generated functions so mypy sees them.
107+
Deployed-bytecode getters (`get_proxy_1_3_0_deployed_bytecode`, …) are `@cache`d and written by hand.
108+
109+
### `safe_eth.safe` — Safe protocol logic
110+
111+
`Safe.__new__` is a factory: `Safe(address, ethereum_client)` detects the deployed version over RPC
112+
(or takes `version=` to skip the lookup) and returns the matching subclass from `_version_class_map()`
113+
— `SafeV001`, `SafeV100`, `SafeV111`, `SafeV120`, `SafeV130`, `SafeV141`, `SafeV150`. Version specific
114+
behaviour goes in the subclass, shared behaviour in `Safe`, and `SafeCompatibilityAdapter` holds what
115+
1.4.1 and 1.5.0 share. `proxy_factory.py` and `compatibility_fallback_handler.py` use the same
116+
version-subclass pattern.
117+
118+
Every contract wrapper extends `ContractBase` (`safe_eth/eth/contracts/contract_base.py`), which
119+
requires a `get_contract_fn()` returning one of the generated contract getters and exposes a
120+
`cached_property contract`.
121+
122+
Other core modules:
123+
- `safe_tx.py`: builds, hashes (EIP-712) and signs Safe transactions
124+
- `safe_signature.py`: parses the packed signature blob into `SafeSignature` / `SafeSignatureAsync`
125+
objects by `SafeSignatureType` (EOA, ETH_SIGN, APPROVED_HASH, CONTRACT_SIGNATURE/EIP-1271, SECP256R1)
126+
- `multi_send.py`, `safe_create2_tx.py`, `safe_creator.py`, `p256.py`
127+
- `account_abstraction/safe_operation.py` on top of `safe_eth/eth/account_abstraction/` (ERC-4337
128+
user operations and bundler client)
129+
130+
### External service clients
131+
132+
`safe_eth/eth/clients/` (Etherscan v2, Blockscout, Sourcify, ENS, CowSwap) and `safe_eth/safe/api/`
133+
(Transaction Service API on `base_api.py`). Several have both sync and async variants. `oracles/`
134+
holds the price oracles (Uniswap v2/v3, Kyber, SushiSwap, Curve, Superfluid…).
135+
136+
### Optional Django layer
137+
138+
`safe_eth/eth/django/` (model fields, serializers, filters, validators, forms) and
139+
`safe_eth/safe/serializers.py` are only importable with the `django` extra. Keep Django imports out
140+
of the core modules. Where a value can come from Django settings or the environment, Django settings
141+
win when Django is installed — see `get_auto_ethereum_client` and
142+
`EthereumTestCaseMixin.get_ethereum_test_account`.
143+
144+
### Generated data files — do not hand-edit
145+
146+
- `safe_eth/safe/safe_deployments.py`: generated from the `safe-deployments` repo by
147+
`scripts/generators/generate_safe_deployments.py`
148+
- `safe_eth/eth/ethereum_network.py` (the `EthereumNetwork` enum, ~2000 chains) and the multicall
149+
addresses: `scripts/generators/generate_chains_list.py` and `generate_chains_info_from_viem.py`
150+
- `safe_eth/safe/addresses.py`: per chain, `MASTER_COPIES` as `(address, deployment block, version)`
151+
and `PROXY_FACTORIES` as `(address, deployment block)`. Updated by the add-new-address workflow
152+
153+
## Configuration
154+
155+
The library reads everything from environment variables, all optional. `README.rst` has the canonical
156+
list with defaults; the groups are:
157+
158+
- **RPC client**: `ETHEREUM_NODE_URL` (ignored under Django, which uses `settings.ETHEREUM_NODE_URL`),
159+
`ETHEREUM_RPC_TIMEOUT`, `ETHEREUM_RPC_SLOW_TIMEOUT`, `ETHEREUM_RPC_RETRY_COUNT`,
160+
`ETHEREUM_RPC_BATCH_REQUEST_MAX_SIZE`
161+
- **Caching**: `CACHE_KECCAK`, `CACHE_CHECKSUM_ADDRESS` (`lru_cache` sizes for the fast helpers)
162+
- **Contract addresses**: `SAFE_SINGLETON_FACTORY_ADDRESS`, `SAFE_SIMULATE_TX_ACCESSOR_ADDRESS`, for
163+
chains where the deterministic address differs
164+
- **Transaction Service**: `SAFE_TRANSACTION_SERVICE_API_KEY` (JWT from developer.safe.global),
165+
`SAFE_TRANSACTION_SERVICE_REQUEST_TIMEOUT`
166+
- **Block explorer / source clients**: `ETHERSCAN_CLIENT_*`, `BLOCKSCOUT_CLIENT_*`, `SOURCIFY_*`,
167+
`ENS_CLIENT_REQUEST_TIMEOUT`. `ETHERSCAN_CLIENT_MAX_REQUESTS` and `BLOCKSCOUT_CLIENT_MAX_REQUESTS`
168+
only tune the async clients' pools. `SOURCIFY_CLIENT_MAX_REQUESTS` applies to both: the sync
169+
`SourcifyClient` passes it to `prepare_http_session` as `pool_maxsize`
170+
171+
Anything new added here must be documented in `README.rst`.
172+
173+
## Testing Strategy
174+
175+
Tests live next to the code they cover, in `tests/` packages inside each module
176+
(`safe_eth/eth/tests/`, `safe_eth/safe/tests/`, `safe_eth/util/tests/`).
177+
178+
Base classes:
179+
- `EthereumTestCaseMixin` (`safe_eth/eth/tests/ethereum_test_case.py`): sets up `ethereum_client`,
180+
`w3`, a funded `ethereum_test_account` and deploys Multicall. The client is cached across test
181+
classes, so do not mutate it
182+
- `SafeTestCaseMixin` (`safe_eth/safe/tests/safe_test_case.py`): adds deployed singletons for Safe
183+
0.0.1, 1.0.0, 1.1.1, 1.3.0, 1.4.1 and 1.5.0, the 1.4.1 and 1.5.0 proxy factories, MultiSend and the
184+
fallback handlers, plus `deploy_test_safe*` helpers. `SafeV120` exists in `_version_class_map()`
185+
but has no fixture: 1.2.0 shipped with a bug, was replaced by 1.3.0 and never used in production
186+
187+
Conventions:
188+
- Tests are `unittest`-style `TestCase` classes run under pytest, not bare pytest functions
189+
- The async client tests (`test_async_ethereum_client.py`) subclass the sync test classes and swap in
190+
a proxy that drives the coroutines, so a sync test added there is covered on both clients
191+
- Tests hitting a real network call `just_test_if_mainnet_node()` / `just_test_if_polygon_node()`
192+
(`safe_eth/eth/tests/utils.py`). An unset `ETHEREUM_MAINNET_NODE` / `ETHEREUM_POLYGON_NODE`
193+
`pytest.skip`s the module. A variable that is set but points at an unreachable node `pytest.fail`s,
194+
on a bad response or an `IOError`. Never make a test fail because a node is missing
195+
- Other optional CI keys: `ETHEREUM_4337_BUNDLER_URL`, `ETHERSCAN_API_KEY`, `ENS_CLIENT_API_KEY`,
196+
`SAFE_TRANSACTION_SERVICE_API_KEY`
197+
- Recorded API responses go in the `mocks/` package of the module being tested
198+
- CI reruns failures 3 times (`--reruns 3 --reruns-delay 10`); network flakiness is expected
199+
- All imports at the top of the file, never inside test functions
200+
201+
## Common Development Tasks
202+
203+
### GitHub Flow (Branching and PRs)
204+
205+
- Branch from `main` for every change, in a dedicated worktree
206+
- Keep commits focused and atomic; commit subjects use `feat:`, `fix:`, `chore:` prefixes
207+
- Open PRs against `main`; label with `breaking_change` or `dependencies` when relevant, since
208+
`.github/release.yml` builds the changelog from labels
209+
- Link the PR to the Linear issue (Platform / `eth-py`)
210+
211+
### Adding a Contract ABI
212+
213+
1. Drop the compiled JSON (with `abi`, and `bytecode` if it will be deployed) in
214+
`safe_eth/eth/contracts/abis/`
215+
2. Add the entry to the `contracts` dict in `safe_eth/eth/contracts/__init__.py`
216+
3. Add the typed stub declaration for the generated `get_<name>_contract` so mypy sees it
217+
218+
### Supporting a New Safe Version
219+
220+
1. Add the ABI as above
221+
2. Add the `SafeV<version>` subclass in `safe_eth/safe/safe.py` with its `get_contract_fn()`, and
222+
register it in `_version_class_map()`; update `_DEFAULT_VERSION` if it becomes the default
223+
3. Do the same for `ProxyFactory` and the fallback handler when the version changes them
224+
4. Deploy the new singleton in `SafeTestCaseMixin` and extend the version-specific test modules
225+
226+
### Adding Chain Addresses
227+
228+
Do not edit `safe_eth/safe/addresses.py` by hand. Users open an issue from the
229+
`add_safe_address_new_chain.yml` template;
230+
`.github/workflows/validate_new_address_issue_input_data.yml` validates the input and
231+
`create_pr_with_new_address.yml` opens the PR, which the team reviews and merges.
232+
233+
### Updating Safe Deployments
234+
235+
Run `python scripts/generators/generate_safe_deployments.py` (clones `safe-deployments`, rewrites
236+
`safe_eth/safe/safe_deployments.py`) and commit the regenerated file.
237+
238+
### Releasing
239+
240+
Bump `VERSION` in `safe_eth/__init__.py` in its own PR (hatch reads it as the package version), then
241+
publish a GitHub release. The `publish` job in `.github/workflows/python.yml` runs `uv build` and
242+
`uv publish` on the `released` event.
243+
244+
## Python Version
245+
246+
Supported: **3.10 to 3.13** (CI matrix). mypy targets 3.13. Do not use syntax or stdlib APIs newer
247+
than 3.10.
248+
249+
## Code Quality Standards
250+
251+
- **Type hints required**: all functions must have complete annotations. The package ships `py.typed`
252+
and mypy runs over `safe_eth` in pre-commit with `check_untyped_defs`, `warn_unused_ignores`,
253+
`warn_redundant_casts`
254+
- **reST style docstrings**: `:param x:` / `:return:`, matching the surrounding code and Sphinx
255+
- **Formatting**: black and isort (profile `black`, with the custom section order
256+
`FUTURE, STDLIB, DJANGO, THIRDPARTY, SAFE_FOUNDATION, FIRSTPARTY, LOCALFOLDER`), flake8 with
257+
line length 88 (`E501` ignored, black decides)
258+
- **Web3 types**: use `ChecksumAddress`, `HexBytes`, `HexStr` and the `web3.types` aliases instead of
259+
raw `str`/`bytes` for blockchain data
260+
- **Exceptions**: raise the library's own exceptions from `safe_eth/eth/exceptions.py` and
261+
`safe_eth/safe/exceptions.py`; do not leak `web3`/`requests` exceptions to callers
262+
- **Descriptive variable names in loops and comprehensions**: `for address in addresses`, not
263+
`for addr in addresses` or `for a in addrs`
264+
- Update `README.rst` when adding a public feature or an environment variable
265+
266+
## Architectural Decisions and Design Rationale
267+
268+
### Version Dispatch in `Safe.__new__` Instead of `if version ==` Branches
269+
270+
**Decision**: `Safe(address, ethereum_client)` returns a version-specific subclass, chosen by a
271+
factory in `__new__`.
272+
273+
**Rationale**:
274+
- Safe contracts changed signatures across versions (1.0.0 to 1.5.0); branching inside each method
275+
would spread the version checks over the whole class
276+
- Callers get one entry point and do not need to know the deployed version
277+
- Passing `version=` skips the RPC lookup, which matters for the indexers calling this in a loop
278+
279+
**Implementation**: `_version_class_map()` maps the semantic version to the class,
280+
`_default_version_class()` handles unknown versions.
281+
282+
### Sync and Async Clients Are Separate Implementations
283+
284+
**Decision**: `AsyncEthereumClient` mirrors `EthereumClient` rather than sharing a base with
285+
sync/async variants of each method.
286+
287+
**Trade-off**: features must be added twice, but neither client pays for the other's abstraction, and
288+
consumers that are fully sync (Django services) never import async machinery.
289+
290+
### Chain Data Is Generated, Not Fetched at Runtime
291+
292+
**Decision**: chain ids, multicall addresses and Safe deployment addresses are committed as Python
293+
files generated from upstream sources, instead of being read from an API or JSON at runtime.
294+
295+
**Rationale**:
296+
- The library must work offline and with no extra network call at import time
297+
- The data is reviewable in the diff, so a wrong upstream address is caught in review
298+
- Chain additions are frequent but mechanical, which is why they go through the issue-driven workflow
299+
instead of manual edits
300+
301+
### Fast Keccak and Checksum Helpers
302+
303+
**Decision**: `safe_eth/eth/utils.py` provides pysha3-backed, `lru_cache`d `fast_keccak`,
304+
`fast_to_checksum_address` and `fast_is_checksum_address`, used everywhere instead of the `Web3`
305+
equivalents.
306+
307+
**Rationale**: the indexers checksum millions of addresses; the cached implementation is
308+
significantly faster, and the cache sizes are tunable with `CACHE_KECCAK` / `CACHE_CHECKSUM_ADDRESS`.
309+
`safe_eth/eth/tests/test_keccak_performance.py` benchmarks it against `eth_utils` and `Web3` with
310+
pytest-benchmark.
311+
312+
### Django Is an Optional Extra
313+
314+
**Decision**: Django code lives in dedicated modules behind the `django` extra, and the core never
315+
imports Django at module level.
316+
317+
**Rationale**: `safe-cli` and library users install `safe-eth-py` without Django. Where both sources
318+
exist, Django settings take precedence over environment variables, so a Django service configures
319+
everything in one place.

‎CLAUDE.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
AGENTS.md

0 commit comments

Comments
 (0)