This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
safe-eth-py is a Python library (published to PyPI as safe-eth-py, previously gnosis-py), not a
service. It provides EthereumClient, a wrapper over web3.py with ERC20/ERC721/tracing/batching
helpers, the Safe contract classes and Safe transaction/signature handling, price oracles, clients
for external services (Etherscan, Blockscout, Sourcify, ENS, CowSwap, Safe Transaction Service) and
an optional Django layer.
It is a shared dependency of the Safe Python backends, so any public API change ripples into
safe-transaction-service, safe-queue-service, safe-auth-service, safe-decoder-service and
safe-cli. Check the consumers before renaming or changing the signature of anything exported.
- Team: Platform
- Repository:
safe-global/safe-eth-py
- Create issues under team Platform with the
eth-pyandBackendlabels - Use
add-new-addressfor new chain address issues (that flow is automated, see below) - Add PR links as issue attachments/links
- Branches follow the Linear name:
uxio/pla-<number>-<slug>. Plainfeat/<scope>,fix/<scope>,chore/<scope>branches are also used for work without an issue
uv sync --group dev --all-extras --frozen
source .venv/bin/activate
pre-commit install -f--all-extras pulls the django extra, which the test suite needs. uv.lock is the source of
truth: always sync --frozen, run uv lock after editing pyproject.toml and commit both.
[tool.uv] exclude-newer = "7 days" rejects packages published in the last 7 days, so a brand new
release cannot be locked yet.
Tests need Postgres (Django test database) and a ganache node on localhost:8545 started with the
fixed mnemonic (-d), because the test mixins deploy Safe and Multicall contracts on it:
docker compose up -d db ganache
# Run all tests
pytest
# Run a single test file
pytest safe_eth/safe/tests/test_safe.py
# Run a specific test
pytest safe_eth/safe/tests/test_safe.py::TestSafe::test_estimate_tx_gas
# Run with coverage
coverage run --source=safe_eth -m pytest -rxXs
coverage report
# compose up + pytest + compose down
./run_tests.shDJANGO_SETTINGS_MODULE=config.settings.test is set automatically by pytest-env
([tool.pytest_env] in pyproject.toml), so plain pytest works. The config/ package exists only
to run the Django part of the suite and is not shipped in the wheel.
pre-commit run --all-files # isort, black, flake8, mypy — this is what CI runs
mypy safe_eth./build_docs.sh # sphinx-apidoc + make html, output in docs/buildEthereumClient wraps web3.py and composes domain managers, all built in its __init__:
.erc20(Erc20Manager),.erc721(Erc721Manager),.tracing(TracingManager),.batch_call_manager(BatchCallManager) — all subclasses ofEthereumClientManager.multicall(Multicall), deployed per chain or fromsafe_eth/eth/multicall.pyaddresses
New node-level features belong in a manager, not on the client itself. async_ethereum_client.py
mirrors the sync client for async callers; a feature added to one usually has to be added to both.
Performance rules that hold across the codebase:
- Prefer
batch_call/ multicall over N sequential RPC calls - Prefer
fast_to_checksum_address/fast_is_checksum_address/fast_keccak(safe_eth/eth/utils.py, pysha3-backed andlru_cached) over theWeb3equivalents
safe_eth/eth/contracts/__init__.py holds a contracts dict mapping a name to a JSON file under
abis/, and generates the get_<name>_contract(w3, address) functions dynamically with setattr at
import time. The module also declares typed stubs for those generated functions so mypy sees them.
Deployed-bytecode getters (get_proxy_1_3_0_deployed_bytecode, …) are @cached and written by hand.
Safe.__new__ is a factory: Safe(address, ethereum_client) detects the deployed version over RPC
(or takes version= to skip the lookup) and returns the matching subclass from _version_class_map()
— SafeV001, SafeV100, SafeV111, SafeV120, SafeV130, SafeV141, SafeV150. Version specific
behaviour goes in the subclass, shared behaviour in Safe, and SafeCompatibilityAdapter holds what
1.4.1 and 1.5.0 share. proxy_factory.py and compatibility_fallback_handler.py use the same
version-subclass pattern.
Every contract wrapper extends ContractBase (safe_eth/eth/contracts/contract_base.py), which
requires a get_contract_fn() returning one of the generated contract getters and exposes a
cached_property contract.
Other core modules:
safe_tx.py: builds, hashes (EIP-712) and signs Safe transactionssafe_signature.py: parses the packed signature blob intoSafeSignature/SafeSignatureAsyncobjects bySafeSignatureType(EOA, ETH_SIGN, APPROVED_HASH, CONTRACT_SIGNATURE/EIP-1271, SECP256R1)multi_send.py,safe_create2_tx.py,safe_creator.py,p256.pyaccount_abstraction/safe_operation.pyon top ofsafe_eth/eth/account_abstraction/(ERC-4337 user operations and bundler client)
safe_eth/eth/clients/ (Etherscan v2, Blockscout, Sourcify, ENS, CowSwap) and safe_eth/safe/api/
(Transaction Service API on base_api.py). Several have both sync and async variants. oracles/
holds the price oracles (Uniswap v2/v3, Kyber, SushiSwap, Curve, Superfluid…).
safe_eth/eth/django/ (model fields, serializers, filters, validators, forms) and
safe_eth/safe/serializers.py are only importable with the django extra. Keep Django imports out
of the core modules. Where a value can come from Django settings or the environment, Django settings
win when Django is installed — see get_auto_ethereum_client and
EthereumTestCaseMixin.get_ethereum_test_account.
safe_eth/safe/safe_deployments.py: generated from thesafe-deploymentsrepo byscripts/generators/generate_safe_deployments.pysafe_eth/eth/ethereum_network.py(theEthereumNetworkenum, ~2000 chains) and the multicall addresses:scripts/generators/generate_chains_list.pyandgenerate_chains_info_from_viem.pysafe_eth/safe/addresses.py: per chain,MASTER_COPIESas(address, deployment block, version)andPROXY_FACTORIESas(address, deployment block). Updated by the add-new-address workflow
The library reads everything from environment variables, all optional. README.rst has the canonical
list with defaults; the groups are:
- RPC client:
ETHEREUM_NODE_URL(ignored under Django, which usessettings.ETHEREUM_NODE_URL),ETHEREUM_RPC_TIMEOUT,ETHEREUM_RPC_SLOW_TIMEOUT,ETHEREUM_RPC_RETRY_COUNT,ETHEREUM_RPC_BATCH_REQUEST_MAX_SIZE - Caching:
CACHE_KECCAK,CACHE_CHECKSUM_ADDRESS(lru_cachesizes for the fast helpers) - Contract addresses:
SAFE_SINGLETON_FACTORY_ADDRESS,SAFE_SIMULATE_TX_ACCESSOR_ADDRESS, for chains where the deterministic address differs - Transaction Service:
SAFE_TRANSACTION_SERVICE_API_KEY(JWT from developer.safe.global),SAFE_TRANSACTION_SERVICE_REQUEST_TIMEOUT - Block explorer / source clients:
ETHERSCAN_CLIENT_*,BLOCKSCOUT_CLIENT_*,SOURCIFY_*,ENS_CLIENT_REQUEST_TIMEOUT.ETHERSCAN_CLIENT_MAX_REQUESTSandBLOCKSCOUT_CLIENT_MAX_REQUESTSonly tune the async clients' pools.SOURCIFY_CLIENT_MAX_REQUESTSapplies to both: the syncSourcifyClientpasses it toprepare_http_sessionaspool_maxsize
Anything new added here must be documented in README.rst.
Tests live next to the code they cover, in tests/ packages inside each module
(safe_eth/eth/tests/, safe_eth/safe/tests/, safe_eth/util/tests/).
Base classes:
EthereumTestCaseMixin(safe_eth/eth/tests/ethereum_test_case.py): sets upethereum_client,w3, a fundedethereum_test_accountand deploys Multicall. The client is cached across test classes, so do not mutate itSafeTestCaseMixin(safe_eth/safe/tests/safe_test_case.py): adds deployed singletons for Safe 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 fallback handlers, plusdeploy_test_safe*helpers.SafeV120exists in_version_class_map()but has no fixture: 1.2.0 shipped with a bug, was replaced by 1.3.0 and never used in production
Conventions:
- Tests are
unittest-styleTestCaseclasses run under pytest, not bare pytest functions - The async client tests (
test_async_ethereum_client.py) subclass the sync test classes and swap in a proxy that drives the coroutines, so a sync test added there is covered on both clients - Tests hitting a real network call
just_test_if_mainnet_node()/just_test_if_polygon_node()(safe_eth/eth/tests/utils.py). An unsetETHEREUM_MAINNET_NODE/ETHEREUM_POLYGON_NODEpytest.skips the module. A variable that is set but points at an unreachable nodepytest.fails, on a bad response or anIOError. Never make a test fail because a node is missing - Other optional CI keys:
ETHEREUM_4337_BUNDLER_URL,ETHERSCAN_API_KEY,ENS_CLIENT_API_KEY,SAFE_TRANSACTION_SERVICE_API_KEY - Recorded API responses go in the
mocks/package of the module being tested - CI reruns failures 3 times (
--reruns 3 --reruns-delay 10); network flakiness is expected - All imports at the top of the file, never inside test functions
- Branch from
mainfor every change, in a dedicated worktree - Keep commits focused and atomic; commit subjects use
feat:,fix:,chore:prefixes - Open PRs against
main; label withbreaking_changeordependencieswhen relevant, since.github/release.ymlbuilds the changelog from labels - Link the PR to the Linear issue (Platform /
eth-py)
- Drop the compiled JSON (with
abi, andbytecodeif it will be deployed) insafe_eth/eth/contracts/abis/ - Add the entry to the
contractsdict insafe_eth/eth/contracts/__init__.py - Add the typed stub declaration for the generated
get_<name>_contractso mypy sees it
- Add the ABI as above
- Add the
SafeV<version>subclass insafe_eth/safe/safe.pywith itsget_contract_fn(), and register it in_version_class_map(); update_DEFAULT_VERSIONif it becomes the default - Do the same for
ProxyFactoryand the fallback handler when the version changes them - Deploy the new singleton in
SafeTestCaseMixinand extend the version-specific test modules
Do not edit safe_eth/safe/addresses.py by hand. Users open an issue from the
add_safe_address_new_chain.yml template;
.github/workflows/validate_new_address_issue_input_data.yml validates the input and
create_pr_with_new_address.yml opens the PR, which the team reviews and merges.
Run python scripts/generators/generate_safe_deployments.py (clones safe-deployments, rewrites
safe_eth/safe/safe_deployments.py) and commit the regenerated file.
Bump VERSION in safe_eth/__init__.py in its own PR (hatch reads it as the package version), then
publish a GitHub release. The publish job in .github/workflows/python.yml runs uv build and
uv publish on the released event.
Supported: 3.10 to 3.13 (CI matrix). mypy targets 3.13. Do not use syntax or stdlib APIs newer than 3.10.
- Type hints required: all functions must have complete annotations. The package ships
py.typedand mypy runs oversafe_ethin pre-commit withcheck_untyped_defs,warn_unused_ignores,warn_redundant_casts - reST style docstrings:
:param x:/:return:, matching the surrounding code and Sphinx - Formatting: black and isort (profile
black, with the custom section orderFUTURE, STDLIB, DJANGO, THIRDPARTY, SAFE_FOUNDATION, FIRSTPARTY, LOCALFOLDER), flake8 with line length 88 (E501ignored, black decides) - Web3 types: use
ChecksumAddress,HexBytes,HexStrand theweb3.typesaliases instead of rawstr/bytesfor blockchain data - Exceptions: raise the library's own exceptions from
safe_eth/eth/exceptions.pyandsafe_eth/safe/exceptions.py; do not leakweb3/requestsexceptions to callers - Descriptive variable names in loops and comprehensions:
for address in addresses, notfor addr in addressesorfor a in addrs - Update
README.rstwhen adding a public feature or an environment variable
Decision: Safe(address, ethereum_client) returns a version-specific subclass, chosen by a
factory in __new__.
Rationale:
- Safe contracts changed signatures across versions (1.0.0 to 1.5.0); branching inside each method would spread the version checks over the whole class
- Callers get one entry point and do not need to know the deployed version
- Passing
version=skips the RPC lookup, which matters for the indexers calling this in a loop
Implementation: _version_class_map() maps the semantic version to the class,
_default_version_class() handles unknown versions.
Decision: AsyncEthereumClient mirrors EthereumClient rather than sharing a base with
sync/async variants of each method.
Trade-off: features must be added twice, but neither client pays for the other's abstraction, and consumers that are fully sync (Django services) never import async machinery.
Decision: chain ids, multicall addresses and Safe deployment addresses are committed as Python files generated from upstream sources, instead of being read from an API or JSON at runtime.
Rationale:
- The library must work offline and with no extra network call at import time
- The data is reviewable in the diff, so a wrong upstream address is caught in review
- Chain additions are frequent but mechanical, which is why they go through the issue-driven workflow instead of manual edits
Decision: safe_eth/eth/utils.py provides pysha3-backed, lru_cached fast_keccak,
fast_to_checksum_address and fast_is_checksum_address, used everywhere instead of the Web3
equivalents.
Rationale: the indexers checksum millions of addresses; the cached implementation is
significantly faster, and the cache sizes are tunable with CACHE_KECCAK / CACHE_CHECKSUM_ADDRESS.
safe_eth/eth/tests/test_keccak_performance.py benchmarks it against eth_utils and Web3 with
pytest-benchmark.
Decision: Django code lives in dedicated modules behind the django extra, and the core never
imports Django at module level.
Rationale: safe-cli and library users install safe-eth-py without Django. Where both sources
exist, Django settings take precedence over environment variables, so a Django service configures
everything in one place.