• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

safe-global / safe-eth-py / 10793540350

10 Sep 2024 01:31PM UTC coverage: 93.551% (-0.3%) from 93.892%
10793540350

push

github

falvaradorodriguez
Fix cowswap test

8777 of 9382 relevant lines covered (93.55%)

3.74 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

90.88
/safe_eth/safe/safe.py
1
import dataclasses
4✔
2
import math
4✔
3
import os
4✔
4
from abc import ABCMeta, abstractmethod
4✔
5
from functools import cached_property
4✔
6
from logging import getLogger
4✔
7
from typing import Any, Callable, Dict, List, Optional, Type, Union
4✔
8

9
import eth_abi
4✔
10
from eth_abi.exceptions import DecodingError
4✔
11
from eth_abi.packed import encode_packed
4✔
12
from eth_account import Account
4✔
13
from eth_account.signers.local import LocalAccount
4✔
14
from eth_typing import ChecksumAddress, Hash32, HexAddress, HexStr
4✔
15
from hexbytes import HexBytes
4✔
16
from web3 import Web3
4✔
17
from web3.contract import Contract
4✔
18
from web3.exceptions import Web3Exception
4✔
19
from web3.types import BlockIdentifier, TxParams, Wei
4✔
20

21
from safe_eth.eth import EthereumClient, EthereumTxSent
4✔
22
from safe_eth.eth.constants import GAS_CALL_DATA_BYTE, NULL_ADDRESS, SENTINEL_ADDRESS
4✔
23
from safe_eth.eth.contracts import (
4✔
24
    ContractBase,
25
    get_compatibility_fallback_handler_contract,
26
    get_safe_contract,
27
    get_safe_V0_0_1_contract,
28
    get_safe_V1_0_0_contract,
29
    get_safe_V1_1_1_contract,
30
    get_safe_V1_3_0_contract,
31
    get_safe_V1_4_1_contract,
32
    get_simulate_tx_accessor_V1_4_1_contract,
33
)
34
from safe_eth.eth.utils import (
4✔
35
    fast_bytes_to_checksum_address,
36
    fast_is_checksum_address,
37
    fast_keccak,
38
    get_empty_tx_params,
39
)
40

41
from ..eth.typing import EthereumData
4✔
42
from .addresses import SAFE_SIMULATE_TX_ACCESSOR_ADDRESS
4✔
43
from .enums import SafeOperationEnum
4✔
44
from .exceptions import CannotEstimateGas, CannotRetrieveSafeInfoException
4✔
45
from .safe_creator import SafeCreator
4✔
46
from .safe_tx import SafeTx
4✔
47

48
logger = getLogger(__name__)
4✔
49

50

51
@dataclasses.dataclass
4✔
52
class SafeInfo:
4✔
53
    address: ChecksumAddress
4✔
54
    fallback_handler: ChecksumAddress
4✔
55
    guard: ChecksumAddress
4✔
56
    master_copy: ChecksumAddress
4✔
57
    modules: List[ChecksumAddress]
4✔
58
    nonce: int
4✔
59
    owners: List[ChecksumAddress]
4✔
60
    threshold: int
4✔
61
    version: str
4✔
62

63

64
class Safe(SafeCreator, ContractBase, metaclass=ABCMeta):
4✔
65
    """
66
    Collection of methods and utilies to handle a Safe
67
    """
68

69
    # keccak256("fallback_manager.handler.address")
70
    FALLBACK_HANDLER_STORAGE_SLOT = (
4✔
71
        0x6C9A6C4A39284E37ED1CF53D337577D14212A4870FB976A4366C693B939918D5
72
    )
73
    # keccak256("guard_manager.guard.address")
74
    GUARD_STORAGE_SLOT = (
4✔
75
        0x4A204F620C8C5CCDCA3FD54D003BADD85BA500436A431F0CBDA4F558C93C34C8
76
    )
77

78
    # keccak256("SafeMessage(bytes message)");
79
    SAFE_MESSAGE_TYPEHASH = bytes.fromhex(
4✔
80
        "60b3cbf8b4a223d68d641b3b6ddf9a298e7f33710cf3d3a9d1146b5a6150fbca"
81
    )
82

83
    def __new__(
4✔
84
        cls, address: ChecksumAddress, ethereum_client: EthereumClient, *args, **kwargs
85
    ) -> "Safe":
86
        """
87
        Hacky factory for Safe
88

89
        :param address:
90
        :param ethereum_client:
91
        :param kwargs:
92
        """
93
        assert fast_is_checksum_address(address), "%s is not a valid address" % address
4✔
94
        if cls is not Safe:
4✔
95
            return super().__new__(cls, *args, **kwargs)
4✔
96

97
        versions: Dict[str, Type[Safe]] = {
4✔
98
            "0.0.1": SafeV001,
99
            "1.0.0": SafeV100,
100
            "1.1.1": SafeV111,
101
            "1.2.0": SafeV120,
102
            "1.3.0": SafeV130,
103
            "1.4.1": SafeV141,
104
        }
105
        default_version = SafeV141
4✔
106

107
        version: Optional[str]
108
        try:
4✔
109
            contract = get_safe_contract(ethereum_client.w3, address=address)
4✔
110
            version = contract.functions.VERSION().call(block_identifier="latest")
4✔
111
        except (Web3Exception, ValueError):
4✔
112
            version = None  # Cannot detect the version
4✔
113

114
        instance_class = versions.get(version or "", default_version)
4✔
115
        instance = super().__new__(instance_class)
4✔
116
        return instance
4✔
117

118
    def __init__(
4✔
119
        self,
120
        address: ChecksumAddress,
121
        ethereum_client: EthereumClient,
122
        simulate_tx_accessor_address: Optional[ChecksumAddress] = None,
123
    ):
124
        self._simulate_tx_accessor_address = simulate_tx_accessor_address
4✔
125
        super().__init__(address, ethereum_client)
4✔
126

127
    def __str__(self):
4✔
128
        return f"Safe={self.address}"
×
129

130
    @abstractmethod
4✔
131
    def get_version(self) -> str:
4✔
132
        """
133
        :return: String with Safe Master Copy semantic version, must match `retrieve_version()`
134
        """
135
        raise NotImplementedError
×
136

137
    @cached_property
4✔
138
    def chain_id(self) -> int:
4✔
139
        return self.ethereum_client.get_chain_id()
4✔
140

141
    @property
4✔
142
    def simulate_tx_accessor_address(self) -> ChecksumAddress:
4✔
143
        if self._simulate_tx_accessor_address:
4✔
144
            return self._simulate_tx_accessor_address
4✔
145
        return ChecksumAddress(
×
146
            HexAddress(
147
                HexStr(
148
                    os.environ.get(
149
                        "SAFE_SIMULATE_TX_ACCESSOR_ADDRESS",
150
                        SAFE_SIMULATE_TX_ACCESSOR_ADDRESS,
151
                    )
152
                )
153
            )
154
        )
155

156
    @simulate_tx_accessor_address.setter
4✔
157
    def simulate_tx_accessor_address(self, value: ChecksumAddress):
4✔
158
        self._simulate_tx_accessor_address = value
×
159

160
    def retrieve_version(
4✔
161
        self, block_identifier: Optional[BlockIdentifier] = "latest"
162
    ) -> str:
163
        return self.contract.functions.VERSION().call(
4✔
164
            block_identifier=block_identifier or "latest"
165
        )
166

167
    @cached_property
4✔
168
    def domain_separator(self) -> Optional[bytes]:
4✔
169
        """
170
        :return: EIP721 DomainSeparator for the Safe. Returns `None` if not supported (for Safes < 1.0.0)
171
        """
172
        try:
4✔
173
            return self.retrieve_domain_separator()
4✔
174
        except (Web3Exception, DecodingError, ValueError):
4✔
175
            logger.warning("Safe %s does not support domainSeparator", self.address)
4✔
176
            return None
4✔
177

178
    @classmethod
4✔
179
    def deploy_contract(
4✔
180
        cls,
181
        ethereum_client: EthereumClient,
182
        deployer_account: LocalAccount,
183
    ) -> EthereumTxSent:
184
        """
185
        Deploy master contract. Takes deployer_account (if unlocked in the node) or the deployer private key
186
        Safe with version > v1.1.1 doesn't need to be initialized as it already has a constructor
187

188
        :param ethereum_client:
189
        :param deployer_account: Ethereum account
190
        :return: ``EthereumTxSent`` with the deployed contract address
191
        """
192
        contract_fn = cls.get_contract_fn(cls)  # type: ignore[arg-type]
4✔
193
        safe_contract = contract_fn(ethereum_client.w3, None)
4✔
194
        constructor_data = safe_contract.constructor().build_transaction(
4✔
195
            get_empty_tx_params()
196
        )["data"]
197
        ethereum_tx_sent = ethereum_client.deploy_and_initialize_contract(
4✔
198
            deployer_account, constructor_data
199
        )
200
        deployed_version = (
4✔
201
            contract_fn(ethereum_client.w3, ethereum_tx_sent.contract_address)
202
            .functions.VERSION()
203
            .call()
204
        )
205
        safe_version = cls.get_version(cls)  # type: ignore[arg-type]
4✔
206
        assert (
4✔
207
            deployed_version == safe_version
208
        ), f"Deployed version {deployed_version} is not matching expected {safe_version} version"
209

210
        logger.info(
4✔
211
            "Deployed and initialized Safe Master Contract version=%s on address %s by %s",
212
            deployed_version,
213
            ethereum_tx_sent.contract_address,
214
            deployer_account.address,
215
        )
216
        return ethereum_tx_sent
4✔
217

218
    def check_funds_for_tx_gas(
4✔
219
        self, safe_tx_gas: int, base_gas: int, gas_price: int, gas_token: str
220
    ) -> bool:
221
        """
222
        Check safe has enough funds to pay for a tx
223

224
        :param safe_tx_gas: Safe tx gas
225
        :param base_gas: Data gas
226
        :param gas_price: Gas Price
227
        :param gas_token: Gas Token, to use token instead of ether for the gas
228
        :return: `True` if enough funds, `False` otherwise
229
        """
230
        if gas_token == NULL_ADDRESS:
4✔
231
            balance = self.ethereum_client.get_balance(self.address)
4✔
232
        else:
233
            balance = self.ethereum_client.erc20.get_balance(self.address, gas_token)
×
234
        return balance >= (safe_tx_gas + base_gas) * gas_price
4✔
235

236
    def estimate_tx_base_gas(
4✔
237
        self,
238
        to: ChecksumAddress,
239
        value: int,
240
        data: bytes,
241
        operation: int,
242
        gas_token: ChecksumAddress,
243
        estimated_tx_gas: int,
244
    ) -> int:
245
        """
246
        Calculate gas costs that are independent of the transaction execution(e.g. base transaction fee,
247
        signature check, payment of the refund...)
248

249
        :param to:
250
        :param value:
251
        :param data:
252
        :param operation:
253
        :param gas_token:
254
        :param estimated_tx_gas: gas calculated with `estimate_tx_gas`
255
        :return:
256
        """
257
        data = data or b""
4✔
258
        safe_contract = self.contract
4✔
259
        threshold = self.retrieve_threshold()
4✔
260
        nonce = self.retrieve_nonce()
4✔
261

262
        # Every byte == 0 -> 4  Gas
263
        # Every byte != 0 -> 16 Gas (68 before Istanbul)
264
        # numbers < 256 (0x00(31*2)..ff) are 192 -> 31 * 4 + 1 * GAS_CALL_DATA_BYTE
265
        # numbers < 65535 (0x(30*2)..ffff) are 256 -> 30 * 4 + 2 * GAS_CALL_DATA_BYTE
266

267
        # Calculate gas for signatures
268
        # (array count (3 -> r, s, v) + ecrecover costs) * signature count
269
        # ecrecover for ecdsa ~= 4K gas, we use 6K
270
        ecrecover_gas = 6000
4✔
271
        signature_gas = threshold * (
4✔
272
            1 * GAS_CALL_DATA_BYTE + 2 * 32 * GAS_CALL_DATA_BYTE + ecrecover_gas
273
        )
274

275
        safe_tx_gas = estimated_tx_gas
4✔
276
        base_gas = 0
4✔
277
        gas_price = 1
4✔
278
        gas_token = gas_token or NULL_ADDRESS
4✔
279
        signatures = b""
4✔
280
        refund_receiver = NULL_ADDRESS
4✔
281
        data = HexBytes(
4✔
282
            safe_contract.functions.execTransaction(
283
                to,
284
                value,
285
                data,
286
                operation,
287
                safe_tx_gas,
288
                base_gas,
289
                gas_price,
290
                gas_token,
291
                refund_receiver,
292
                signatures,
293
            ).build_transaction(get_empty_tx_params())["data"]
294
        )
295

296
        # If nonce == 0, nonce storage has to be initialized
297
        if nonce == 0:
4✔
298
            nonce_gas = 20000
4✔
299
        else:
300
            nonce_gas = 5000
×
301

302
        # Keccak costs for the hash of the safe tx
303
        hash_generation_gas = 1500
4✔
304

305
        base_gas = (
4✔
306
            signature_gas
307
            + self.ethereum_client.estimate_data_gas(data)
308
            + nonce_gas
309
            + hash_generation_gas
310
        )
311

312
        # Add additional gas costs
313
        if base_gas > 65536:
4✔
314
            base_gas += 64
×
315
        else:
316
            base_gas += 128
4✔
317

318
        base_gas += 32000  # Base tx costs, transfer costs...
4✔
319
        return base_gas
4✔
320

321
    def estimate_tx_gas_with_safe(
4✔
322
        self,
323
        to: ChecksumAddress,
324
        value: int,
325
        data: bytes,
326
        operation: int,
327
        gas_limit: Optional[int] = None,
328
        block_identifier: Optional[BlockIdentifier] = "latest",
329
    ) -> int:
330
        """
331
        Estimate tx gas using safe `requiredTxGas` method
332

333
        :return: int: Estimated gas
334
        :raises: CannotEstimateGas: If gas cannot be estimated
335
        :raises: ValueError: Cannot decode received data
336
        """
337

338
        safe_address = self.address
4✔
339
        data = data or b""
4✔
340

341
        def parse_revert_data(result: bytes) -> int:
4✔
342
            # 4 bytes - error method id
343
            # 32 bytes - position
344
            # 32 bytes - length
345
            # Last 32 bytes - value of revert (if everything went right)
346
            gas_estimation_offset = 4 + 32 + 32
4✔
347
            gas_estimation = result[gas_estimation_offset:]
4✔
348

349
            # Estimated gas must be 32 bytes
350
            if len(gas_estimation) != 32:
4✔
351
                gas_limit_text = (
4✔
352
                    f"with gas limit={gas_limit} "
353
                    if gas_limit is not None
354
                    else "without gas limit set "
355
                )
356
                logger.warning(
4✔
357
                    "Safe=%s Problem estimating gas, returned value %sis %s for tx=%s",
358
                    safe_address,
359
                    gas_limit_text,
360
                    result.hex(),
361
                    tx,
362
                )
363
                raise CannotEstimateGas("Received %s for tx=%s" % (result.hex(), tx))
4✔
364

365
            return int(gas_estimation.hex(), 16)
4✔
366

367
        tx = self.contract.functions.requiredTxGas(
4✔
368
            to, value, data, operation
369
        ).build_transaction(
370
            {
371
                "from": safe_address,
372
                "gas": 0,  # Don't call estimate
373
                "gasPrice": Wei(0),  # Don't get gas price
374
            }
375
        )
376

377
        tx_params = {
4✔
378
            "from": safe_address,
379
            "to": safe_address,
380
            "data": tx["data"],
381
        }
382

383
        if gas_limit:
4✔
384
            tx_params["gas"] = HexStr(hex(gas_limit))
4✔
385

386
        query = {
4✔
387
            "jsonrpc": "2.0",
388
            "method": "eth_call",
389
            "params": [tx_params, block_identifier],
390
            "id": 1,
391
        }
392

393
        response = self.ethereum_client.http_session.post(
4✔
394
            self.ethereum_client.ethereum_node_url, json=query, timeout=30
395
        )
396
        if response.ok:
4✔
397
            response_data = response.json()
4✔
398
            error_data: Optional[str] = None
4✔
399
            if "error" in response_data and "data" in response_data["error"]:
4✔
400
                error_data = response_data["error"]["data"]
4✔
401
            elif "result" in response_data:  # Ganache-cli
×
402
                error_data = response_data["result"]
×
403

404
            if error_data:
4✔
405
                if "0x" in error_data:
4✔
406
                    return parse_revert_data(
4✔
407
                        HexBytes(error_data[error_data.find("0x") :])
408
                    )
409

410
        raise CannotEstimateGas(
×
411
            f"Received {response.status_code} - {response.content!r} from ethereum node"
412
        )
413

414
    def estimate_tx_gas_with_web3(
4✔
415
        self, to: ChecksumAddress, value: int, data: EthereumData
416
    ) -> int:
417
        """
418
        :param to:
419
        :param value:
420
        :param data:
421
        :return: Estimation using web3 `estimate_gas`
422
        """
423
        try:
4✔
424
            return self.ethereum_client.estimate_gas(
4✔
425
                to, from_=self.address, value=value, data=data
426
            )
427
        except (Web3Exception, ValueError) as exc:
4✔
428
            raise CannotEstimateGas(
4✔
429
                f"Cannot estimate gas with `eth_estimateGas`: {exc}"
430
            ) from exc
431

432
    def estimate_tx_gas_by_trying(
4✔
433
        self, to: ChecksumAddress, value: int, data: Union[bytes, str], operation: int
434
    ):
435
        """
436
        Try to get an estimation with Safe's `requiredTxGas`. If estimation is successful, try to set a gas limit and
437
        estimate again. If gas estimation is ok, same gas estimation should be returned, if it's less than required
438
        estimation will not be completed, so estimation was not accurate and gas limit needs to be increased.
439

440
        :param to:
441
        :param value:
442
        :param data:
443
        :param operation:
444
        :return: Estimated gas calling `requiredTxGas` setting a gas limit and checking if `eth_call` is successful
445
        :raises: CannotEstimateGas
446
        """
447
        if not data:
4✔
448
            data = b""
×
449
        elif isinstance(data, str):
4✔
450
            data = HexBytes(data)
4✔
451

452
        gas_estimated = self.estimate_tx_gas_with_safe(to, value, data, operation)
4✔
453
        block_gas_limit: Optional[int] = None
4✔
454
        base_gas: Optional[int] = self.ethereum_client.estimate_data_gas(data)
4✔
455

456
        for i in range(
4✔
457
            1, 30
458
        ):  # Make sure tx can be executed, fixing for example 63/64th problem
459
            try:
4✔
460
                self.estimate_tx_gas_with_safe(
4✔
461
                    to,
462
                    value,
463
                    data,
464
                    operation,
465
                    gas_limit=gas_estimated + (base_gas or 0) + 32000,
466
                )
467
                return gas_estimated
4✔
468
            except CannotEstimateGas:
4✔
469
                logger.warning(
4✔
470
                    "Safe=%s - Found 63/64 problem gas-estimated=%d to=%s data=%s",
471
                    self.address,
472
                    gas_estimated,
473
                    to,
474
                    data.hex(),
475
                )
476
                block_gas_limit = (
4✔
477
                    block_gas_limit
478
                    or self.w3.eth.get_block("latest", full_transactions=False)[
479
                        "gasLimit"
480
                    ]
481
                )
482
                gas_estimated = math.floor((1 + i * 0.03) * gas_estimated)
4✔
483
                if gas_estimated >= block_gas_limit:
4✔
484
                    return block_gas_limit
×
485
        return gas_estimated
×
486

487
    def estimate_tx_gas(
4✔
488
        self, to: ChecksumAddress, value: int, data: bytes, operation: int
489
    ) -> int:
490
        """
491
        Estimate tx gas. Use `requiredTxGas` on the Safe contract and fallbacks to `eth_estimateGas` if that method
492
        fails. Note: `eth_estimateGas` cannot estimate delegate calls
493

494
        :param to:
495
        :param value:
496
        :param data:
497
        :param operation:
498
        :return: Estimated gas for Safe inner tx
499
        :raises: CannotEstimateGas
500
        """
501
        # Costs to route through the proxy and nested calls
502
        PROXY_GAS = 1000
4✔
503
        # https://github.com/ethereum/solidity/blob/dfe3193c7382c80f1814247a162663a97c3f5e67/libsolidity/codegen/ExpressionCompiler.cpp#L1764
504
        # This was `false` before solc 0.4.21 -> `m_context.evmVersion().canOverchargeGasForCall()`
505
        # So gas needed by caller will be around 35k
506
        OLD_CALL_GAS = 35000
4✔
507
        # Web3 `estimate_gas` estimates less gas
508
        WEB3_ESTIMATION_OFFSET = 23000
4✔
509
        ADDITIONAL_GAS = PROXY_GAS + OLD_CALL_GAS
4✔
510

511
        try:
4✔
512
            return (
4✔
513
                self.estimate_tx_gas_by_trying(to, value, data, operation)
514
                + ADDITIONAL_GAS
515
            )
516
        except CannotEstimateGas:
×
517
            return (
×
518
                self.estimate_tx_gas_with_web3(to, value, data)
519
                + ADDITIONAL_GAS
520
                + WEB3_ESTIMATION_OFFSET
521
            )
522

523
    def get_message_hash(self, message: Union[str, Hash32]) -> Hash32:
4✔
524
        """
525
        Return hash of a message that can be signed by owners.
526

527
        :param message: Message that should be hashed. A ``Hash32`` must be provided for EIP191 or EIP712 messages
528
        :return: Message hash
529
        """
530
        if isinstance(message, str):
4✔
531
            message_to_hash = message.encode()  # Convertir str a bytes
4✔
532
        else:
533
            message_to_hash = message
4✔
534

535
        message_hash = fast_keccak(message_to_hash)
4✔
536

537
        safe_message_hash = fast_keccak(
4✔
538
            eth_abi.encode(
539
                ["bytes32", "bytes32"], [self.SAFE_MESSAGE_TYPEHASH, message_hash]
540
            )
541
        )
542
        return fast_keccak(
4✔
543
            encode_packed(
544
                ["bytes1", "bytes1", "bytes32", "bytes32"],
545
                [
546
                    bytes.fromhex("19"),
547
                    bytes.fromhex("01"),
548
                    self.domain_separator,
549
                    safe_message_hash,
550
                ],
551
            )
552
        )
553

554
    def retrieve_all_info(
4✔
555
        self, block_identifier: Optional[BlockIdentifier] = "latest"
556
    ) -> SafeInfo:
557
        """
558
        Get all Safe info in the same batch call.
559

560
        :param block_identifier:
561
        :return:
562
        :raises: CannotRetrieveSafeInfoException
563
        """
564

565
        # FIXME for not initialized Safes `getModules` get into an infinite loop on the RPC
566
        try:
4✔
567
            contract = self.contract
4✔
568
            master_copy = self.retrieve_master_copy_address()
4✔
569
            if master_copy == NULL_ADDRESS:
4✔
570
                raise CannotRetrieveSafeInfoException(self.address)
4✔
571

572
            fallback_handler = self.retrieve_fallback_handler()
4✔
573
            guard = self.retrieve_guard()  # Guard was implemented in v1.1.1
4✔
574

575
            # From v1.1.1:
576
            # - `getModulesPaginated` is available
577
            # - `getModules` returns only 10 modules
578
            modules_fn = (
4✔
579
                contract.functions.getModulesPaginated(SENTINEL_ADDRESS, 20)
580
                if hasattr(contract.functions, "getModulesPaginated")
581
                else contract.functions.getModules()
582
            )
583

584
            results = self.ethereum_client.batch_call(
4✔
585
                [
586
                    modules_fn,
587
                    contract.functions.nonce(),
588
                    contract.functions.getOwners(),
589
                    contract.functions.getThreshold(),
590
                    contract.functions.VERSION(),
591
                ],
592
                from_address=self.address,
593
                block_identifier=block_identifier,
594
                raise_exception=False,
595
            )
596
            modules_response, nonce, owners, threshold, version = results
4✔
597
            if (
4✔
598
                modules_response
599
                and len(modules_response) == 2
600
                and isinstance(modules_response[0], (tuple, list))
601
            ):
602
                # Must be a Tuple[List[ChecksumAddress], ChecksumAddress]
603
                # >= v1.1.1
604
                modules, next_module = modules_response
4✔
605
                if modules and next_module != SENTINEL_ADDRESS:
4✔
606
                    # Still more elements in the list
607
                    modules = self.retrieve_modules()
×
608
            else:
609
                # < v1.1.1
610
                modules = modules_response
4✔
611

612
            return SafeInfo(
4✔
613
                self.address,
614
                fallback_handler,
615
                guard,
616
                master_copy,
617
                modules if modules else [],
618
                nonce,
619
                owners,
620
                threshold,
621
                version,
622
            )
623
        except (Web3Exception, ValueError) as e:
4✔
624
            raise CannotRetrieveSafeInfoException(self.address) from e
×
625

626
    def retrieve_domain_separator(
4✔
627
        self, block_identifier: Optional[BlockIdentifier] = "latest"
628
    ) -> Optional[bytes]:
629
        return self.contract.functions.domainSeparator().call(
4✔
630
            block_identifier=block_identifier or "latest"
631
        )
632

633
    def retrieve_code(self) -> HexBytes:
4✔
634
        return self.w3.eth.get_code(self.address)
4✔
635

636
    def retrieve_fallback_handler(
4✔
637
        self, block_identifier: Optional[BlockIdentifier] = "latest"
638
    ) -> ChecksumAddress:
639
        address = self.ethereum_client.w3.eth.get_storage_at(
4✔
640
            self.address,
641
            self.FALLBACK_HANDLER_STORAGE_SLOT,
642
            block_identifier=block_identifier,
643
        )[-20:].rjust(20, b"\0")
644
        if len(address) == 20:
4✔
645
            return fast_bytes_to_checksum_address(address)
4✔
646
        else:
647
            return NULL_ADDRESS
×
648

649
    def retrieve_guard(
4✔
650
        self, block_identifier: Optional[BlockIdentifier] = "latest"
651
    ) -> ChecksumAddress:
652
        address = self.ethereum_client.w3.eth.get_storage_at(
4✔
653
            self.address, self.GUARD_STORAGE_SLOT, block_identifier=block_identifier
654
        )[-20:].rjust(20, b"\0")
655
        if len(address) == 20:
4✔
656
            return fast_bytes_to_checksum_address(address)
4✔
657
        else:
658
            return NULL_ADDRESS
×
659

660
    def retrieve_master_copy_address(
4✔
661
        self, block_identifier: Optional[BlockIdentifier] = "latest"
662
    ) -> ChecksumAddress:
663
        address = self.w3.eth.get_storage_at(
4✔
664
            self.address, "0x00", block_identifier=block_identifier
665
        )[-20:].rjust(20, b"\0")
666
        return fast_bytes_to_checksum_address(address)
4✔
667

668
    def retrieve_modules(
4✔
669
        self,
670
        pagination: Optional[int] = 50,
671
        max_modules_to_retrieve: Optional[int] = 500,
672
        block_identifier: Optional[BlockIdentifier] = "latest",
673
    ) -> List[ChecksumAddress]:
674
        """
675
        Get modules enabled on the Safe
676
        From v1.1.1:
677
          - ``getModulesPaginated`` is available
678
          - ``getModules`` returns only 10 modules
679

680
        :param pagination: Number of modules to get per request
681
        :param max_modules_to_retrieve: Maximum number of modules to retrieve
682
        :param block_identifier:
683
        :return: List of module addresses
684
        """
685
        if pagination is None:
4✔
686
            pagination = 50
×
687

688
        if max_modules_to_retrieve is None:
4✔
689
            max_modules_to_retrieve = 500
×
690

691
        if block_identifier is None:
4✔
692
            block_identifier = "latest"
×
693

694
        if not hasattr(self.contract.functions, "getModulesPaginated"):
4✔
695
            # Custom code for Safes < v1.3.0
696
            # Safe V1_0_0 can get into an infinite loop if it's not initialized
697
            if self.retrieve_threshold() == 0:
4✔
698
                return []
4✔
699
            return self.contract.functions.getModules().call(
4✔
700
                block_identifier=block_identifier
701
            )
702

703
        # We need to iterate the module paginator
704
        contract = self.contract
4✔
705
        next_module = SENTINEL_ADDRESS
4✔
706
        all_modules: List[ChecksumAddress] = []
4✔
707

708
        for _ in range(max_modules_to_retrieve // pagination):
4✔
709
            # If we use a `while True` loop a custom coded Safe could get us into an infinite loop
710
            (modules, next_module) = contract.functions.getModulesPaginated(
4✔
711
                next_module, pagination
712
            ).call(block_identifier=block_identifier)
713

714
            # Safes with version < 1.4.0 don't include the `starter address` used as pagination in the module list
715
            # From 1.4.0 onwards it is included, so we check for duplicated addresses before inserting
716
            for module in modules + [next_module]:
4✔
717
                if module not in all_modules + [NULL_ADDRESS, SENTINEL_ADDRESS]:
4✔
718
                    all_modules.append(module)
4✔
719

720
            if not modules or next_module in (NULL_ADDRESS, SENTINEL_ADDRESS):
4✔
721
                # `NULL_ADDRESS` is only seen in uninitialized Safes
722
                break
4✔
723
        return all_modules
4✔
724

725
    def retrieve_is_hash_approved(
4✔
726
        self,
727
        owner: str,
728
        safe_hash: bytes,
729
        block_identifier: Optional[BlockIdentifier] = "latest",
730
    ) -> bool:
731
        return (
4✔
732
            self.contract.functions.approvedHashes(owner, safe_hash).call(
733
                block_identifier=block_identifier or "latest"
734
            )
735
            == 1
736
        )
737

738
    def retrieve_is_message_signed(
4✔
739
        self,
740
        message_hash: Hash32,
741
        block_identifier: Optional[BlockIdentifier] = "latest",
742
    ) -> bool:
743
        return self.contract.functions.signedMessages(message_hash).call(
4✔
744
            block_identifier=block_identifier or "latest"
745
        )
746

747
    def retrieve_is_owner(
4✔
748
        self, owner: str, block_identifier: Optional[BlockIdentifier] = "latest"
749
    ) -> bool:
750
        return self.contract.functions.isOwner(owner).call(
4✔
751
            block_identifier=block_identifier or "latest"
752
        )
753

754
    def retrieve_nonce(
4✔
755
        self, block_identifier: Optional[BlockIdentifier] = "latest"
756
    ) -> int:
757
        return self.contract.functions.nonce().call(
4✔
758
            block_identifier=block_identifier or "latest"
759
        )
760

761
    def retrieve_owners(
4✔
762
        self, block_identifier: Optional[BlockIdentifier] = "latest"
763
    ) -> List[str]:
764
        return self.contract.functions.getOwners().call(
4✔
765
            block_identifier=block_identifier or "latest"
766
        )
767

768
    def retrieve_threshold(
4✔
769
        self, block_identifier: Optional[BlockIdentifier] = "latest"
770
    ) -> int:
771
        return self.contract.functions.getThreshold().call(
4✔
772
            block_identifier=block_identifier or "latest"
773
        )
774

775
    def build_multisig_tx(
4✔
776
        self,
777
        to: ChecksumAddress,
778
        value: int,
779
        data: bytes,
780
        operation: int = SafeOperationEnum.CALL.value,
781
        safe_tx_gas: int = 0,
782
        base_gas: int = 0,
783
        gas_price: int = 0,
784
        gas_token: ChecksumAddress = NULL_ADDRESS,
785
        refund_receiver: ChecksumAddress = NULL_ADDRESS,
786
        signatures: bytes = b"",
787
        safe_nonce: Optional[int] = None,
788
    ) -> SafeTx:
789
        """
790
        Allows to execute a Safe transaction confirmed by required number of owners and then pays the account
791
        that submitted the transaction. The fees are always transfered, even if the user transaction fails
792

793
        :param to: Destination address of Safe transaction
794
        :param value: Ether value of Safe transaction
795
        :param data: Data payload of Safe transaction
796
        :param operation: Operation type of Safe transaction
797
        :param safe_tx_gas: Gas that should be used for the Safe transaction
798
        :param base_gas: Gas costs for that are independent of the transaction execution
799
            (e.g. base transaction fee, signature check, payment of the refund)
800
        :param gas_price: Gas price that should be used for the payment calculation
801
        :param gas_token: Token address (or `0x000..000` if ETH) that is used for the payment
802
        :param refund_receiver: Address of receiver of gas payment (or `0x000..000` if tx.origin).
803
        :param signatures: Packed signature data ({bytes32 r}{bytes32 s}{uint8 v})
804
        :param safe_nonce: Nonce of the safe (to calculate hash)
805
        :param safe_version: Safe version (to calculate hash)
806
        :return: SafeTx
807
        """
808

809
        if safe_nonce is None:
4✔
810
            safe_nonce = self.retrieve_nonce()
4✔
811
        return SafeTx(
4✔
812
            self.ethereum_client,
813
            self.address,
814
            to,
815
            value,
816
            data,
817
            operation,
818
            safe_tx_gas,
819
            base_gas,
820
            gas_price,
821
            gas_token,
822
            refund_receiver,
823
            signatures=signatures,
824
            safe_nonce=safe_nonce,
825
            safe_version=self.get_version(),
826
            chain_id=self.chain_id,
827
        )
828

829
    def send_multisig_tx(
4✔
830
        self,
831
        to: ChecksumAddress,
832
        value: int,
833
        data: bytes,
834
        operation: int,
835
        safe_tx_gas: int,
836
        base_gas: int,
837
        gas_price: int,
838
        gas_token: ChecksumAddress,
839
        refund_receiver: ChecksumAddress,
840
        signatures: bytes,
841
        tx_sender_private_key: HexStr,
842
        tx_gas=None,
843
        tx_gas_price=None,
844
        block_identifier: Optional[BlockIdentifier] = "latest",
845
    ) -> EthereumTxSent:
846
        """
847
        Build and send Safe tx
848

849
        :param to:
850
        :param value:
851
        :param data:
852
        :param operation:
853
        :param safe_tx_gas:
854
        :param base_gas:
855
        :param gas_price:
856
        :param gas_token:
857
        :param refund_receiver:
858
        :param signatures:
859
        :param tx_sender_private_key:
860
        :param tx_gas: Gas for the external tx. If not, `(safe_tx_gas + data_gas) * 2` will be used
861
        :param tx_gas_price: Gas price of the external tx. If not, `gas_price` will be used
862
        :param block_identifier:
863
        :return: Tuple(tx_hash, tx)
864
        :raises: InvalidMultisigTx: If user tx cannot go through the Safe
865
        """
866

867
        safe_tx = self.build_multisig_tx(
4✔
868
            to,
869
            value,
870
            data,
871
            operation,
872
            safe_tx_gas,
873
            base_gas,
874
            gas_price,
875
            gas_token,
876
            refund_receiver,
877
            signatures,
878
        )
879

880
        tx_sender_address = Account.from_key(tx_sender_private_key).address
4✔
881
        safe_tx.call(
4✔
882
            tx_sender_address=tx_sender_address, block_identifier=block_identifier
883
        )
884

885
        tx_hash, tx = safe_tx.execute(
4✔
886
            tx_sender_private_key=tx_sender_private_key,
887
            tx_gas=tx_gas,
888
            tx_gas_price=tx_gas_price,
889
            block_identifier=block_identifier,
890
        )
891

892
        return EthereumTxSent(tx_hash, tx, None)
4✔
893

894

895
class SafeV001(Safe):
4✔
896
    def get_version(self):
4✔
897
        return "0.0.1"
×
898

899
    def get_contract_fn(self) -> Callable[[Web3, Optional[ChecksumAddress]], Contract]:
4✔
900
        return get_safe_V0_0_1_contract
×
901

902
    @staticmethod
4✔
903
    def deploy_contract(
4✔
904
        ethereum_client: EthereumClient, deployer_account: LocalAccount
905
    ) -> EthereumTxSent:
906
        """
907
        Deploy master contract. Takes deployer_account (if unlocked in the node) or the deployer private key
908

909
        :param ethereum_client:
910
        :param deployer_account: Ethereum account
911
        :return: ``EthereumTxSent`` with the deployed contract address
912
        """
913

914
        safe_contract = get_safe_V0_0_1_contract(ethereum_client.w3)
4✔
915
        constructor_data = safe_contract.constructor().build_transaction(
4✔
916
            get_empty_tx_params()
917
        )["data"]
918
        initializer_data = safe_contract.functions.setup(
4✔
919
            # We use 2 owners that nobody controls for the master copy
920
            [
921
                "0x0000000000000000000000000000000000000002",
922
                "0x0000000000000000000000000000000000000003",
923
            ],
924
            2,  # Threshold. Maximum security
925
            NULL_ADDRESS,  # Address for optional DELEGATE CALL
926
            b"",  # Data for optional DELEGATE CALL
927
        ).build_transaction({"to": NULL_ADDRESS, "gas": 0, "gasPrice": Wei(0)})["data"]
928

929
        ethereum_tx_sent = ethereum_client.deploy_and_initialize_contract(
4✔
930
            deployer_account, constructor_data, HexBytes(initializer_data)
931
        )
932
        logger.info(
4✔
933
            "Deployed and initialized Old Safe Master Contract=%s by %s",
934
            ethereum_tx_sent.contract_address,
935
            deployer_account.address,
936
        )
937
        return ethereum_tx_sent
4✔
938

939

940
class SafeV100(Safe):
4✔
941
    def get_version(self):
4✔
942
        return "1.0.0"
4✔
943

944
    def get_contract_fn(self) -> Callable[[Web3, Optional[ChecksumAddress]], Contract]:
4✔
945
        return get_safe_V1_0_0_contract
4✔
946

947
    @staticmethod
4✔
948
    def deploy_contract(
4✔
949
        ethereum_client: EthereumClient, deployer_account: LocalAccount
950
    ) -> EthereumTxSent:
951
        """
952
        Deploy master contract. Takes deployer_account (if unlocked in the node) or the deployer private key
953

954
        :param ethereum_client:
955
        :param deployer_account: Ethereum account
956
        :return: ``EthereumTxSent`` with the deployed contract address
957
        """
958

959
        safe_contract = get_safe_V1_0_0_contract(ethereum_client.w3)
4✔
960
        constructor_data = safe_contract.constructor().build_transaction(
4✔
961
            get_empty_tx_params()
962
        )["data"]
963
        initializer_data = safe_contract.functions.setup(
4✔
964
            # We use 2 owners that nobody controls for the master copy
965
            [
966
                "0x0000000000000000000000000000000000000002",
967
                "0x0000000000000000000000000000000000000003",
968
            ],
969
            2,  # Threshold. Maximum security
970
            NULL_ADDRESS,  # Address for optional DELEGATE CALL
971
            b"",  # Data for optional DELEGATE CALL
972
            NULL_ADDRESS,  # Payment token
973
            0,  # Payment
974
            NULL_ADDRESS,  # Refund receiver
975
        ).build_transaction({"to": NULL_ADDRESS, "gas": 0, "gasPrice": Wei(0)})["data"]
976

977
        ethereum_tx_sent = ethereum_client.deploy_and_initialize_contract(
4✔
978
            deployer_account, constructor_data, HexBytes(initializer_data)
979
        )
980
        logger.info(
4✔
981
            "Deployed and initialized Safe Master Contract=%s by %s",
982
            ethereum_tx_sent.contract_address,
983
            deployer_account.address,
984
        )
985
        return ethereum_tx_sent
4✔
986

987

988
class SafeV111(Safe):
4✔
989
    def get_version(self):
4✔
990
        return "1.1.1"
4✔
991

992
    def get_contract_fn(self) -> Callable[[Web3, Optional[ChecksumAddress]], Contract]:
4✔
993
        return get_safe_V1_1_1_contract
4✔
994

995

996
class SafeV120(Safe):
4✔
997
    def get_version(self):
4✔
998
        return "1.2.0"
×
999

1000
    def get_contract_fn(self) -> Callable[[Web3, Optional[ChecksumAddress]], Contract]:
4✔
1001
        return get_safe_V1_1_1_contract
×
1002

1003

1004
class SafeV130(Safe):
4✔
1005
    def get_version(self):
4✔
1006
        return "1.3.0"
4✔
1007

1008
    def get_contract_fn(self) -> Callable[[Web3, Optional[ChecksumAddress]], Contract]:
4✔
1009
        return get_safe_V1_3_0_contract
4✔
1010

1011

1012
class SafeV141(Safe):
4✔
1013
    def get_version(self):
4✔
1014
        return "1.4.1"
4✔
1015

1016
    def get_contract_fn(self) -> Callable[[Web3, Optional[ChecksumAddress]], Contract]:
4✔
1017
        return get_safe_V1_4_1_contract
4✔
1018

1019
    def estimate_tx_gas_with_safe(
4✔
1020
        self,
1021
        to: ChecksumAddress,
1022
        value: int,
1023
        data: bytes,
1024
        operation: int,
1025
        gas_limit: Optional[int] = None,
1026
        block_identifier: Optional[BlockIdentifier] = "latest",
1027
    ) -> int:
1028
        """
1029
        Estimate tx gas. Use `SimulateTxAccesor` and `simulate` on the `CompatibilityFallHandler`
1030

1031
        :param to:
1032
        :param value:
1033
        :param data:
1034
        :param operation:
1035
        :param gas_limit:
1036
        :param block_identifier:
1037
        :return:
1038
        """
1039
        accessor = get_simulate_tx_accessor_V1_4_1_contract(
4✔
1040
            self.w3, address=self.simulate_tx_accessor_address
1041
        )
1042
        simulator = get_compatibility_fallback_handler_contract(
4✔
1043
            self.w3, address=self.address
1044
        )
1045
        simulation_data = accessor.functions.simulate(
4✔
1046
            to, value, data, operation
1047
        ).build_transaction(get_empty_tx_params())["data"]
1048
        params: TxParams = {"gas": gas_limit} if gas_limit else {}
4✔
1049
        # params = {'gas': 2_045_741}
1050
        try:
4✔
1051
            accessible_data = simulator.functions.simulate(
4✔
1052
                accessor.address, simulation_data
1053
            ).call(params)
1054
        except ValueError as e:
4✔
1055
            raise CannotEstimateGas(f"Reverted call using SimulateTxAccessor {e}")
4✔
1056
        try:
4✔
1057
            # Simulate returns (uint256 estimate, bool success, bytes memory returnData)
1058
            (estimate, success, return_data) = eth_abi.decode(
4✔
1059
                ["uint256", "bool", "bytes"], accessible_data
1060
            )
1061
            if not success:
4✔
1062
                raise CannotEstimateGas(
4✔
1063
                    "Cannot estimate gas using SimulateTxAccessor - Execution not successful"
1064
                )
1065
            return estimate
4✔
1066
        except DecodingError as e:
4✔
1067
            try:
×
1068
                decoded_revert: Union[tuple[Any], str] = eth_abi.decode(
×
1069
                    ["string"], accessible_data
1070
                )
1071
            except DecodingError:
×
1072
                decoded_revert = "No revert message"
×
1073
            raise CannotEstimateGas(
×
1074
                f"Cannot estimate gas using SimulateTxAccessor {e} - {decoded_revert}"
1075
            )
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc