|
| 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. |
0 commit comments