diff --git a/gnosis/eth/contracts/__init__.py b/gnosis/eth/contracts/__init__.py index c4219d303..ab6895a7a 100644 --- a/gnosis/eth/contracts/__init__.py +++ b/gnosis/eth/contracts/__init__.py @@ -1,3 +1,4 @@ +# flake8: noqa F401 """ Safe Addresses. Should be the same for every chain except for the ones with `chainId` protection. Check: https://github.com/safe-global/safe-deployments/tree/main/src/assets @@ -23,7 +24,7 @@ import json import os import sys -from typing import Any, Dict, Optional +from typing import Any, Callable, Dict, Optional from eth_typing import ChecksumAddress from hexbytes import HexBytes @@ -32,19 +33,8 @@ from gnosis.util import cache - -def load_contract_interface(file_name): - return _load_json_file(_abi_file_path(file_name)) - - -def _abi_file_path(file): - return os.path.abspath(os.path.join(os.path.dirname(__file__), file)) - - -def _load_json_file(path): - with open(path) as f: - return json.load(f) - +from .abis.multicall import multicall_v3_abi, multicall_v3_bytecode +from .contract_base import ContractBase current_module = sys.modules[__name__] contracts = { @@ -60,7 +50,7 @@ def _load_json_file(path): "delegate_constructor_proxy": "DelegateConstructorProxy.json", "multi_send": "MultiSend.json", "paying_proxy": "PayingProxy.json", - "proxy_factory": "ProxyFactory_V1_3_0.json", + "proxy_factory_V1_3_0": "ProxyFactory_V1_3_0.json", "proxy_factory_V1_1_1": "ProxyFactory_V1_1_1.json", "proxy_factory_V1_0_0": "ProxyFactory_V1_0_0.json", "proxy": "Proxy_V1_1_1.json", @@ -74,15 +64,42 @@ def _load_json_file(path): } -def generate_contract_fn(contract: Dict[str, Any]): +def load_contract_interface(file_name: str) -> Dict[str, Any]: """ - Dynamically generate functions to work with the contracts + :param file_name: + :return: Get parsed JSON to ABI with the relative filename to this file path + """ + return _load_json_file(_abi_file_path(file_name)) + + +def _abi_file_path(file_name: str) -> str: + """ + :param file_name: + :return: Full path to the provided ``file_name`` + """ + return os.path.abspath(os.path.join(os.path.dirname(__file__), "abis", file_name)) + + +def _load_json_file(path) -> Dict[str, Any]: + """ + :param path: + :return: Parsed json for the provided file + """ + with open(path) as f: + return json.load(f) + + +def generate_contract_fn( + contract: Dict[str, Any] +) -> Callable[[Web3, Optional[ChecksumAddress]], Contract]: + """ + Dynamically generate a function to build a Web3 Contract for the provided contract ABI :param contract: - :return: + :return: function that will return a Web3 Contract from an ABI """ - def fn(w3: Web3, address: Optional[ChecksumAddress] = None): + def fn(w3: Web3, address: Optional[ChecksumAddress] = None) -> Contract: return w3.eth.contract( address=address, abi=contract["abi"], bytecode=contract.get("bytecode") ) @@ -90,8 +107,8 @@ def fn(w3: Web3, address: Optional[ChecksumAddress] = None): return fn -# Anotate functions that will be generated later with `setattr` so typing does not complains -def get_safe_contract(w3: Web3, address: Optional[str] = None) -> Contract: +# Anotate functions that will be generated later with `setattr` so typing does not complain +def get_safe_contract(w3: Web3, address: Optional[ChecksumAddress] = None) -> Contract: """ :param w3: :param address: @@ -100,110 +117,154 @@ def get_safe_contract(w3: Web3, address: Optional[str] = None) -> Contract: return get_safe_V1_3_0_contract(w3, address=address) -def get_safe_V1_3_0_contract(w3: Web3, address: Optional[str] = None) -> Contract: +def get_safe_V1_3_0_contract( + w3: Web3, address: Optional[ChecksumAddress] = None +) -> Contract: pass -def get_safe_V1_1_1_contract(w3: Web3, address: Optional[str] = None) -> Contract: +def get_safe_V1_1_1_contract( + w3: Web3, address: Optional[ChecksumAddress] = None +) -> Contract: pass -def get_safe_V1_0_0_contract(w3: Web3, address: Optional[str] = None) -> Contract: +def get_safe_V1_0_0_contract( + w3: Web3, address: Optional[ChecksumAddress] = None +) -> Contract: pass -def get_safe_V0_0_1_contract(w3: Web3, address: Optional[str] = None) -> Contract: +def get_safe_V0_0_1_contract( + w3: Web3, address: Optional[ChecksumAddress] = None +) -> Contract: pass def get_compatibility_fallback_handler_V1_3_0_contract( - w3: Web3, address: Optional[str] = None + w3: Web3, address: Optional[ChecksumAddress] = None ) -> Contract: pass -def get_erc20_contract(w3: Web3, address: Optional[str] = None) -> Contract: +def get_erc20_contract(w3: Web3, address: Optional[ChecksumAddress] = None) -> Contract: pass -def get_erc721_contract(w3: Web3, address: Optional[str] = None) -> Contract: +def get_erc721_contract( + w3: Web3, address: Optional[ChecksumAddress] = None +) -> Contract: pass -def get_erc1155_contract(w3: Web3, address: Optional[str] = None) -> Contract: +def get_erc1155_contract( + w3: Web3, address: Optional[ChecksumAddress] = None +) -> Contract: pass -def get_example_erc20_contract(w3: Web3, address: Optional[str] = None) -> Contract: +def get_example_erc20_contract( + w3: Web3, address: Optional[ChecksumAddress] = None +) -> Contract: pass def get_delegate_constructor_proxy_contract( - w3: Web3, address: Optional[str] = None + w3: Web3, address: Optional[ChecksumAddress] = None ) -> Contract: pass -def get_multi_send_contract(w3: Web3, address: Optional[str] = None) -> Contract: +def get_multi_send_contract( + w3: Web3, address: Optional[ChecksumAddress] = None +) -> Contract: pass -def get_paying_proxy_contract(w3: Web3, address: Optional[str] = None) -> Contract: +def get_paying_proxy_contract( + w3: Web3, address: Optional[ChecksumAddress] = None +) -> Contract: pass -def get_proxy_factory_contract(w3: Web3, address: Optional[str] = None) -> Contract: +def get_proxy_factory_contract( + w3: Web3, address: Optional[ChecksumAddress] = None +) -> Contract: + return get_proxy_factory_V1_3_0_contract(w3, address) + + +def get_proxy_factory_V1_3_0_contract( + w3: Web3, address: Optional[ChecksumAddress] = None +) -> Contract: pass def get_proxy_factory_V1_1_1_contract( - w3: Web3, address: Optional[str] = None + w3: Web3, address: Optional[ChecksumAddress] = None ) -> Contract: pass def get_proxy_factory_V1_0_0_contract( - w3: Web3, address: Optional[str] = None + w3: Web3, address: Optional[ChecksumAddress] = None ) -> Contract: pass -def get_proxy_contract(w3: Web3, address: Optional[str] = None) -> Contract: +def get_proxy_contract(w3: Web3, address: Optional[ChecksumAddress] = None) -> Contract: pass -def get_uniswap_exchange_contract(w3: Web3, address: Optional[str] = None) -> Contract: +def get_uniswap_exchange_contract( + w3: Web3, address: Optional[ChecksumAddress] = None +) -> Contract: pass -def get_uniswap_factory_contract(w3: Web3, address: Optional[str] = None) -> Contract: +def get_uniswap_factory_contract( + w3: Web3, address: Optional[ChecksumAddress] = None +) -> Contract: pass def get_uniswap_v2_factory_contract( - w3: Web3, address: Optional[str] = None + w3: Web3, address: Optional[ChecksumAddress] = None ) -> Contract: pass -def get_uniswap_v2_pair_contract(w3: Web3, address: Optional[str] = None) -> Contract: +def get_uniswap_v2_pair_contract( + w3: Web3, address: Optional[ChecksumAddress] = None +) -> Contract: pass -def get_uniswap_v2_router_contract(w3: Web3, address: Optional[str] = None) -> Contract: +def get_uniswap_v2_router_contract( + w3: Web3, address: Optional[ChecksumAddress] = None +) -> Contract: pass def get_kyber_network_proxy_contract( - w3: Web3, address: Optional[str] = None + w3: Web3, address: Optional[ChecksumAddress] = None ) -> Contract: pass -def get_cpk_factory_contract(w3: Web3, address: Optional[str] = None) -> Contract: +def get_cpk_factory_contract( + w3: Web3, address: Optional[ChecksumAddress] = None +) -> Contract: pass +def get_multicall_v3_contract(w3: Web3, address: Optional[ChecksumAddress] = None): + return w3.eth.contract( + address=address, + abi=multicall_v3_abi, + bytecode=multicall_v3_bytecode, + ) + + @cache def get_proxy_1_3_0_deployed_bytecode() -> bytes: return HexBytes(load_contract_interface("Proxy_V1_3_0.json")["deployedBytecode"]) diff --git a/gnosis/eth/contracts/CPKFactory.json b/gnosis/eth/contracts/abis/CPKFactory.json similarity index 100% rename from gnosis/eth/contracts/CPKFactory.json rename to gnosis/eth/contracts/abis/CPKFactory.json diff --git a/gnosis/eth/contracts/CompatibilityFallbackHandler_V1_3_0.json b/gnosis/eth/contracts/abis/CompatibilityFallbackHandler_V1_3_0.json similarity index 100% rename from gnosis/eth/contracts/CompatibilityFallbackHandler_V1_3_0.json rename to gnosis/eth/contracts/abis/CompatibilityFallbackHandler_V1_3_0.json diff --git a/gnosis/eth/contracts/DelegateConstructorProxy.json b/gnosis/eth/contracts/abis/DelegateConstructorProxy.json similarity index 100% rename from gnosis/eth/contracts/DelegateConstructorProxy.json rename to gnosis/eth/contracts/abis/DelegateConstructorProxy.json diff --git a/gnosis/eth/contracts/ERC1155.json b/gnosis/eth/contracts/abis/ERC1155.json similarity index 100% rename from gnosis/eth/contracts/ERC1155.json rename to gnosis/eth/contracts/abis/ERC1155.json diff --git a/gnosis/eth/contracts/ERC20.json b/gnosis/eth/contracts/abis/ERC20.json similarity index 100% rename from gnosis/eth/contracts/ERC20.json rename to gnosis/eth/contracts/abis/ERC20.json diff --git a/gnosis/eth/contracts/ERC20TestToken.json b/gnosis/eth/contracts/abis/ERC20TestToken.json similarity index 100% rename from gnosis/eth/contracts/ERC20TestToken.json rename to gnosis/eth/contracts/abis/ERC20TestToken.json diff --git a/gnosis/eth/contracts/ERC721.json b/gnosis/eth/contracts/abis/ERC721.json similarity index 100% rename from gnosis/eth/contracts/ERC721.json rename to gnosis/eth/contracts/abis/ERC721.json diff --git a/gnosis/eth/contracts/GnosisSafe_V0_0_1.json b/gnosis/eth/contracts/abis/GnosisSafe_V0_0_1.json similarity index 100% rename from gnosis/eth/contracts/GnosisSafe_V0_0_1.json rename to gnosis/eth/contracts/abis/GnosisSafe_V0_0_1.json diff --git a/gnosis/eth/contracts/GnosisSafe_V1_0_0.json b/gnosis/eth/contracts/abis/GnosisSafe_V1_0_0.json similarity index 100% rename from gnosis/eth/contracts/GnosisSafe_V1_0_0.json rename to gnosis/eth/contracts/abis/GnosisSafe_V1_0_0.json diff --git a/gnosis/eth/contracts/GnosisSafe_V1_1_1.json b/gnosis/eth/contracts/abis/GnosisSafe_V1_1_1.json similarity index 100% rename from gnosis/eth/contracts/GnosisSafe_V1_1_1.json rename to gnosis/eth/contracts/abis/GnosisSafe_V1_1_1.json diff --git a/gnosis/eth/contracts/GnosisSafe_V1_3_0.json b/gnosis/eth/contracts/abis/GnosisSafe_V1_3_0.json similarity index 100% rename from gnosis/eth/contracts/GnosisSafe_V1_3_0.json rename to gnosis/eth/contracts/abis/GnosisSafe_V1_3_0.json diff --git a/gnosis/eth/contracts/MultiSend.json b/gnosis/eth/contracts/abis/MultiSend.json similarity index 100% rename from gnosis/eth/contracts/MultiSend.json rename to gnosis/eth/contracts/abis/MultiSend.json diff --git a/gnosis/eth/contracts/PayingProxy.json b/gnosis/eth/contracts/abis/PayingProxy.json similarity index 100% rename from gnosis/eth/contracts/PayingProxy.json rename to gnosis/eth/contracts/abis/PayingProxy.json diff --git a/gnosis/eth/contracts/ProxyFactory_V1_0_0.json b/gnosis/eth/contracts/abis/ProxyFactory_V1_0_0.json similarity index 100% rename from gnosis/eth/contracts/ProxyFactory_V1_0_0.json rename to gnosis/eth/contracts/abis/ProxyFactory_V1_0_0.json diff --git a/gnosis/eth/contracts/ProxyFactory_V1_1_1.json b/gnosis/eth/contracts/abis/ProxyFactory_V1_1_1.json similarity index 100% rename from gnosis/eth/contracts/ProxyFactory_V1_1_1.json rename to gnosis/eth/contracts/abis/ProxyFactory_V1_1_1.json diff --git a/gnosis/eth/contracts/ProxyFactory_V1_3_0.json b/gnosis/eth/contracts/abis/ProxyFactory_V1_3_0.json similarity index 100% rename from gnosis/eth/contracts/ProxyFactory_V1_3_0.json rename to gnosis/eth/contracts/abis/ProxyFactory_V1_3_0.json diff --git a/gnosis/eth/contracts/Proxy_V1_0_0.json b/gnosis/eth/contracts/abis/Proxy_V1_0_0.json similarity index 100% rename from gnosis/eth/contracts/Proxy_V1_0_0.json rename to gnosis/eth/contracts/abis/Proxy_V1_0_0.json diff --git a/gnosis/eth/contracts/Proxy_V1_1_1.json b/gnosis/eth/contracts/abis/Proxy_V1_1_1.json similarity index 100% rename from gnosis/eth/contracts/Proxy_V1_1_1.json rename to gnosis/eth/contracts/abis/Proxy_V1_1_1.json diff --git a/gnosis/eth/contracts/Proxy_V1_3_0.json b/gnosis/eth/contracts/abis/Proxy_V1_3_0.json similarity index 100% rename from gnosis/eth/contracts/Proxy_V1_3_0.json rename to gnosis/eth/contracts/abis/Proxy_V1_3_0.json diff --git a/gnosis/eth/abis/__init__.py b/gnosis/eth/contracts/abis/__init__.py similarity index 100% rename from gnosis/eth/abis/__init__.py rename to gnosis/eth/contracts/abis/__init__.py diff --git a/gnosis/eth/contracts/kyber_network_proxy.json b/gnosis/eth/contracts/abis/kyber_network_proxy.json similarity index 100% rename from gnosis/eth/contracts/kyber_network_proxy.json rename to gnosis/eth/contracts/abis/kyber_network_proxy.json diff --git a/gnosis/eth/abis/multicall.py b/gnosis/eth/contracts/abis/multicall.py similarity index 100% rename from gnosis/eth/abis/multicall.py rename to gnosis/eth/contracts/abis/multicall.py diff --git a/gnosis/eth/contracts/uniswap_exchange.json b/gnosis/eth/contracts/abis/uniswap_exchange.json similarity index 100% rename from gnosis/eth/contracts/uniswap_exchange.json rename to gnosis/eth/contracts/abis/uniswap_exchange.json diff --git a/gnosis/eth/contracts/uniswap_factory.json b/gnosis/eth/contracts/abis/uniswap_factory.json similarity index 100% rename from gnosis/eth/contracts/uniswap_factory.json rename to gnosis/eth/contracts/abis/uniswap_factory.json diff --git a/gnosis/eth/contracts/uniswap_v2_factory.json b/gnosis/eth/contracts/abis/uniswap_v2_factory.json similarity index 100% rename from gnosis/eth/contracts/uniswap_v2_factory.json rename to gnosis/eth/contracts/abis/uniswap_v2_factory.json diff --git a/gnosis/eth/contracts/uniswap_v2_pair.json b/gnosis/eth/contracts/abis/uniswap_v2_pair.json similarity index 100% rename from gnosis/eth/contracts/uniswap_v2_pair.json rename to gnosis/eth/contracts/abis/uniswap_v2_pair.json diff --git a/gnosis/eth/contracts/uniswap_v2_router.json b/gnosis/eth/contracts/abis/uniswap_v2_router.json similarity index 100% rename from gnosis/eth/contracts/uniswap_v2_router.json rename to gnosis/eth/contracts/abis/uniswap_v2_router.json diff --git a/gnosis/eth/contracts/contract_base.py b/gnosis/eth/contracts/contract_base.py new file mode 100644 index 000000000..45d1ea658 --- /dev/null +++ b/gnosis/eth/contracts/contract_base.py @@ -0,0 +1,30 @@ +from abc import ABCMeta, abstractmethod +from functools import cached_property +from logging import getLogger +from typing import Callable, Optional + +from eth_typing import ChecksumAddress +from web3 import Web3 +from web3.contract import Contract + +logger = getLogger(__name__) + + +class ContractBase(metaclass=ABCMeta): + def __init__( + self, address: ChecksumAddress, ethereum_client: "EthereumClient" # noqa F821 + ): + self.address = address + self.ethereum_client = ethereum_client + self.w3 = ethereum_client.w3 + + @abstractmethod + def get_contract_fn(self) -> Callable[[Web3, Optional[ChecksumAddress]], Contract]: + """ + :return: Contract function to get the proper contract + """ + raise NotImplementedError + + @cached_property + def contract(self) -> Contract: + return self.get_contract_fn()(self.ethereum_client.w3, self.address) diff --git a/gnosis/eth/ethereum_client.py b/gnosis/eth/ethereum_client.py index 68dd6f556..4e65714f6 100644 --- a/gnosis/eth/ethereum_client.py +++ b/gnosis/eth/ethereum_client.py @@ -144,7 +144,7 @@ def with_exception_handling(*args, **kwargs): class EthereumTxSent(NamedTuple): tx_hash: bytes tx: TxParams - contract_address: Optional[str] + contract_address: Optional[ChecksumAddress] class Erc20Info(NamedTuple): @@ -1411,7 +1411,7 @@ def deploy_and_initialize_contract( constructor_data: bytes, initializer_data: bytes = b"", check_receipt: bool = True, - ): + ) -> EthereumTxSent: contract_address: Optional[ChecksumAddress] = None for data in (constructor_data, initializer_data): # Because initializer_data is not mandatory @@ -1505,6 +1505,12 @@ def estimate_gas( @staticmethod def estimate_data_gas(data: bytes): + """ + Estimate gas costs only for "storage" of the ``data`` bytes provided + + :param data: + :return: + """ if isinstance(data, str): data = HexBytes(data) @@ -1698,6 +1704,57 @@ def get_blocks( def is_contract(self, contract_address: ChecksumAddress) -> bool: return bool(self.w3.eth.get_code(contract_address)) + @staticmethod + def build_tx_params( + from_address: Optional[ChecksumAddress] = None, + to_address: Optional[ChecksumAddress] = None, + value: Optional[int] = None, + gas: Optional[int] = None, + gas_price: Optional[int] = None, + nonce: Optional[int] = None, + chain_id: Optional[int] = None, + tx_params: Optional[TxParams] = None, + ) -> TxParams: + """ + Build tx params dictionary. + If an existing TxParams dictionary is provided the fields will be replaced by the provided ones + + :param from_address: + :param to_address: + :param value: + :param gas: + :param gas_price: + :param nonce: + :param chain_id: + :param tx_params: An existing TxParams dictionary will be replaced by the providen values + :return: + """ + + tx_params: TxParams = tx_params or {} + + if from_address: + tx_params["from"] = from_address + + if to_address: + tx_params["to"] = to_address + + if value is not None: + tx_params["value"] = value + + if gas_price is not None: + tx_params["gasPrice"] = gas_price + + if gas is not None: + tx_params["gas"] = gas + + if nonce is not None: + tx_params["nonce"] = nonce + + if chain_id is not None: + tx_params["chainId"] = chain_id + + return tx_params + @tx_with_exception_handling def send_transaction(self, transaction_dict: TxParams) -> HexBytes: return self.w3.eth.send_transaction(transaction_dict) diff --git a/gnosis/eth/multicall.py b/gnosis/eth/multicall.py index 4fd05bada..53554c3b2 100644 --- a/gnosis/eth/multicall.py +++ b/gnosis/eth/multicall.py @@ -4,7 +4,7 @@ """ import logging from dataclasses import dataclass -from typing import Any, List, Optional, Sequence, Tuple +from typing import Any, Callable, List, Optional, Sequence, Tuple import eth_abi from eth_abi.exceptions import DecodingError @@ -14,11 +14,11 @@ from web3 import Web3 from web3._utils.abi import map_abi_data from web3._utils.normalizers import BASE_RETURN_NORMALIZERS -from web3.contract.contract import ContractFunction +from web3.contract.contract import Contract, ContractFunction from web3.exceptions import ContractLogicError from . import EthereumClient, EthereumNetwork, EthereumNetworkNotSupported -from .abis.multicall import multicall_v3_abi, multicall_v3_bytecode +from .contracts import ContractBase, get_multicall_v3_contract from .ethereum_client import EthereumTxSent from .exceptions import BatchCallFunctionFailed @@ -37,7 +37,7 @@ class MulticallDecodedResult: return_data_decoded: Optional[Any] -class Multicall: +class Multicall(ContractBase): # https://github.com/mds1/multicall#deployments ADDRESSES = { EthereumNetwork.MAINNET: "0xcA11bde05977b3631167028862bE2a173976CA11", @@ -75,8 +75,6 @@ def __init__( ethereum_client: EthereumClient, multicall_contract_address: Optional[ChecksumAddress] = None, ): - self.ethereum_client = ethereum_client - self.w3 = ethereum_client.w3 ethereum_network = ethereum_client.get_network() address = multicall_contract_address or self.ADDRESSES.get(ethereum_network) if not address: @@ -86,12 +84,10 @@ def __init__( raise EthereumNetworkNotSupported( "Multicall contract not available for %s", ethereum_network.name ) - self.contract = self.get_contract(self.w3, address) + super().__init__(address, ethereum_client) - def get_contract(self, w3: Web3, address: Optional[ChecksumAddress] = None): - return w3.eth.contract( - address, abi=multicall_v3_abi, bytecode=multicall_v3_bytecode - ) + def get_contract_fn(self) -> Callable[[Web3, Optional[ChecksumAddress]], Contract]: + return get_multicall_v3_contract @classmethod def deploy_contract( @@ -104,17 +100,17 @@ def deploy_contract( :param deployer_account: Ethereum Account :return: deployed contract address """ - contract = cls.get_contract(cls, ethereum_client.w3) - tx = contract.constructor().build_transaction( - {"from": deployer_account.address} + contract_fn = cls.get_contract_fn(cls) + contract = contract_fn(ethereum_client.w3) + constructor_data = contract.constructor().build_transaction( + {"gas": 0, "gasPrice": 0} + )["data"] + + ethereum_tx_sent = ethereum_client.deploy_and_initialize_contract( + deployer_account, constructor_data ) - tx_hash = ethereum_client.send_unsigned_transaction( - tx, private_key=deployer_account.key - ) - tx_receipt = ethereum_client.get_transaction_receipt(tx_hash, timeout=120) - assert tx_receipt and tx_receipt["status"] - contract_address = tx_receipt["contractAddress"] + contract_address = ethereum_tx_sent.contract_address logger.info( "Deployed Multicall V2 Contract %s by %s", contract_address, @@ -122,7 +118,7 @@ def deploy_contract( ) # Add address to addresses dictionary cls.ADDRESSES[ethereum_client.get_network()] = contract_address - return EthereumTxSent(tx_hash, tx, contract_address) + return ethereum_tx_sent @staticmethod def _build_payload( diff --git a/gnosis/safe/proxy_factory.py b/gnosis/safe/proxy_factory.py index dc643d30c..b4d9bb488 100644 --- a/gnosis/safe/proxy_factory.py +++ b/gnosis/safe/proxy_factory.py @@ -1,114 +1,54 @@ -from logging import getLogger -from typing import Optional +from abc import ABCMeta +from typing import Callable, Optional from eth_account.signers.local import LocalAccount from eth_typing import ChecksumAddress -from web3.contract import Contract +from web3 import Web3 +from web3.contract.contract import Contract, ContractFunction from gnosis.eth import EthereumClient, EthereumTxSent from gnosis.eth.contracts import ( + ContractBase, get_paying_proxy_deployed_bytecode, get_proxy_1_0_0_deployed_bytecode, get_proxy_1_1_1_deployed_bytecode, get_proxy_1_1_1_mainnet_deployed_bytecode, get_proxy_1_3_0_deployed_bytecode, - get_proxy_factory_contract, get_proxy_factory_V1_0_0_contract, get_proxy_factory_V1_1_1_contract, + get_proxy_factory_V1_3_0_contract, ) -from gnosis.eth.utils import compare_byte_code, fast_is_checksum_address +from gnosis.eth.utils import compare_byte_code from gnosis.util import cache -logger = getLogger(__name__) - - -class ProxyFactory: - def __init__(self, address: ChecksumAddress, ethereum_client: EthereumClient): - assert fast_is_checksum_address(address), ( - "%s proxy factory address not valid" % address - ) - - self.address = address - self.ethereum_client = ethereum_client - self.w3 = ethereum_client.w3 - - @staticmethod - def _deploy_proxy_factory_contract( - ethereum_client: EthereumClient, - deployer_account: LocalAccount, - contract: Contract, - ) -> EthereumTxSent: - tx = contract.constructor().build_transaction( - {"from": deployer_account.address} - ) - - tx_hash = ethereum_client.send_unsigned_transaction( - tx, private_key=deployer_account.key - ) - tx_receipt = ethereum_client.get_transaction_receipt(tx_hash, timeout=120) - assert tx_receipt - assert tx_receipt["status"] - contract_address = tx_receipt["contractAddress"] - logger.info( - "Deployed and initialized Proxy Factory Contract=%s by %s", - contract_address, - deployer_account.address, - ) - return EthereumTxSent(tx_hash, tx, contract_address) +class ProxyFactoryBase(ContractBase, metaclass=ABCMeta): @classmethod - def deploy_proxy_factory_contract( + def deploy_contract( cls, ethereum_client: EthereumClient, deployer_account: LocalAccount ) -> EthereumTxSent: """ - Deploy proxy factory contract last version (v1.3.0) + Deploy Proxy Factory contract :param ethereum_client: :param deployer_account: Ethereum Account :return: deployed contract address """ - proxy_factory_contract = get_proxy_factory_contract(ethereum_client.w3) - return cls._deploy_proxy_factory_contract( - ethereum_client, deployer_account, proxy_factory_contract - ) - - @classmethod - def deploy_proxy_factory_contract_v1_1_1( - cls, ethereum_client: EthereumClient, deployer_account: LocalAccount - ) -> EthereumTxSent: - """ - Deploy proxy factory contract v1.1.1 - - :param ethereum_client: - :param deployer_account: Ethereum Account - :return: deployed contract address - """ - proxy_factory_contract = get_proxy_factory_V1_1_1_contract(ethereum_client.w3) - return cls._deploy_proxy_factory_contract( - ethereum_client, deployer_account, proxy_factory_contract - ) - - @classmethod - def deploy_proxy_factory_contract_v1_0_0( - cls, ethereum_client: EthereumClient, deployer_account: LocalAccount - ) -> EthereumTxSent: - """ - Deploy proxy factory contract v1.0.0 - - :param ethereum_client: - :param deployer_account: Ethereum Account - :return: deployed contract address - """ - proxy_factory_contract = get_proxy_factory_V1_0_0_contract(ethereum_client.w3) - return cls._deploy_proxy_factory_contract( - ethereum_client, deployer_account, proxy_factory_contract + contract_fn = cls.get_contract_fn(cls) + contract = contract_fn(ethereum_client.w3) + constructor_data = contract.constructor().build_transaction( + {"gas": 0, "gasPrice": 0} + )["data"] + return ethereum_client.deploy_and_initialize_contract( + deployer_account, constructor_data ) def check_proxy_code(self, address: ChecksumAddress) -> bool: """ - Check if proxy is valid + Check if proxy bytecode matches any of the deployed by the supported Proxy Factories + :param address: Ethereum address to check - :return: True if proxy is valid, False otherwise + :return: ``True`` if proxy is valid, ``False`` otherwise """ deployed_proxy_code = self.w3.eth.get_code(address) @@ -125,6 +65,39 @@ def check_proxy_code(self, address: ChecksumAddress) -> bool: return True return False + def _deploy_proxy_contract( + self, + deployer_account: LocalAccount, + deploy_fn: ContractFunction, + gas: Optional[int] = None, + gas_price: Optional[int] = None, + nonce: Optional[int] = None, + ) -> EthereumTxSent: + """ + Common logic for `createProxy` and `createProxyWithNonce` + + :param deployer_account: + :param deploy_fn: + :param gas: + :param gas_price: + :param nonce: + :return: EthereumTxSent + """ + + tx_params = self.ethereum_client.build_tx_params( + from_address=deployer_account.address, + gas=gas, + gas_price=gas_price, + nonce=nonce, + ) + contract_address = deploy_fn.call(tx_params) + tx = deploy_fn.build_transaction(tx_params) + tx_hash = self.ethereum_client.send_unsigned_transaction( + tx, private_key=deployer_account.key + ) + + return EthereumTxSent(tx_hash, tx, contract_address) + def deploy_proxy_contract( self, deployer_account: LocalAccount, @@ -132,37 +105,24 @@ def deploy_proxy_contract( initializer: bytes = b"", gas: Optional[int] = None, gas_price: Optional[int] = None, + nonce: Optional[int] = None, ) -> EthereumTxSent: """ - Deploy proxy contract via ProxyFactory using `createProxy` function + Deploy proxy contract via ProxyFactory using `createProxy` function (CREATE opcode) + :param deployer_account: Ethereum account :param master_copy: Address the proxy will point at - :param initializer: Initializer + :param initializer: Initializer for the deployed proxy :param gas: Gas :param gas_price: Gas Price + :param nonce: Nonce :return: EthereumTxSent """ - proxy_factory_contract = self.get_contract() - create_proxy_fn = proxy_factory_contract.functions.createProxy( - master_copy, initializer - ) - - tx_parameters = {"from": deployer_account.address} - contract_address = create_proxy_fn.call(tx_parameters) - - if gas_price is not None: - tx_parameters["gasPrice"] = gas_price + create_proxy_fn = self.contract.functions.createProxy(master_copy, initializer) - if gas is not None: - tx_parameters["gas"] = gas - - tx = create_proxy_fn.build_transaction(tx_parameters) - # Auto estimation of gas does not work. We use a little more gas just in case - tx["gas"] = tx["gas"] + 50000 - tx_hash = self.ethereum_client.send_unsigned_transaction( - tx, private_key=deployer_account.key + return self._deploy_proxy_contract( + deployer_account, create_proxy_fn, gas=gas, gas_price=gas_price, nonce=nonce ) - return EthereumTxSent(tx_hash, tx, contract_address) def deploy_proxy_contract_with_nonce( self, @@ -175,50 +135,63 @@ def deploy_proxy_contract_with_nonce( nonce: Optional[int] = None, ) -> EthereumTxSent: """ - Deploy proxy contract via Proxy Factory using `createProxyWithNonce` (create2) + Deploy proxy contract via Proxy Factory using `createProxyWithNonce` (CREATE2 opcode) :param deployer_account: Ethereum account :param master_copy: Address the proxy will point at - :param initializer: Data for safe creation - :param salt_nonce: Uint256 for `create2` salt + :param initializer: Initializer for the deployed proxy + :param salt_nonce: Uint256 for ``CREATE2`` salt :param gas: Gas :param gas_price: Gas Price :param nonce: Nonce - :return: Tuple(tx-hash, tx, deployed contract address) + :return: EthereumTxSent """ - proxy_factory_contract = self.get_contract() - create_proxy_fn = proxy_factory_contract.functions.createProxyWithNonce( + create_proxy_fn = self.contract.functions.createProxyWithNonce( master_copy, initializer, salt_nonce ) - tx_parameters = {"from": deployer_account.address} - contract_address = create_proxy_fn.call(tx_parameters) - - if gas_price is not None: - tx_parameters["gasPrice"] = gas_price - - if gas is not None: - tx_parameters["gas"] = gas - - if nonce is not None: - tx_parameters["nonce"] = nonce - - tx = create_proxy_fn.build_transaction(tx_parameters) - # Auto estimation of gas does not work. We use a little more gas just in case - tx["gas"] = tx["gas"] + 50000 - tx_hash = self.ethereum_client.send_unsigned_transaction( - tx, private_key=deployer_account.key + return self._deploy_proxy_contract( + deployer_account, create_proxy_fn, gas=gas, gas_price=gas_price, nonce=nonce ) - return EthereumTxSent(tx_hash, tx, contract_address) - - def get_contract(self, address: Optional[ChecksumAddress] = None): - address = address or self.address - return get_proxy_factory_contract(self.ethereum_client.w3, address) @cache - def get_proxy_runtime_code(self, address: Optional[ChecksumAddress] = None): + def get_proxy_runtime_code(self): """ Get runtime code for current proxy factory """ - address = address or self.address - return self.get_contract(address=address).functions.proxyRuntimeCode().call() + return self.contract.functions.proxyRuntimeCode().call() + + +class ProxyFactoryV100(ProxyFactoryBase): + def get_contract_fn(self) -> Callable[[Web3, ChecksumAddress], Contract]: + return get_proxy_factory_V1_0_0_contract + + +class ProxyFactoryV111(ProxyFactoryBase): + def get_contract_fn(self) -> Callable[[Web3, ChecksumAddress], Contract]: + return get_proxy_factory_V1_1_1_contract + + +class ProxyFactoryV130(ProxyFactoryBase): + def get_contract_fn(self) -> Callable[[Web3, ChecksumAddress], Contract]: + return get_proxy_factory_V1_3_0_contract + + +class ProxyFactory: + versions = { + "1.0.0": ProxyFactoryV100, + "1.1.1": ProxyFactoryV111, + "1.3.0": ProxyFactoryV130, + } + + def __new__( + cls, + address: ChecksumAddress, + ethereum_client: EthereumClient, + version: str = "1.3.0", + ): + # Return default version 1.3.0 + proxy_factory_version = cls.versions.get(version, ProxyFactoryV130) + instance = super().__new__(proxy_factory_version) + instance.__init__(address, ethereum_client) + return instance diff --git a/gnosis/safe/safe.py b/gnosis/safe/safe.py index 52d79c708..104e3b1be 100644 --- a/gnosis/safe/safe.py +++ b/gnosis/safe/safe.py @@ -1,5 +1,6 @@ import dataclasses import math +from abc import ABCMeta, abstractmethod from enum import Enum from functools import cached_property from logging import getLogger @@ -10,7 +11,7 @@ from eth_abi.packed import encode_packed from eth_account import Account from eth_account.signers.local import LocalAccount -from eth_typing import ChecksumAddress, Hash32 +from eth_typing import ChecksumAddress, Hash32, HexStr from hexbytes import HexBytes from web3 import Web3 from web3.contract import Contract @@ -20,6 +21,7 @@ from gnosis.eth import EthereumClient, EthereumTxSent from gnosis.eth.constants import GAS_CALL_DATA_BYTE, NULL_ADDRESS, SENTINEL_ADDRESS from gnosis.eth.contracts import ( + ContractBase, get_compatibility_fallback_handler_V1_3_0_contract, get_delegate_constructor_proxy_contract, get_safe_contract, @@ -32,7 +34,6 @@ fast_bytes_to_checksum_address, fast_is_checksum_address, fast_keccak, - get_eth_address_with_key, ) from gnosis.safe.proxy_factory import ProxyFactory @@ -42,8 +43,7 @@ CannotRetrieveSafeInfoException, InvalidPaymentToken, ) -from .safe_create2_tx import SafeCreate2Tx, SafeCreate2TxBuilder -from .safe_creation_tx import InvalidERC20Token, SafeCreationTx +from .safe_create2_tx import InvalidERC20Token, SafeCreate2Tx, SafeCreate2TxBuilder from .safe_tx import SafeTx logger = getLogger(__name__) @@ -75,9 +75,9 @@ class SafeInfo: version: str -class Safe: +class SafeBase(ContractBase, metaclass=ABCMeta): """ - Class to manage a Gnosis Safe + Collection of methods and utilies to handle a Safe """ # keccak256("fallback_manager.handler.address") @@ -94,28 +94,24 @@ class Safe: "60b3cbf8b4a223d68d641b3b6ddf9a298e7f33710cf3d3a9d1146b5a6150fbca" ) - def __init__(self, address: ChecksumAddress, ethereum_client: EthereumClient): - """ - :param address: Safe address - :param ethereum_client: Initialized ethereum client - """ - assert fast_is_checksum_address(address), "%s is not a valid address" % address - - self.ethereum_client = ethereum_client - self.w3 = self.ethereum_client.w3 - self.address = address - def __str__(self): return f"Safe={self.address}" + @abstractmethod + def get_version(self) -> str: + """ + :return: String with Safe Master Copy semantic version, must match `retrieve_version()` + """ + raise NotImplementedError + @cached_property - def contract(self) -> Contract: - v_1_3_0_contract = get_safe_V1_3_0_contract(self.w3, address=self.address) - version = v_1_3_0_contract.functions.VERSION().call() - if version == "1.3.0": - return v_1_3_0_contract - else: - return get_safe_V1_1_1_contract(self.w3, address=self.address) + def chain_id(self) -> int: + return self.ethereum_client.get_chain_id() + + def retrieve_version( + self, block_identifier: Optional[BlockIdentifier] = "latest" + ) -> str: + return self.contract.functions.VERSION().call(block_identifier=block_identifier) @cached_property def domain_separator(self) -> Optional[bytes]: @@ -128,75 +124,11 @@ def domain_separator(self) -> Optional[bytes]: logger.warning("Safe %s does not support domainSeparator", self.address) return None - @staticmethod - def create( - ethereum_client: EthereumClient, - deployer_account: LocalAccount, - master_copy_address: str, - owners: List[str], - threshold: int, - fallback_handler: str = NULL_ADDRESS, - proxy_factory_address: Optional[str] = None, - payment_token: str = NULL_ADDRESS, - payment: int = 0, - payment_receiver: str = NULL_ADDRESS, - ) -> EthereumTxSent: - - """ - Deploy new Safe proxy pointing to the specified `master_copy` address and configured - with the provided `owners` and `threshold`. By default, payment for the deployer of the tx will be `0`. - If `proxy_factory_address` is set deployment will be done using the proxy factory instead of calling - the `constructor` of a new `DelegatedProxy` - Using `proxy_factory_address` is recommended, as it takes less gas. - (Testing with `Ganache` and 1 owner 261534 without proxy vs 229022 with Proxy) - """ - - assert owners, "At least one owner must be set" - assert 1 <= threshold <= len(owners), "Threshold=%d must be <= %d" % ( - threshold, - len(owners), - ) - - initializer = ( - get_safe_contract(ethereum_client.w3, NULL_ADDRESS) - .functions.setup( - owners, - threshold, - NULL_ADDRESS, # Contract address for optional delegate call - b"", # Data payload for optional delegate call - fallback_handler, # Handler for fallback calls to this contract, - payment_token, - payment, - payment_receiver, - ) - .build_transaction({"gas": Wei(1), "gasPrice": Wei(1)})["data"] - ) - - if proxy_factory_address: - proxy_factory = ProxyFactory(proxy_factory_address, ethereum_client) - return proxy_factory.deploy_proxy_contract( - deployer_account, master_copy_address, initializer=HexBytes(initializer) - ) - - proxy_contract = get_delegate_constructor_proxy_contract(ethereum_client.w3) - tx = proxy_contract.constructor( - master_copy_address, initializer - ).build_transaction({"from": deployer_account.address}) - tx_hash = ethereum_client.send_unsigned_transaction( - tx, private_key=deployer_account.key - ) - tx_receipt = ethereum_client.get_transaction_receipt(tx_hash, timeout=60) - assert tx_receipt - assert tx_receipt["status"] - - contract_address = tx_receipt["contractAddress"] - return EthereumTxSent(tx_hash, tx, contract_address) - - @staticmethod - def _deploy_master_contract( + @classmethod + def deploy_contract( + cls, ethereum_client: EthereumClient, deployer_account: LocalAccount, - contract_fn: Callable[[Web3, Optional[str]], Contract], ) -> EthereumTxSent: """ Deploy master contract. Takes deployer_account (if unlocked in the node) or the deployer private key @@ -207,446 +139,153 @@ def _deploy_master_contract( :param contract_fn: get contract function :return: deployed contract address """ + contract_fn = cls.get_contract_fn(cls) safe_contract = contract_fn(ethereum_client.w3) - constructor_tx = safe_contract.constructor().build_transaction() - tx_hash = ethereum_client.send_unsigned_transaction( - constructor_tx, private_key=deployer_account.key + constructor_data = safe_contract.constructor().build_transaction( + {"gas": 0, "gasPrice": 0} + )["data"] + ethereum_tx_sent = ethereum_client.deploy_and_initialize_contract( + deployer_account, constructor_data ) - tx_receipt = ethereum_client.get_transaction_receipt(tx_hash, timeout=60) - assert tx_receipt - assert tx_receipt["status"] - - ethereum_tx_sent = EthereumTxSent( - tx_hash, constructor_tx, tx_receipt["contractAddress"] + deployed_version = ( + contract_fn(ethereum_client.w3, ethereum_tx_sent.contract_address) + .functions.VERSION() + .call() ) + assert deployed_version == cls.get_version( + cls + ), f"Deployed version {deployed_version} is not matching expected {cls.get_version(cls)} version" + logger.info( "Deployed and initialized Safe Master Contract version=%s on address %s by %s", - contract_fn(ethereum_client.w3, ethereum_tx_sent.contract_address) - .functions.VERSION() - .call(), + deployed_version, ethereum_tx_sent.contract_address, deployer_account.address, ) return ethereum_tx_sent - @classmethod - def deploy_compatibility_fallback_handler( - cls, ethereum_client: EthereumClient, deployer_account: LocalAccount - ) -> EthereumTxSent: + def check_funds_for_tx_gas( + self, safe_tx_gas: int, base_gas: int, gas_price: int, gas_token: str + ) -> bool: """ - Deploy Compatibility Fallback handler v1.3.0 + Check safe has enough funds to pay for a tx - :param ethereum_client: - :param deployer_account: Ethereum account - :return: deployed contract address + :param safe_tx_gas: Safe tx gas + :param base_gas: Data gas + :param gas_price: Gas Price + :param gas_token: Gas Token, to use token instead of ether for the gas + :return: `True` if enough funds, `False` otherwise """ + if gas_token == NULL_ADDRESS: + balance = self.ethereum_client.get_balance(self.address) + else: + balance = self.ethereum_client.erc20.get_balance(self.address, gas_token) + return balance >= (safe_tx_gas + base_gas) * gas_price - contract = get_compatibility_fallback_handler_V1_3_0_contract( - ethereum_client.w3 - ) - constructor_tx = contract.constructor().build_transaction() - tx_hash = ethereum_client.send_unsigned_transaction( - constructor_tx, private_key=deployer_account.key - ) - tx_receipt = ethereum_client.get_transaction_receipt(tx_hash, timeout=60) - assert tx_receipt - assert tx_receipt["status"] - - ethereum_tx_sent = EthereumTxSent( - tx_hash, constructor_tx, tx_receipt["contractAddress"] - ) - logger.info( - "Deployed and initialized Compatibility Fallback Handler version=%s on address %s by %s", - "1.3.0", - ethereum_tx_sent.contract_address, - deployer_account.address, - ) - return ethereum_tx_sent - - @classmethod - def deploy_master_contract_v1_3_0( - cls, ethereum_client: EthereumClient, deployer_account: LocalAccount - ) -> EthereumTxSent: + def estimate_tx_base_gas( + self, + to: ChecksumAddress, + value: int, + data: bytes, + operation: int, + gas_token: ChecksumAddress, + estimated_tx_gas: int, + ) -> int: """ - Deploy master contract v1.3.0. Takes deployer_account (if unlocked in the node) or the deployer private key - Safe with version > v1.1.1 doesn't need to be initialized as it already has a constructor + Calculate gas costs that are independent of the transaction execution(e.g. base transaction fee, + signature check, payment of the refund...) - :param ethereum_client: - :param deployer_account: Ethereum account - :return: deployed contract address + :param to: + :param value: + :param data: + :param operation: + :param gas_token: + :param estimated_tx_gas: gas calculated with `estimate_tx_gas` + :return: """ + data = data or b"" + safe_contract = self.contract + threshold = self.retrieve_threshold() + nonce = self.retrieve_nonce() + + # Every byte == 0 -> 4 Gas + # Every byte != 0 -> 16 Gas (68 before Istanbul) + # numbers < 256 (0x00(31*2)..ff) are 192 -> 31 * 4 + 1 * GAS_CALL_DATA_BYTE + # numbers < 65535 (0x(30*2)..ffff) are 256 -> 30 * 4 + 2 * GAS_CALL_DATA_BYTE - return cls._deploy_master_contract( - ethereum_client, deployer_account, get_safe_V1_3_0_contract + # Calculate gas for signatures + # (array count (3 -> r, s, v) + ecrecover costs) * signature count + # ecrecover for ecdsa ~= 4K gas, we use 6K + ecrecover_gas = 6000 + signature_gas = threshold * ( + 1 * GAS_CALL_DATA_BYTE + 2 * 32 * GAS_CALL_DATA_BYTE + ecrecover_gas ) - @classmethod - def deploy_master_contract_v1_1_1( - cls, ethereum_client: EthereumClient, deployer_account: LocalAccount - ) -> EthereumTxSent: - """ - Deploy master contract v1.1.1. Takes deployer_account (if unlocked in the node) or the deployer private key - Safe with version > v1.1.1 doesn't need to be initialized as it already has a constructor + safe_tx_gas = estimated_tx_gas + base_gas = 0 + gas_price = 1 + gas_token = gas_token or NULL_ADDRESS + signatures = b"" + refund_receiver = NULL_ADDRESS + data = HexBytes( + safe_contract.functions.execTransaction( + to, + value, + data, + operation, + safe_tx_gas, + base_gas, + gas_price, + gas_token, + refund_receiver, + signatures, + ).build_transaction({"gas": 1, "gasPrice": 1})["data"] + ) - :param ethereum_client: - :param deployer_account: Ethereum account - :return: deployed contract address - """ + # If nonce == 0, nonce storage has to be initialized + if nonce == 0: + nonce_gas = 20000 + else: + nonce_gas = 5000 + + # Keccak costs for the hash of the safe tx + hash_generation_gas = 1500 - return cls._deploy_master_contract( - ethereum_client, deployer_account, get_safe_V1_1_1_contract + base_gas = ( + signature_gas + + self.ethereum_client.estimate_data_gas(data) + + nonce_gas + + hash_generation_gas ) - @staticmethod - def deploy_master_contract_v1_0_0( - ethereum_client: EthereumClient, deployer_account: LocalAccount - ) -> EthereumTxSent: + # Add additional gas costs + if base_gas > 65536: + base_gas += 64 + else: + base_gas += 128 + + base_gas += 32000 # Base tx costs, transfer costs... + return base_gas + + def estimate_tx_gas_with_safe( + self, + to: ChecksumAddress, + value: int, + data: bytes, + operation: int, + gas_limit: Optional[int] = None, + block_identifier: Optional[BlockIdentifier] = "latest", + ) -> int: """ - Deploy master contract. Takes deployer_account (if unlocked in the node) or the deployer private key + Estimate tx gas using safe `requiredTxGas` method - :param ethereum_client: - :param deployer_account: Ethereum account - :return: deployed contract address + :return: int: Estimated gas + :raises: CannotEstimateGas: If gas cannot be estimated + :raises: ValueError: Cannot decode received data """ - safe_contract = get_safe_V1_0_0_contract(ethereum_client.w3) - constructor_data = safe_contract.constructor().build_transaction({"gas": 0})[ - "data" - ] - initializer_data = safe_contract.functions.setup( - # We use 2 owners that nobody controls for the master copy - [ - "0x0000000000000000000000000000000000000002", - "0x0000000000000000000000000000000000000003", - ], - 2, # Threshold. Maximum security - NULL_ADDRESS, # Address for optional DELEGATE CALL - b"", # Data for optional DELEGATE CALL - NULL_ADDRESS, # Payment token - 0, # Payment - NULL_ADDRESS, # Refund receiver - ).build_transaction({"to": NULL_ADDRESS})["data"] - - ethereum_tx_sent = ethereum_client.deploy_and_initialize_contract( - deployer_account, constructor_data, HexBytes(initializer_data) - ) - logger.info( - "Deployed and initialized Safe Master Contract=%s by %s", - ethereum_tx_sent.contract_address, - deployer_account.address, - ) - return ethereum_tx_sent - - @staticmethod - def deploy_master_contract_v0_0_1( - ethereum_client: EthereumClient, deployer_account: LocalAccount - ) -> EthereumTxSent: - """ - Deploy master contract. Takes deployer_account (if unlocked in the node) or the deployer private key - - :param ethereum_client: - :param deployer_account: Ethereum account - :return: deployed contract address - """ - - safe_contract = get_safe_V0_0_1_contract(ethereum_client.w3) - constructor_data = safe_contract.constructor().build_transaction({"gas": 0})[ - "data" - ] - initializer_data = safe_contract.functions.setup( - # We use 2 owners that nobody controls for the master copy - [ - "0x0000000000000000000000000000000000000002", - "0x0000000000000000000000000000000000000003", - ], - 2, # Threshold. Maximum security - NULL_ADDRESS, # Address for optional DELEGATE CALL - b"", # Data for optional DELEGATE CALL - ).build_transaction({"to": NULL_ADDRESS})["data"] - - ethereum_tx_sent = ethereum_client.deploy_and_initialize_contract( - deployer_account, constructor_data, HexBytes(initializer_data) - ) - logger.info( - "Deployed and initialized Old Safe Master Contract=%s by %s", - ethereum_tx_sent.contract_address, - deployer_account.address, - ) - return ethereum_tx_sent - - @staticmethod - def estimate_safe_creation( - ethereum_client: EthereumClient, - old_master_copy_address: str, - number_owners: int, - gas_price: int, - payment_token: Optional[str], - payment_receiver: str = NULL_ADDRESS, - payment_token_eth_value: float = 1.0, - fixed_creation_cost: Optional[int] = None, - ) -> SafeCreationEstimate: - s = 15 - owners = [get_eth_address_with_key()[0] for _ in range(number_owners)] - threshold = number_owners - safe_creation_tx = SafeCreationTx( - w3=ethereum_client.w3, - owners=owners, - threshold=threshold, - signature_s=s, - master_copy=old_master_copy_address, - gas_price=gas_price, - funder=payment_receiver, - payment_token=payment_token, - payment_token_eth_value=payment_token_eth_value, - fixed_creation_cost=fixed_creation_cost, - ) - return SafeCreationEstimate( - safe_creation_tx.gas, - safe_creation_tx.gas_price, - safe_creation_tx.payment, - safe_creation_tx.payment_token, - ) - - @staticmethod - def estimate_safe_creation_2( - ethereum_client: EthereumClient, - master_copy_address: str, - proxy_factory_address: str, - number_owners: int, - gas_price: int, - payment_token: Optional[str], - payment_receiver: str = NULL_ADDRESS, - fallback_handler: Optional[str] = None, - payment_token_eth_value: float = 1.0, - fixed_creation_cost: Optional[int] = None, - ) -> SafeCreationEstimate: - salt_nonce = 15 - owners = [Account.create().address for _ in range(number_owners)] - threshold = number_owners - if not fallback_handler: - fallback_handler = ( - Account.create().address - ) # Better estimate it, it's required for new Safes - safe_creation_tx = SafeCreate2TxBuilder( - w3=ethereum_client.w3, - master_copy_address=master_copy_address, - proxy_factory_address=proxy_factory_address, - ).build( - owners=owners, - threshold=threshold, - fallback_handler=fallback_handler, - salt_nonce=salt_nonce, - gas_price=gas_price, - payment_receiver=payment_receiver, - payment_token=payment_token, - payment_token_eth_value=payment_token_eth_value, - fixed_creation_cost=fixed_creation_cost, - ) - return SafeCreationEstimate( - safe_creation_tx.gas, - safe_creation_tx.gas_price, - safe_creation_tx.payment, - safe_creation_tx.payment_token, - ) - - @staticmethod - def build_safe_creation_tx( - ethereum_client: EthereumClient, - master_copy_old_address: str, - s: int, - owners: List[str], - threshold: int, - gas_price: int, - payment_token: Optional[str], - payment_receiver: str, - payment_token_eth_value: float = 1.0, - fixed_creation_cost: Optional[int] = None, - ) -> SafeCreationTx: - try: - safe_creation_tx = SafeCreationTx( - w3=ethereum_client.w3, - owners=owners, - threshold=threshold, - signature_s=s, - master_copy=master_copy_old_address, - gas_price=gas_price, - funder=payment_receiver, - payment_token=payment_token, - payment_token_eth_value=payment_token_eth_value, - fixed_creation_cost=fixed_creation_cost, - ) - except InvalidERC20Token as exc: - raise InvalidPaymentToken( - "Invalid payment token %s" % payment_token - ) from exc - - assert safe_creation_tx.tx_pyethereum.nonce == 0 - return safe_creation_tx - - @staticmethod - def build_safe_create2_tx( - ethereum_client: EthereumClient, - master_copy_address: str, - proxy_factory_address: str, - salt_nonce: int, - owners: List[str], - threshold: int, - gas_price: int, - payment_token: Optional[str], - payment_receiver: Optional[str] = None, # If none, it will be `tx.origin` - fallback_handler: Optional[str] = NULL_ADDRESS, - payment_token_eth_value: float = 1.0, - fixed_creation_cost: Optional[int] = None, - ) -> SafeCreate2Tx: - """ - Prepare safe proxy deployment for being relayed. It calculates and sets the costs of deployment to be returned - to the sender of the tx. If you are an advanced user you may prefer to use `create` function - """ - try: - safe_creation_tx = SafeCreate2TxBuilder( - w3=ethereum_client.w3, - master_copy_address=master_copy_address, - proxy_factory_address=proxy_factory_address, - ).build( - owners=owners, - threshold=threshold, - fallback_handler=fallback_handler, - salt_nonce=salt_nonce, - gas_price=gas_price, - payment_receiver=payment_receiver, - payment_token=payment_token, - payment_token_eth_value=payment_token_eth_value, - fixed_creation_cost=fixed_creation_cost, - ) - except InvalidERC20Token as exc: - raise InvalidPaymentToken( - "Invalid payment token %s" % payment_token - ) from exc - - return safe_creation_tx - - def check_funds_for_tx_gas( - self, safe_tx_gas: int, base_gas: int, gas_price: int, gas_token: str - ) -> bool: - """ - Check safe has enough funds to pay for a tx - - :param safe_tx_gas: Safe tx gas - :param base_gas: Data gas - :param gas_price: Gas Price - :param gas_token: Gas Token, to use token instead of ether for the gas - :return: `True` if enough funds, `False` otherwise - """ - if gas_token == NULL_ADDRESS: - balance = self.ethereum_client.get_balance(self.address) - else: - balance = self.ethereum_client.erc20.get_balance(self.address, gas_token) - return balance >= (safe_tx_gas + base_gas) * gas_price - - def estimate_tx_base_gas( - self, - to: str, - value: int, - data: bytes, - operation: int, - gas_token: str, - estimated_tx_gas: int, - ) -> int: - """ - Calculate gas costs that are independent of the transaction execution(e.g. base transaction fee, - signature check, payment of the refund...) - - :param to: - :param value: - :param data: - :param operation: - :param gas_token: - :param estimated_tx_gas: gas calculated with `estimate_tx_gas` - :return: - """ - data = data or b"" - safe_contract = self.contract - threshold = self.retrieve_threshold() - nonce = self.retrieve_nonce() - - # Every byte == 0 -> 4 Gas - # Every byte != 0 -> 16 Gas (68 before Istanbul) - # numbers < 256 (0x00(31*2)..ff) are 192 -> 31 * 4 + 1 * GAS_CALL_DATA_BYTE - # numbers < 65535 (0x(30*2)..ffff) are 256 -> 30 * 4 + 2 * GAS_CALL_DATA_BYTE - - # Calculate gas for signatures - # (array count (3 -> r, s, v) + ecrecover costs) * signature count - # ecrecover for ecdsa ~= 4K gas, we use 6K - ecrecover_gas = 6000 - signature_gas = threshold * ( - 1 * GAS_CALL_DATA_BYTE + 2 * 32 * GAS_CALL_DATA_BYTE + ecrecover_gas - ) - - safe_tx_gas = estimated_tx_gas - base_gas = 0 - gas_price = 1 - gas_token = gas_token or NULL_ADDRESS - signatures = b"" - refund_receiver = NULL_ADDRESS - data = HexBytes( - safe_contract.functions.execTransaction( - to, - value, - data, - operation, - safe_tx_gas, - base_gas, - gas_price, - gas_token, - refund_receiver, - signatures, - ).build_transaction({"gas": 1, "gasPrice": 1})["data"] - ) - - # If nonce == 0, nonce storage has to be initialized - if nonce == 0: - nonce_gas = 20000 - else: - nonce_gas = 5000 - - # Keccak costs for the hash of the safe tx - hash_generation_gas = 1500 - - base_gas = ( - signature_gas - + self.ethereum_client.estimate_data_gas(data) - + nonce_gas - + hash_generation_gas - ) - - # Add additional gas costs - if base_gas > 65536: - base_gas += 64 - else: - base_gas += 128 - - base_gas += 32000 # Base tx costs, transfer costs... - return base_gas - - def estimate_tx_gas_with_safe( - self, - to: str, - value: int, - data: bytes, - operation: int, - gas_limit: Optional[int] = None, - block_identifier: Optional[BlockIdentifier] = "latest", - ) -> int: - """ - Estimate tx gas using safe `requiredTxGas` method - - :return: int: Estimated gas - :raises: CannotEstimateGas: If gas cannot be estimated - :raises: ValueError: Cannot decode received data - """ - - safe_address = self.address - data = data or b"" + safe_address = self.address + data = data or b"" def parse_revert_data(result: bytes) -> int: # 4 bytes - error method id @@ -721,7 +360,9 @@ def parse_revert_data(result: bytes) -> int: f"Received {response.status_code} - {response.content} from ethereum node" ) - def estimate_tx_gas_with_web3(self, to: str, value: int, data: EthereumData) -> int: + def estimate_tx_gas_with_web3( + self, to: ChecksumAddress, value: int, data: EthereumData + ) -> int: """ :param to: :param value: @@ -738,10 +379,10 @@ def estimate_tx_gas_with_web3(self, to: str, value: int, data: EthereumData) -> ) from exc def estimate_tx_gas_by_trying( - self, to: str, value: int, data: Union[bytes, str], operation: int + self, to: ChecksumAddress, value: int, data: Union[bytes, str], operation: int ): """ - Try to get an estimation with Safe's `requiredTxGas`. If estimation if successful, try to set a gas limit and + Try to get an estimation with Safe's `requiredTxGas`. If estimation is successful, try to set a gas limit and estimate again. If gas estimation is ok, same gas estimation should be returned, if it's less than required estimation will not be completed, so estimation was not accurate and gas limit needs to be increased. @@ -792,7 +433,9 @@ def estimate_tx_gas_by_trying( return block_gas_limit return gas_estimated - def estimate_tx_gas(self, to: str, value: int, data: bytes, operation: int) -> int: + def estimate_tx_gas( + self, to: ChecksumAddress, value: int, data: bytes, operation: int + ) -> int: """ Estimate tx gas. Use `requiredTxGas` on the Safe contract and fallbacks to `eth_estimateGas` if that method fails. Note: `eth_estimateGas` cannot estimate delegate calls @@ -826,28 +469,11 @@ def estimate_tx_gas(self, to: str, value: int, data: bytes, operation: int) -> i + WEB3_ESTIMATION_OFFSET ) - def estimate_tx_operational_gas(self, data_bytes_length: int) -> int: - """ - DEPRECATED. `estimate_tx_base_gas` already includes this. - Estimates the gas for the verification of the signatures and other safe related tasks - before and after executing a transaction. - Calculation will be the sum of: - - - Base cost of 15000 gas - - 100 of gas per word of `data_bytes` - - Validate the signatures 5000 * threshold (ecrecover for ecdsa ~= 4K gas) - - :param data_bytes_length: Length of the data (in bytes, so `len(HexBytes('0x12'))` would be `1` - :return: gas costs per signature * threshold of Safe - """ - threshold = self.retrieve_threshold() - return 15000 + data_bytes_length // 32 * 100 + 5000 * threshold - def get_message_hash(self, message: Union[str, Hash32]) -> Hash32: """ Return hash of a message that can be signed by owners. - :param message: Message that should be hashed + :param message: Message that should be hashed. A ``Hash32`` must be provided for EIP191 or EIP712 messages :return: Message hash """ @@ -885,14 +511,24 @@ def retrieve_all_info( try: contract = self.contract master_copy = self.retrieve_master_copy_address() + if master_copy == NULL_ADDRESS: + raise CannotRetrieveSafeInfoException(self.address) + fallback_handler = self.retrieve_fallback_handler() - guard = self.retrieve_guard() + guard = self.retrieve_guard() # Guard was implemented in v1.1.1 + + # From v1.1.1: + # - `getModulesPaginated` is available + # - `getModules` returns only 10 modules + modules_fn = ( + contract.functions.getModulesPaginated(SENTINEL_ADDRESS, 20) + if hasattr(contract.functions, "getModulesPaginated") + else contract.functions.getModules() + ) results = self.ethereum_client.batch_call( [ - contract.functions.getModulesPaginated( - SENTINEL_ADDRESS, 20 - ), # Does not exist in version < 1.1.1 + modules_fn, contract.functions.nonce(), contract.functions.getOwners(), contract.functions.getThreshold(), @@ -903,14 +539,20 @@ def retrieve_all_info( raise_exception=False, ) modules_response, nonce, owners, threshold, version = results - if not modules_response: - # < 1.1.1 - modules = self.retrieve_modules() - else: + if ( + modules_response + and len(modules_response) == 2 + and isinstance(modules_response[0], (tuple, list)) + ): + # Must be a Tuple[List[ChecksumAddress], ChecksumAddress] + # >= v1.1.1 modules, next_module = modules_response if modules and next_module != SENTINEL_ADDRESS: # Still more elements in the list modules = self.retrieve_modules() + else: + # < v1.1.1 + modules = modules_response return SafeInfo( self.address, @@ -971,46 +613,48 @@ def retrieve_master_copy_address( def retrieve_modules( self, pagination: Optional[int] = 50, - block_identifier: Optional[BlockIdentifier] = "latest", max_modules_to_retrieve: Optional[int] = 500, + block_identifier: Optional[BlockIdentifier] = "latest", ) -> List[ChecksumAddress]: """ + Get modules enabled on the Safe + From v1.1.1: + - ``getModulesPaginated`` is available + - ``getModules`` returns only 10 modules + :param pagination: Number of modules to get per request - :param block_identifier: :param max_modules_to_retrieve: Maximum number of modules to retrieve + :param block_identifier: :return: List of module addresses """ - try: - # Contracts with Safe version < 1.1.0 were not paginated - contract = get_safe_V1_0_0_contract( - self.ethereum_client.w3, address=self.address - ) - return contract.functions.getModules().call( + if not hasattr(self.contract.functions, "getModulesPaginated"): + return self.contract.functions.getModules().call( block_identifier=block_identifier ) - except Web3Exception: - pass + # We need to iterate the module paginator contract = self.contract - address = SENTINEL_ADDRESS + next_module = SENTINEL_ADDRESS all_modules: List[ChecksumAddress] = [] for _ in range(max_modules_to_retrieve // pagination): # If we use a `while True` loop a custom coded Safe could get us into an infinite loop - (modules, address) = contract.functions.getModulesPaginated( - address, pagination + (modules, next_module) = contract.functions.getModulesPaginated( + next_module, pagination ).call(block_identifier=block_identifier) - if not modules or address in (NULL_ADDRESS, SENTINEL_ADDRESS): - # `NULL_ADDRESS` is only seen in uninitialized Safes - break - # Safes with version < 1.4.0 don't include the `starter address` used as pagination in the module list # From 1.4.0 onwards it is included, so we check for duplicated addresses before inserting modules_to_insert = [ - module for module in modules + [address] if module not in all_modules + module + for module in modules + [next_module] + if module not in all_modules + [NULL_ADDRESS, SENTINEL_ADDRESS] ] all_modules.extend(modules_to_insert) + + if not modules or next_module in (NULL_ADDRESS, SENTINEL_ADDRESS): + # `NULL_ADDRESS` is only seen in uninitialized Safes + break return all_modules def retrieve_is_hash_approved( @@ -1028,7 +672,7 @@ def retrieve_is_hash_approved( def retrieve_is_message_signed( self, - message_hash: bytes, + message_hash: Hash32, block_identifier: Optional[BlockIdentifier] = "latest", ) -> bool: return self.contract.functions.signedMessages(message_hash).call( @@ -1061,127 +705,445 @@ def retrieve_threshold( block_identifier=block_identifier ) - def retrieve_version( - self, block_identifier: Optional[BlockIdentifier] = "latest" - ) -> str: - return self.contract.functions.VERSION().call(block_identifier=block_identifier) + def build_multisig_tx( + self, + to: ChecksumAddress, + value: int, + data: bytes, + operation: int = SafeOperation.CALL.value, + safe_tx_gas: int = 0, + base_gas: int = 0, + gas_price: int = 0, + gas_token: ChecksumAddress = NULL_ADDRESS, + refund_receiver: ChecksumAddress = NULL_ADDRESS, + signatures: bytes = b"", + safe_nonce: Optional[int] = None, + ) -> SafeTx: + """ + Allows to execute a Safe transaction confirmed by required number of owners and then pays the account + that submitted the transaction. The fees are always transfered, even if the user transaction fails + + :param to: Destination address of Safe transaction + :param value: Ether value of Safe transaction + :param data: Data payload of Safe transaction + :param operation: Operation type of Safe transaction + :param safe_tx_gas: Gas that should be used for the Safe transaction + :param base_gas: Gas costs for that are independent of the transaction execution + (e.g. base transaction fee, signature check, payment of the refund) + :param gas_price: Gas price that should be used for the payment calculation + :param gas_token: Token address (or `0x000..000` if ETH) that is used for the payment + :param refund_receiver: Address of receiver of gas payment (or `0x000..000` if tx.origin). + :param signatures: Packed signature data ({bytes32 r}{bytes32 s}{uint8 v}) + :param safe_nonce: Nonce of the safe (to calculate hash) + :param safe_version: Safe version (to calculate hash) + :return: SafeTx + """ + + if safe_nonce is None: + safe_nonce = self.retrieve_nonce() + return SafeTx( + self.ethereum_client, + self.address, + to, + value, + data, + operation, + safe_tx_gas, + base_gas, + gas_price, + gas_token, + refund_receiver, + signatures=signatures, + safe_nonce=safe_nonce, + safe_version=self.get_version(), + chain_id=self.chain_id, + ) + + def send_multisig_tx( + self, + to: ChecksumAddress, + value: int, + data: bytes, + operation: int, + safe_tx_gas: int, + base_gas: int, + gas_price: int, + gas_token: ChecksumAddress, + refund_receiver: ChecksumAddress, + signatures: bytes, + tx_sender_private_key: HexStr, + tx_gas=None, + tx_gas_price=None, + block_identifier: Optional[BlockIdentifier] = "latest", + ) -> EthereumTxSent: + """ + Build and send Safe tx + + :param to: + :param value: + :param data: + :param operation: + :param safe_tx_gas: + :param base_gas: + :param gas_price: + :param gas_token: + :param refund_receiver: + :param signatures: + :param tx_sender_private_key: + :param tx_gas: Gas for the external tx. If not, `(safe_tx_gas + data_gas) * 2` will be used + :param tx_gas_price: Gas price of the external tx. If not, `gas_price` will be used + :param block_identifier: + :return: Tuple(tx_hash, tx) + :raises: InvalidMultisigTx: If user tx cannot go through the Safe + """ + + safe_tx = self.build_multisig_tx( + to, + value, + data, + operation, + safe_tx_gas, + base_gas, + gas_price, + gas_token, + refund_receiver, + signatures, + ) + + tx_sender_address = Account.from_key(tx_sender_private_key).address + safe_tx.call( + tx_sender_address=tx_sender_address, block_identifier=block_identifier + ) + + tx_hash, tx = safe_tx.execute( + tx_sender_private_key=tx_sender_private_key, + tx_gas=tx_gas, + tx_gas_price=tx_gas_price, + block_identifier=block_identifier, + ) + + return EthereumTxSent(tx_hash, tx, None) + + +class SafeV001(SafeBase): + def get_version(self): + return "0.0.1" + + def get_contract_fn(self) -> Callable[[Web3, ChecksumAddress], Contract]: + return get_safe_V0_0_1_contract + + @staticmethod + def deploy_contract( + ethereum_client: EthereumClient, deployer_account: LocalAccount + ) -> EthereumTxSent: + """ + Deploy master contract. Takes deployer_account (if unlocked in the node) or the deployer private key + + :param ethereum_client: + :param deployer_account: Ethereum account + :return: deployed contract address + """ + + safe_contract = get_safe_V0_0_1_contract(ethereum_client.w3) + constructor_data = safe_contract.constructor().build_transaction({"gas": 0})[ + "data" + ] + initializer_data = safe_contract.functions.setup( + # We use 2 owners that nobody controls for the master copy + [ + "0x0000000000000000000000000000000000000002", + "0x0000000000000000000000000000000000000003", + ], + 2, # Threshold. Maximum security + NULL_ADDRESS, # Address for optional DELEGATE CALL + b"", # Data for optional DELEGATE CALL + ).build_transaction({"to": NULL_ADDRESS, "gas": 0, "gasPrice": 0})["data"] + + ethereum_tx_sent = ethereum_client.deploy_and_initialize_contract( + deployer_account, constructor_data, HexBytes(initializer_data) + ) + logger.info( + "Deployed and initialized Old Safe Master Contract=%s by %s", + ethereum_tx_sent.contract_address, + deployer_account.address, + ) + return ethereum_tx_sent + + +class SafeV100(SafeBase): + def get_version(self): + return "1.0.0" + + def get_contract_fn(self) -> Contract: + return get_safe_V1_0_0_contract + + @staticmethod + def deploy_contract( + ethereum_client: EthereumClient, deployer_account: LocalAccount + ) -> EthereumTxSent: + """ + Deploy master contract. Takes deployer_account (if unlocked in the node) or the deployer private key + + :param ethereum_client: + :param deployer_account: Ethereum account + :return: deployed contract address + """ + + safe_contract = get_safe_V1_0_0_contract(ethereum_client.w3) + constructor_data = safe_contract.constructor().build_transaction({"gas": 0})[ + "data" + ] + initializer_data = safe_contract.functions.setup( + # We use 2 owners that nobody controls for the master copy + [ + "0x0000000000000000000000000000000000000002", + "0x0000000000000000000000000000000000000003", + ], + 2, # Threshold. Maximum security + NULL_ADDRESS, # Address for optional DELEGATE CALL + b"", # Data for optional DELEGATE CALL + NULL_ADDRESS, # Payment token + 0, # Payment + NULL_ADDRESS, # Refund receiver + ).build_transaction({"to": NULL_ADDRESS, "gas": 0, "gasPrice": 0})["data"] + + ethereum_tx_sent = ethereum_client.deploy_and_initialize_contract( + deployer_account, constructor_data, HexBytes(initializer_data) + ) + logger.info( + "Deployed and initialized Safe Master Contract=%s by %s", + ethereum_tx_sent.contract_address, + deployer_account.address, + ) + return ethereum_tx_sent + + +class SafeV111(SafeBase): + def get_version(self): + return "1.1.1" + + def get_contract_fn(self) -> Contract: + return get_safe_V1_1_1_contract + + +class SafeV130(SafeBase): + def get_version(self): + return "1.3.0" + + def get_contract_fn(self) -> Contract: + return get_safe_V1_3_0_contract - def build_multisig_tx( - self, - to: str, - value: int, - data: bytes, - operation: int = SafeOperation.CALL.value, - safe_tx_gas: int = 0, - base_gas: int = 0, - gas_price: int = 0, - gas_token: str = NULL_ADDRESS, - refund_receiver: str = NULL_ADDRESS, - signatures: bytes = b"", - safe_nonce: Optional[int] = None, - safe_version: Optional[str] = None, - ) -> SafeTx: + +class Safe: + versions = { + "0.0.1": SafeV001, + "1.0.0": SafeV100, + "1.1.1": SafeV111, + "1.3.0": SafeV130, + } + + def __new__(cls, address: ChecksumAddress, ethereum_client: EthereumClient): + assert fast_is_checksum_address(address), "%s is not a valid address" % address + version: Optional[str] + try: + contract = get_safe_contract(ethereum_client.w3, address=address) + version = contract.functions.VERSION().call(block_identifier="latest") + except (Web3Exception, ValueError): + version = None # Cannot detect the version + + safe_version = cls.versions.get(version, SafeV130) + instance = super().__new__(safe_version) + instance.__init__(address, ethereum_client) + return instance + + @staticmethod + def create( + ethereum_client: EthereumClient, + deployer_account: LocalAccount, + master_copy_address: ChecksumAddress, + owners: List[ChecksumAddress], + threshold: int, + fallback_handler: Optional[ChecksumAddress] = NULL_ADDRESS, + proxy_factory_address: Optional[ChecksumAddress] = None, + payment_token: Optional[ChecksumAddress] = NULL_ADDRESS, + payment: int = 0, + payment_receiver: Optional[ChecksumAddress] = NULL_ADDRESS, + ) -> EthereumTxSent: """ - Allows to execute a Safe transaction confirmed by required number of owners and then pays the account - that submitted the transaction. The fees are always transfered, even if the user transaction fails + Deploy new Safe proxy pointing to the specified `master_copy` address and configured + with the provided `owners` and `threshold`. By default, payment for the deployer of the tx will be `0`. + If `proxy_factory_address` is set deployment will be done using the proxy factory instead of calling + the `constructor` of a new `DelegatedProxy` + Using `proxy_factory_address` is recommended - :param to: Destination address of Safe transaction - :param value: Ether value of Safe transaction - :param data: Data payload of Safe transaction - :param operation: Operation type of Safe transaction - :param safe_tx_gas: Gas that should be used for the Safe transaction - :param base_gas: Gas costs for that are independent of the transaction execution - (e.g. base transaction fee, signature check, payment of the refund) - :param gas_price: Gas price that should be used for the payment calculation - :param gas_token: Token address (or `0x000..000` if ETH) that is used for the payment - :param refund_receiver: Address of receiver of gas payment (or `0x000..000` if tx.origin). - :param signatures: Packed signature data ({bytes32 r}{bytes32 s}{uint8 v}) - :param safe_nonce: Nonce of the safe (to calculate hash) - :param safe_version: Safe version (to calculate hash) + :param ethereum_client: + :param deployer_account: + :param master_copy_address: + :param owners: + :param threshold: + :param fallback_handler: + :param proxy_factory_address: + :param payment_token: + :param payment: + :param payment_receiver: :return: """ - if safe_nonce is None: - safe_nonce = self.retrieve_nonce() - safe_version = safe_version or self.retrieve_version() - return SafeTx( - self.ethereum_client, - self.address, - to, - value, - data, - operation, - safe_tx_gas, - base_gas, - gas_price, - gas_token, - refund_receiver, - signatures=signatures, - safe_nonce=safe_nonce, - safe_version=safe_version, + assert owners, "At least one owner must be set" + assert 1 <= threshold <= len(owners), "Threshold=%d must be <= %d" % ( + threshold, + len(owners), ) - def send_multisig_tx( - self, - to: str, - value: int, - data: bytes, - operation: int, - safe_tx_gas: int, - base_gas: int, - gas_price: int, - gas_token: str, - refund_receiver: str, - signatures: bytes, - tx_sender_private_key: str, - tx_gas=None, - tx_gas_price=None, - block_identifier: Optional[BlockIdentifier] = "latest", + initializer = ( + get_safe_contract(ethereum_client.w3, NULL_ADDRESS) + .functions.setup( + owners, + threshold, + NULL_ADDRESS, # Contract address for optional delegate call + b"", # Data payload for optional delegate call + fallback_handler, # Handler for fallback calls to this contract, + payment_token, + payment, + payment_receiver, + ) + .build_transaction({"gas": Wei(1), "gasPrice": Wei(1)})["data"] + ) + + if proxy_factory_address: + proxy_factory = ProxyFactory(proxy_factory_address, ethereum_client) + return proxy_factory.deploy_proxy_contract( + deployer_account, master_copy_address, initializer=HexBytes(initializer) + ) + + proxy_contract = get_delegate_constructor_proxy_contract(ethereum_client.w3) + tx = proxy_contract.constructor( + master_copy_address, initializer + ).build_transaction({"from": deployer_account.address}) + tx_hash = ethereum_client.send_unsigned_transaction( + tx, private_key=deployer_account.key + ) + tx_receipt = ethereum_client.get_transaction_receipt(tx_hash, timeout=60) + assert tx_receipt + assert tx_receipt["status"] + + contract_address = tx_receipt["contractAddress"] + return EthereumTxSent(tx_hash, tx, contract_address) + + @staticmethod + def deploy_compatibility_fallback_handler( + ethereum_client: EthereumClient, deployer_account: LocalAccount ) -> EthereumTxSent: """ - Build and send Safe tx + Deploy Last compatibility Fallback handler (v1.3.0) - :param to: - :param value: - :param data: - :param operation: - :param safe_tx_gas: - :param base_gas: - :param gas_price: - :param gas_token: - :param refund_receiver: - :param signatures: - :param tx_sender_private_key: - :param tx_gas: Gas for the external tx. If not, `(safe_tx_gas + data_gas) * 2` will be used - :param tx_gas_price: Gas price of the external tx. If not, `gas_price` will be used - :param block_identifier: - :return: Tuple(tx_hash, tx) - :raises: InvalidMultisigTx: If user tx cannot go through the Safe + :param ethereum_client: + :param deployer_account: Ethereum account + :return: deployed contract address """ - safe_tx = self.build_multisig_tx( - to, - value, - data, - operation, - safe_tx_gas, - base_gas, - gas_price, - gas_token, - refund_receiver, - signatures, + contract = get_compatibility_fallback_handler_V1_3_0_contract( + ethereum_client.w3 + ) + constructor_tx = contract.constructor().build_transaction() + tx_hash = ethereum_client.send_unsigned_transaction( + constructor_tx, private_key=deployer_account.key ) + tx_receipt = ethereum_client.get_transaction_receipt(tx_hash, timeout=60) + assert tx_receipt + assert tx_receipt["status"] - tx_sender_address = Account.from_key(tx_sender_private_key).address - safe_tx.call( - tx_sender_address=tx_sender_address, block_identifier=block_identifier + ethereum_tx_sent = EthereumTxSent( + tx_hash, constructor_tx, tx_receipt["contractAddress"] + ) + logger.info( + "Deployed and initialized Compatibility Fallback Handler version=%s on address %s by %s", + "1.3.0", + ethereum_tx_sent.contract_address, + deployer_account.address, ) + return ethereum_tx_sent - tx_hash, tx = safe_tx.execute( - tx_sender_private_key=tx_sender_private_key, - tx_gas=tx_gas, - tx_gas_price=tx_gas_price, - block_identifier=block_identifier, + @staticmethod + def estimate_safe_creation_2( + ethereum_client: EthereumClient, + master_copy_address: str, + proxy_factory_address: str, + number_owners: int, + gas_price: int, + payment_token: Optional[str], + payment_receiver: str = NULL_ADDRESS, + fallback_handler: Optional[str] = None, + payment_token_eth_value: float = 1.0, + fixed_creation_cost: Optional[int] = None, + ) -> SafeCreationEstimate: + salt_nonce = 15 + owners = [Account.create().address for _ in range(number_owners)] + threshold = number_owners + if not fallback_handler: + fallback_handler = ( + Account.create().address + ) # Better estimate it, it's required for new Safes + safe_creation_tx = SafeCreate2TxBuilder( + w3=ethereum_client.w3, + master_copy_address=master_copy_address, + proxy_factory_address=proxy_factory_address, + ).build( + owners=owners, + threshold=threshold, + fallback_handler=fallback_handler, + salt_nonce=salt_nonce, + gas_price=gas_price, + payment_receiver=payment_receiver, + payment_token=payment_token, + payment_token_eth_value=payment_token_eth_value, + fixed_creation_cost=fixed_creation_cost, + ) + return SafeCreationEstimate( + safe_creation_tx.gas, + safe_creation_tx.gas_price, + safe_creation_tx.payment, + safe_creation_tx.payment_token, ) - return EthereumTxSent(tx_hash, tx, None) + @staticmethod + def build_safe_create2_tx( + ethereum_client: EthereumClient, + master_copy_address: str, + proxy_factory_address: str, + salt_nonce: int, + owners: List[str], + threshold: int, + gas_price: int, + payment_token: Optional[str], + payment_receiver: Optional[str] = None, # If none, it will be `tx.origin` + fallback_handler: Optional[str] = NULL_ADDRESS, + payment_token_eth_value: float = 1.0, + fixed_creation_cost: Optional[int] = None, + ) -> SafeCreate2Tx: + """ + Prepare safe proxy deployment for being relayed. It calculates and sets the costs of deployment to be returned + to the sender of the tx. If you are an advanced user you may prefer to use `create` function + """ + try: + safe_creation_tx = SafeCreate2TxBuilder( + w3=ethereum_client.w3, + master_copy_address=master_copy_address, + proxy_factory_address=proxy_factory_address, + ).build( + owners=owners, + threshold=threshold, + fallback_handler=fallback_handler, + salt_nonce=salt_nonce, + gas_price=gas_price, + payment_receiver=payment_receiver, + payment_token=payment_token, + payment_token_eth_value=payment_token_eth_value, + fixed_creation_cost=fixed_creation_cost, + ) + except InvalidERC20Token as exc: + raise InvalidPaymentToken( + "Invalid payment token %s" % payment_token + ) from exc + + return safe_creation_tx diff --git a/gnosis/safe/safe_create2_tx.py b/gnosis/safe/safe_create2_tx.py index dc28d3c16..16ce4f400 100644 --- a/gnosis/safe/safe_create2_tx.py +++ b/gnosis/safe/safe_create2_tx.py @@ -19,6 +19,10 @@ logger = getLogger(__name__) +# Gas required to transfer a regular token. Assume the worst scenario with a regular token transfer without storage +# initialized (payment_receiver no previous owner of token) +TOKEN_TRANSFER_GAS = 60_000 + class InvalidERC20Token(Exception): pass @@ -50,6 +54,7 @@ class SafeCreate2TxBuilder: def __init__(self, w3: Web3, master_copy_address: str, proxy_factory_address: str): """ Init builder for safe creation using create2 + :param w3: Web3 instance :param master_copy_address: `Gnosis Safe` master copy address :param proxy_factory_address: `Gnosis Proxy Factory` address @@ -95,6 +100,7 @@ def build( ): """ Prepare Safe creation + :param owners: Owners of the Safe :param threshold: Minimum number of users required to operate the Safe :param fallback_handler: Handler for fallback calls to the Safe @@ -125,12 +131,14 @@ def build( payment_receiver=payment_receiver, ) - magic_gas: int = self._calculate_gas(owners, safe_setup_data, payment_token) + calculated_gas: int = self._calculate_gas( + owners, safe_setup_data, payment_token + ) estimated_gas: int = self._estimate_gas( safe_setup_data, salt_nonce, payment_token, payment_receiver ) - logger.debug("Magic gas %d - Estimated gas %d" % (magic_gas, estimated_gas)) - gas = max(magic_gas, estimated_gas) + logger.debug("Magic gas %d - Estimated gas %d", calculated_gas, estimated_gas) + gas = max(calculated_gas, estimated_gas) # Payment will be safe deploy cost payment = self._calculate_refund_payment( @@ -175,23 +183,25 @@ def _calculate_gas( owners: List[str], safe_setup_data: bytes, payment_token: str ) -> int: """ - Calculate gas manually, based on tests of previosly deployed safes + Calculate gas manually, based on tests of previously deployed safes + :param owners: Safe owners :param safe_setup_data: Data for proxy setup :param payment_token: If payment token, we will need more gas to transfer and maybe storage if first time :return: total gas needed for deployment """ - base_gas = 205000 # Transaction base gas + base_gas = 250_000 # Transaction base gas - # If we already have the token, we don't have to pay for storage, so it will be just 5K instead of 20K. - # The other 1K is for overhead of making the call + # If we already have the token, we don't have to pay for storage, so it will be just 5K instead of 60K. if payment_token != NULL_ADDRESS: - payment_token_gas = 55000 + payment_token_gas = TOKEN_TRANSFER_GAS else: payment_token_gas = 0 data_gas = GAS_CALL_DATA_BYTE * len(safe_setup_data) # Data gas - gas_per_owner = 20000 # Magic number calculated by testing and averaging owners + gas_per_owner = ( + 25_000 # Magic number calculated by testing and averaging owners + ) return base_gas + data_gas + payment_token_gas + len(owners) * gas_per_owner @staticmethod @@ -234,40 +244,32 @@ def _estimate_gas( payment_receiver: str, ) -> int: """ - Gas estimation done using web3 and calling the node - Payment cannot be estimated, as no ether is in the address. So we add some gas later. + Estimate gas via `eth_estimateGas`. + Payment cannot be estimated, as ether/tokens don't have to be in the calculated Safe address, + so we add some gas later. + :param initializer: Data initializer to send to GnosisSafe setup method :param salt_nonce: Nonce that will be used to generate the salt to calculate the address of the new proxy contract. :return: Total gas estimation """ - # Estimate the contract deployment. We cannot estimate the refunding, as the safe address has not any fund + # Estimate the contract deployment. We cannot estimate the refunding, as the safe address has not any funds gas: int = self.proxy_factory_contract.functions.createProxyWithNonce( self.master_copy_address, initializer, salt_nonce ).estimate_gas() - # It's not very relevant if is 1 or 9999 - payment: int = 1 - # We estimate the refund as a new tx if payment_token == NULL_ADDRESS: # Same cost to send 1 ether than 1000 - gas += self.w3.eth.estimate_gas( - {"to": payment_receiver, "value": Wei(payment)} - ) + gas += self.w3.eth.estimate_gas({"to": payment_receiver, "value": Wei(1)}) else: - # Top should be around 52000 when storage is needed (funder no previous owner of token), - # we use value 1 as we are simulating an internal call, and in that calls you don't pay for the data. - # If it was a new tx sending 5000 tokens would be more expensive than sending 1 because of data costs - gas += 55000 - # try: - # gas += get_erc20_contract(self.w3, - # payment_token).functions.transfer(payment_receiver, - # payment).estimate_gas({'from': - # payment_token}) - # except ValueError as exc: - # raise InvalidERC20Token from exc + # Assume the worst scenario with a regular token transfer without storage + # initialized (payment_receiver no previous owner of token) + gas += 60_000 + + # Add a little more for overhead + gas += 20_000 return gas diff --git a/gnosis/safe/safe_creation_tx.py b/gnosis/safe/safe_creation_tx.py deleted file mode 100644 index 5ee768d9c..000000000 --- a/gnosis/safe/safe_creation_tx.py +++ /dev/null @@ -1,337 +0,0 @@ -import math -import os -from logging import getLogger -from typing import Any, Dict, List, Optional, Tuple - -import rlp -from eth.vm.forks.frontier.transactions import FrontierTransaction -from eth_keys.exceptions import BadSignature -from hexbytes import HexBytes -from web3 import Web3 -from web3.contract import ContractConstructor -from web3.exceptions import Web3Exception - -from gnosis.eth.constants import GAS_CALL_DATA_BYTE, NULL_ADDRESS, SECPK1_N -from gnosis.eth.contracts import ( - get_erc20_contract, - get_paying_proxy_contract, - get_safe_V0_0_1_contract, -) -from gnosis.eth.utils import ( - fast_is_checksum_address, - fast_to_checksum_address, - mk_contract_address, -) - -logger = getLogger(__name__) - - -class InvalidERC20Token(Exception): - pass - - -class SafeCreationTx: - def __init__( - self, - w3: Web3, - owners: List[str], - threshold: int, - signature_s: int, - master_copy: str, - gas_price: int, - funder: Optional[str], - payment_token: Optional[str] = None, - payment_token_eth_value: float = 1.0, - fixed_creation_cost: Optional[int] = None, - ): - """ - Prepare Safe creation - :param w3: Web3 instance - :param owners: Owners of the Safe - :param threshold: Minimum number of users required to operate the Safe - :param signature_s: Random s value for ecdsa signature - :param master_copy: Safe master copy address - :param gas_price: Gas Price - :param funder: Address to refund when the Safe is created. Address(0) if no need to refund - :param payment_token: Payment token instead of paying the funder with ether. If None Ether will be used - :param payment_token_eth_value: Value of payment token per 1 Ether - :param fixed_creation_cost: Fixed creation cost of Safe (Wei) - """ - - assert 0 < threshold <= len(owners) - funder = funder or NULL_ADDRESS - payment_token = payment_token or NULL_ADDRESS - assert fast_is_checksum_address(master_copy) - assert fast_is_checksum_address(funder) - assert fast_is_checksum_address(payment_token) - - self.w3 = w3 - self.owners = owners - self.threshold = threshold - self.s = signature_s - self.master_copy = master_copy - self.gas_price = gas_price - self.funder = funder - self.payment_token = payment_token - self.payment_token_eth_value = payment_token_eth_value - self.fixed_creation_cost = fixed_creation_cost - - # Get bytes for `setup(address[] calldata _owners, uint256 _threshold, address to, bytes calldata data)` - # This initializer will be passed to the proxy and will be called right after proxy is deployed - safe_setup_data: bytes = self._get_initial_setup_safe_data(owners, threshold) - - # Calculate gas based on experience of previous deployments of the safe - calculated_gas: int = self._calculate_gas( - owners, safe_setup_data, payment_token - ) - # Estimate gas using web3 - estimated_gas: int = self._estimate_gas( - master_copy, safe_setup_data, funder, payment_token - ) - self.gas = max(calculated_gas, estimated_gas) - - # Payment will be safe deploy cost + transfer fees for sending ether to the deployer - self.payment = self._calculate_refund_payment( - self.gas, gas_price, fixed_creation_cost, payment_token_eth_value - ) - - self.tx_dict: Dict[str, Any] = self._build_proxy_contract_creation_tx( - master_copy=master_copy, - initializer=safe_setup_data, - funder=funder, - payment_token=payment_token, - payment=self.payment, - gas=self.gas, - gas_price=gas_price, - ) - - self.tx_pyethereum: FrontierTransaction = ( - self._build_contract_creation_tx_with_valid_signature(self.tx_dict, self.s) - ) - self.tx_raw = rlp.encode(self.tx_pyethereum) - self.tx_hash = self.tx_pyethereum.hash - self.deployer_address = fast_to_checksum_address(self.tx_pyethereum.sender) - self.safe_address = mk_contract_address(self.tx_pyethereum.sender, 0) - - self.v = self.tx_pyethereum.v - self.r = self.tx_pyethereum.r - self.safe_setup_data = safe_setup_data - - assert mk_contract_address(self.deployer_address, nonce=0) == self.safe_address - - @property - def payment_ether(self): - return self.gas * self.gas_price - - @staticmethod - def find_valid_random_signature(s: int) -> Tuple[int, int]: - """ - Find v and r valid values for a given s - :param s: random value - :return: v, r - """ - for _ in range(10000): - r = int(os.urandom(31).hex(), 16) - v = (r % 2) + 27 - if r < SECPK1_N: - tx = FrontierTransaction(0, 1, 21000, b"", 0, b"", v=v, r=r, s=s) - try: - tx.sender - return v, r - except (BadSignature, ValueError): - logger.debug("Cannot find signature with v=%d r=%d s=%d", v, r, s) - - raise ValueError("Valid signature not found with s=%d", s) - - @staticmethod - def _calculate_gas( - owners: List[str], safe_setup_data: bytes, payment_token: str - ) -> int: - """ - Calculate gas manually, based on tests of previosly deployed safes - :param owners: Safe owners - :param safe_setup_data: Data for proxy setup - :param payment_token: If payment token, we will need more gas to transfer and maybe storage if first time - :return: total gas needed for deployment - """ - # TODO Do gas calculation estimating the call instead this magic - - base_gas = 60580 # Transaction standard gas - - # If we already have the token, we don't have to pay for storage, so it will be just 5K instead of 20K. - # The other 1K is for overhead of making the call - if payment_token != NULL_ADDRESS: - payment_token_gas = 55000 - else: - payment_token_gas = 0 - - data_gas = GAS_CALL_DATA_BYTE * len(safe_setup_data) # Data gas - gas_per_owner = 18020 # Magic number calculated by testing and averaging owners - return ( - base_gas - + data_gas - + payment_token_gas - + 270000 - + len(owners) * gas_per_owner - ) - - @staticmethod - def _calculate_refund_payment( - gas: int, - gas_price: int, - fixed_creation_cost: Optional[int], - payment_token_eth_value: float, - ) -> int: - if fixed_creation_cost is None: - # Payment will be safe deploy cost + transfer fees for sending ether to the deployer - base_payment: int = (gas + 23000) * gas_price - # Calculate payment for tokens using the conversion (if used) - return math.ceil(base_payment / payment_token_eth_value) - else: - return fixed_creation_cost - - def _build_proxy_contract_creation_constructor( - self, - master_copy: str, - initializer: bytes, - funder: str, - payment_token: str, - payment: int, - ) -> ContractConstructor: - """ - :param master_copy: Master Copy of Gnosis Safe already deployed - :param initializer: Data initializer to send to GnosisSafe setup method - :param funder: Address that should get the payment (if payment set) - :param payment_token: Address if a token is used. If not set, 0x0 will be ether - :param payment: Payment - :return: Transaction dictionary - """ - if not funder or funder == NULL_ADDRESS: - funder = NULL_ADDRESS - payment = 0 - - return get_paying_proxy_contract(self.w3).constructor( - master_copy, initializer, funder, payment_token, payment - ) - - def _build_proxy_contract_creation_tx( - self, - master_copy: str, - initializer: bytes, - funder: str, - payment_token: str, - payment: int, - gas: int, - gas_price: int, - nonce: int = 0, - ): - """ - :param master_copy: Master Copy of Gnosis Safe already deployed - :param initializer: Data initializer to send to GnosisSafe setup method - :param funder: Address that should get the payment (if payment set) - :param payment_token: Address if a token is used. If not set, 0x0 will be ether - :param payment: Payment - :return: Transaction dictionary - """ - return self._build_proxy_contract_creation_constructor( - master_copy, initializer, funder, payment_token, payment - ).build_transaction( - { - "gas": gas, - "gasPrice": gas_price, - "nonce": nonce, - } - ) - - def _build_contract_creation_tx_with_valid_signature( - self, tx_dict: Dict[str, Any], s: int - ) -> FrontierTransaction: - """ - Use pyethereum `Transaction` to generate valid tx using a random signature - :param tx_dict: Web3 tx dictionary - :param s: Signature s value - :return: PyEthereum creation tx for the proxy contract - """ - zero_address = HexBytes("0x" + "0" * 40) - f_address = HexBytes("0x" + "f" * 40) - nonce = tx_dict["nonce"] - gas_price = tx_dict["gasPrice"] - gas = tx_dict["gas"] - to = tx_dict.get("to", b"") # Contract creation should always have `to` empty - value = tx_dict["value"] - data = tx_dict["data"] - for _ in range(100): - try: - v, r = self.find_valid_random_signature(s) - contract_creation_tx = FrontierTransaction( - nonce, gas_price, gas, to, value, HexBytes(data), v=v, r=r, s=s - ) - sender_address = contract_creation_tx.sender - contract_address: bytes = HexBytes( - mk_contract_address(sender_address, nonce) - ) - if sender_address in (zero_address, f_address) or contract_address in ( - zero_address, - f_address, - ): - raise ValueError("Invalid transaction") - return contract_creation_tx - except BadSignature: - pass - raise ValueError("Valid signature not found with s=%d", s) - - def _estimate_gas( - self, master_copy: str, initializer: bytes, funder: str, payment_token: str - ) -> int: - """ - Gas estimation done using web3 and calling the node - Payment cannot be estimated, as no ether is in the address. So we add some gas later. - :param master_copy: Master Copy of Gnosis Safe already deployed - :param initializer: Data initializer to send to GnosisSafe setup method - :param funder: Address that should get the payment (if payment set) - :param payment_token: Address if a token is used. If not set, 0x0 will be ether - :return: Total gas estimation - """ - - # Estimate the contract deployment. We cannot estimate the refunding, as the safe address has not any fund - gas: int = self._build_proxy_contract_creation_constructor( - master_copy, initializer, funder, payment_token, 0 - ).estimate_gas() - - # We estimate the refund as a new tx - if payment_token == NULL_ADDRESS: - # Same cost to send 1 ether than 1000 - gas += self.w3.eth.estimate_gas({"to": funder, "value": 1}) - else: - # Top should be around 52000 when storage is needed (funder no previous owner of token), - # we use value 1 as we are simulating an internal call, and in that calls you don't pay for the data. - # If it was a new tx sending 5000 tokens would be more expensive than sending 1 because of data costs - try: - gas += ( - get_erc20_contract(self.w3, payment_token) - .functions.transfer(funder, 1) - .estimate_gas({"from": payment_token}) - ) - except Web3Exception as exc: - if "transfer amount exceeds balance" in str(exc): - return 70000 - raise InvalidERC20Token from exc - - return gas - - def _get_initial_setup_safe_data(self, owners: List[str], threshold: int) -> bytes: - return ( - get_safe_V0_0_1_contract(self.w3, self.master_copy) - .functions.setup( - owners, - threshold, - NULL_ADDRESS, # Contract address for optional delegate call - b"", # Data payload for optional delegate call - ) - .build_transaction( - { - "gas": 1, - "gasPrice": 1, - } - )["data"] - ) diff --git a/gnosis/safe/safe_tx.py b/gnosis/safe/safe_tx.py index 31a229c21..fe32d6dc8 100644 --- a/gnosis/safe/safe_tx.py +++ b/gnosis/safe/safe_tx.py @@ -2,6 +2,7 @@ from typing import Any, Dict, List, NoReturn, Optional, Tuple, Type from eth_account import Account +from eth_typing import ChecksumAddress from hexbytes import HexBytes from packaging.version import Version from web3.exceptions import Web3Exception @@ -41,19 +42,19 @@ class SafeTx: def __init__( self, ethereum_client: EthereumClient, - safe_address: str, - to: Optional[str], + safe_address: ChecksumAddress, + to: Optional[ChecksumAddress], value: int, data: bytes, operation: int, safe_tx_gas: int, base_gas: int, gas_price: int, - gas_token: Optional[str], - refund_receiver: Optional[str], + gas_token: Optional[ChecksumAddress], + refund_receiver: Optional[ChecksumAddress], signatures: Optional[bytes] = None, safe_nonce: Optional[int] = None, - safe_version: str = None, + safe_version: Optional[str] = None, chain_id: Optional[int] = None, ): """ diff --git a/gnosis/safe/tests/safe_test_case.py b/gnosis/safe/tests/safe_test_case.py index 2ffd70297..3a98020ba 100644 --- a/gnosis/safe/tests/safe_test_case.py +++ b/gnosis/safe/tests/safe_test_case.py @@ -19,23 +19,24 @@ from gnosis.eth.tests.ethereum_test_case import EthereumTestCaseMixin from gnosis.safe import Safe from gnosis.safe.multi_send import MultiSend -from gnosis.safe.proxy_factory import ProxyFactory +from gnosis.safe.proxy_factory import ProxyFactory, ProxyFactoryV111, ProxyFactoryV130 from gnosis.safe.safe_create2_tx import SafeCreate2Tx from ...eth.constants import NULL_ADDRESS +from ..safe import SafeV001, SafeV100, SafeV111, SafeV130 from .utils import generate_salt_nonce logger = logging.getLogger(__name__) _contract_addresses = { - "safe_V0_0_1": Safe.deploy_master_contract_v0_0_1, - "safe_V1_0_0": Safe.deploy_master_contract_v1_0_0, - "safe_V1_1_1": Safe.deploy_master_contract_v1_1_1, - "safe_v1_3_0": Safe.deploy_master_contract_v1_3_0, + "safe_V0_0_1": SafeV001.deploy_contract, + "safe_V1_0_0": SafeV100.deploy_contract, + "safe_V1_1_1": SafeV111.deploy_contract, + "safe_v1_3_0": SafeV130.deploy_contract, "compatibility_fallback_handler": Safe.deploy_compatibility_fallback_handler, - "proxy_factory": ProxyFactory.deploy_proxy_factory_contract, - "proxy_factory_V1_0_0": ProxyFactory.deploy_proxy_factory_contract_v1_0_0, + "proxy_factory": ProxyFactoryV130.deploy_contract, + "proxy_factory_V1_0_0": ProxyFactoryV111.deploy_contract, "multi_send": MultiSend.deploy_contract, } diff --git a/gnosis/safe/tests/test_proxy_factory/__init__.py b/gnosis/safe/tests/test_proxy_factory/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/gnosis/safe/tests/test_proxy_factory.py b/gnosis/safe/tests/test_proxy_factory/test_proxy_factory.py similarity index 56% rename from gnosis/safe/tests/test_proxy_factory.py rename to gnosis/safe/tests/test_proxy_factory/test_proxy_factory.py index b7d7327e0..66073a858 100644 --- a/gnosis/safe/tests/test_proxy_factory.py +++ b/gnosis/safe/tests/test_proxy_factory/test_proxy_factory.py @@ -5,12 +5,22 @@ from eth_account import Account from gnosis.eth import EthereumClient +from gnosis.eth.contracts import ( + get_proxy_1_0_0_deployed_bytecode, + get_proxy_1_1_1_deployed_bytecode, + get_proxy_1_3_0_deployed_bytecode, +) from gnosis.eth.tests.utils import just_test_if_mainnet_node +from gnosis.eth.utils import compare_byte_code from gnosis.safe import Safe - -from ..proxy_factory import ProxyFactory -from .safe_test_case import SafeTestCaseMixin -from .utils import generate_salt_nonce +from gnosis.safe.proxy_factory import ( + ProxyFactory, + ProxyFactoryV100, + ProxyFactoryV111, + ProxyFactoryV130, +) +from gnosis.safe.tests.safe_test_case import SafeTestCaseMixin +from gnosis.safe.tests.utils import generate_salt_nonce logger = logging.getLogger(__name__) @@ -37,34 +47,38 @@ def test_check_proxy_code(self): self.assertTrue( self.proxy_factory.check_proxy_code(ethereum_tx_sent.contract_address) ) - - # Test proxy factory v1.1.1 - ethereum_tx_sent = ProxyFactory.deploy_proxy_factory_contract_v1_1_1( - self.ethereum_client, self.ethereum_test_account - ) - proxy_factory_V1_1_1 = ProxyFactory( - ethereum_tx_sent.contract_address, self.ethereum_client - ) - ethereum_tx_sent = proxy_factory_V1_1_1.deploy_proxy_contract( - self.ethereum_test_account, self.safe_contract_address - ) - self.assertTrue( - self.proxy_factory.check_proxy_code(ethereum_tx_sent.contract_address) - ) - - # Test proxy factory v1.0.0 - ethereum_tx_sent = ProxyFactory.deploy_proxy_factory_contract_v1_0_0( - self.ethereum_client, self.ethereum_test_account - ) - proxy_factory_V1_0_0 = ProxyFactory( - ethereum_tx_sent.contract_address, self.ethereum_client - ) - ethereum_tx_sent = proxy_factory_V1_0_0.deploy_proxy_contract( - self.ethereum_test_account, self.safe_contract_address - ) - self.assertTrue( - self.proxy_factory.check_proxy_code(ethereum_tx_sent.contract_address) - ) + # Test every version + versions = [ + ("1.0.0", ProxyFactoryV100, get_proxy_1_0_0_deployed_bytecode), + ("1.1.1", ProxyFactoryV111, get_proxy_1_1_1_deployed_bytecode), + ("1.3.0", ProxyFactoryV130, get_proxy_1_3_0_deployed_bytecode), + ] + for version, ProxyFactoryVersion, get_proxy_deployed_bytecode_fn in versions: + with self.subTest(version=version): + deployed_proxy_tx = ProxyFactoryVersion.deploy_contract( + self.ethereum_client, self.ethereum_test_account + ) + proxy_factory = ProxyFactory( + deployed_proxy_tx.contract_address, + self.ethereum_client, + version=version, + ) + deployed_proxy_contract_tx = proxy_factory.deploy_proxy_contract( + self.ethereum_test_account, self.safe_contract_address + ) + self.assertTrue( + proxy_factory.check_proxy_code( + deployed_proxy_contract_tx.contract_address + ) + ) + self.assertTrue( + compare_byte_code( + get_proxy_deployed_bytecode_fn(), + self.w3.eth.get_code( + deployed_proxy_contract_tx.contract_address + ), + ) + ) def test_check_proxy_code_mainnet(self): mainnet_node = just_test_if_mainnet_node() @@ -84,47 +98,6 @@ def test_check_proxy_code_mainnet(self): with self.subTest(safe=safe): self.assertTrue(proxy_factory.check_proxy_code(safe)) - def test_deploy_proxy_contract(self): - s = 15 - owners = [Account.create().address for _ in range(2)] - threshold = 2 - payment_token = None - safe_creation_tx = Safe.build_safe_creation_tx( - self.ethereum_client, - self.safe_contract_V0_0_1_address, - s, - owners, - threshold, - self.gas_price, - payment_token, - payment_receiver=self.ethereum_test_account.address, - ) - # Send ether for safe deploying costs - self.send_tx( - {"to": safe_creation_tx.safe_address, "value": safe_creation_tx.payment}, - self.ethereum_test_account, - ) - - proxy_factory = ProxyFactory( - self.proxy_factory_contract_address, self.ethereum_client - ) - ethereum_tx_sent = proxy_factory.deploy_proxy_contract( - self.ethereum_test_account, - safe_creation_tx.master_copy, - safe_creation_tx.safe_setup_data, - safe_creation_tx.gas, - gas_price=self.gas_price, - ) - receipt = self.ethereum_client.get_transaction_receipt( - ethereum_tx_sent.tx_hash, timeout=20 - ) - self.assertEqual(receipt.status, 1) - safe = Safe(ethereum_tx_sent.contract_address, self.ethereum_client) - self.assertEqual( - safe.retrieve_master_copy_address(), safe_creation_tx.master_copy - ) - self.assertEqual(set(safe.retrieve_owners()), set(owners)) - def test_deploy_proxy_contract_with_nonce(self): salt_nonce = generate_salt_nonce() owners = [Account.create().address for _ in range(2)] diff --git a/gnosis/safe/tests/test_safe.py b/gnosis/safe/tests/test_safe.py index bad58fb95..552fac0f6 100644 --- a/gnosis/safe/tests/test_safe.py +++ b/gnosis/safe/tests/test_safe.py @@ -17,7 +17,7 @@ CouldNotPayGasWithToken, InvalidInternalTx, ) -from ..safe import Safe, SafeOperation +from ..safe import Safe, SafeOperation, SafeV100, SafeV111, SafeV130 from ..signatures import signature_to_bytes, signatures_to_bytes from .safe_test_case import SafeTestCaseMixin @@ -63,22 +63,6 @@ def test_check_funds_for_tx_gas(self): safe.check_funds_for_tx_gas(safe_tx_gas, base_gas, gas_price, NULL_ADDRESS) ) - def test_estimate_safe_creation(self): - number_owners = 4 - gas_price = self.gas_price - payment_token = NULL_ADDRESS - safe_creation_estimate = Safe.estimate_safe_creation( - self.ethereum_client, - self.safe_contract_V0_0_1_address, - number_owners, - gas_price, - payment_token, - ) - self.assertGreater(safe_creation_estimate.gas_price, 0) - self.assertGreater(safe_creation_estimate.gas, 0) - self.assertGreater(safe_creation_estimate.payment, 0) - self.assertEqual(safe_creation_estimate.payment_token, payment_token) - def test_estimate_safe_creation_2(self): number_owners = 4 gas_price = self.gas_price @@ -549,12 +533,6 @@ def test_estimate_tx_gas_with_web3(self): ).build_transaction({"gas": 0})["data"] safe.estimate_tx_gas_with_web3(deployed_erc20.address, value, transfer_data) - def test_estimate_tx_operational_gas(self): - for threshold in range(2, 5): - safe = self.deploy_test_safe(threshold=threshold, number_owners=6) - tx_signature_gas_estimation = safe.estimate_tx_operational_gas(0) - self.assertGreaterEqual(tx_signature_gas_estimation, 20000) - def test_retrieve_code(self): self.assertEqual( Safe(NULL_ADDRESS, self.ethereum_client).retrieve_code(), HexBytes("0x") @@ -634,6 +612,16 @@ def test_retrieve_all_info(self): ): invalid_safe.retrieve_all_info() + def test_safe_instance(self): + owners = [Account.create().address for _ in range(2)] + threshold = 2 + safe_v1_0_0 = self.deploy_test_safe_v1_0_0(owners=owners, threshold=threshold) + self.assertTrue(isinstance(safe_v1_0_0, SafeV100)) + safe_v1_1_1 = self.deploy_test_safe_v1_1_1(owners=owners, threshold=threshold) + self.assertTrue(isinstance(safe_v1_1_1, SafeV111)) + safe_v1_3_0 = self.deploy_test_safe(owners=owners, threshold=threshold) + self.assertTrue(isinstance(safe_v1_3_0, SafeV130)) + def test_retrieve_modules(self): safe = self.deploy_test_safe(owners=[self.ethereum_test_account.address]) safe_contract = safe.contract diff --git a/gnosis/safe/tests/test_safe_create2_tx.py b/gnosis/safe/tests/test_safe_create2_tx.py index fed84d6a8..fe04e4dc4 100644 --- a/gnosis/safe/tests/test_safe_create2_tx.py +++ b/gnosis/safe/tests/test_safe_create2_tx.py @@ -15,7 +15,7 @@ LOG_TITLE_WIDTH = 100 -class TestSafeCreationTx(SafeTestCaseMixin, TestCase): +class TestSafeCreation2Tx(SafeTestCaseMixin, TestCase): def test_safe_create2_tx_builder(self): w3 = self.w3 diff --git a/gnosis/safe/tests/test_safe_creation_tx.py b/gnosis/safe/tests/test_safe_creation_tx.py deleted file mode 100644 index 1316c388e..000000000 --- a/gnosis/safe/tests/test_safe_creation_tx.py +++ /dev/null @@ -1,480 +0,0 @@ -import logging - -from django.test import TestCase - -from eth_account import Account - -from gnosis.eth.constants import NULL_ADDRESS -from gnosis.eth.contracts import get_safe_contract -from gnosis.eth.utils import get_eth_address_with_key - -from ..safe_creation_tx import SafeCreationTx -from .safe_test_case import SafeTestCaseMixin -from .utils import generate_valid_s - -logger = logging.getLogger(__name__) - -LOG_TITLE_WIDTH = 100 - - -class TestSafeCreationTx(SafeTestCaseMixin, TestCase): - def test_safe_creation_tx_builder(self): - logger.info( - "Test Safe Proxy creation without payment".center(LOG_TITLE_WIDTH, "-") - ) - w3 = self.w3 - - s = generate_valid_s() - - funder_account = self.ethereum_test_account - owners = [get_eth_address_with_key()[0] for _ in range(4)] - threshold = len(owners) - 1 - gas_price = self.gas_price - - safe_creation_tx = SafeCreationTx( - w3=w3, - owners=owners, - threshold=threshold, - signature_s=s, - master_copy=self.safe_contract_V0_0_1_address, - gas_price=gas_price, - funder=NULL_ADDRESS, - ) - - logger.info( - "Send %d gwei to deployer %s", - w3.from_wei(safe_creation_tx.payment_ether, "gwei"), - safe_creation_tx.deployer_address, - ) - - self.send_tx( - { - "to": safe_creation_tx.deployer_address, - "value": safe_creation_tx.payment_ether, - }, - funder_account, - ) - - logger.info( - "Create proxy contract with address %s", safe_creation_tx.safe_address - ) - - tx_hash = w3.eth.send_raw_transaction(safe_creation_tx.tx_raw) - tx_receipt = w3.eth.wait_for_transaction_receipt(tx_hash) - self.assertEqual(tx_receipt.contractAddress, safe_creation_tx.safe_address) - - deployed_safe_proxy_contract = get_safe_contract(w3, tx_receipt.contractAddress) - - logger.info( - "Deployer account has still %d gwei left (will be lost)", - w3.from_wei(w3.eth.get_balance(safe_creation_tx.deployer_address), "gwei"), - ) - - self.assertEqual( - deployed_safe_proxy_contract.functions.getThreshold().call(), threshold - ) - self.assertEqual( - deployed_safe_proxy_contract.functions.getOwners().call(), owners - ) - - def test_safe_creation_tx_builder_with_not_enough_funds(self): - w3 = self.w3 - s = generate_valid_s() - funder_account = self.ethereum_test_account - owners = [get_eth_address_with_key()[0] for _ in range(4)] - threshold = len(owners) - 1 - gas_price = self.gas_price - - safe_creation_tx = SafeCreationTx( - w3=w3, - owners=owners, - threshold=threshold, - signature_s=s, - master_copy=self.safe_contract_V0_0_1_address, - gas_price=gas_price, - funder=NULL_ADDRESS, - ) - - logger.info( - "Send %d gwei to deployer %s", - w3.from_wei(safe_creation_tx.payment_ether - 1, "gwei"), - safe_creation_tx.deployer_address, - ) - self.send_tx( - { - "to": safe_creation_tx.deployer_address, - "value": safe_creation_tx.payment_ether - 1, - }, - funder_account, - ) - - with self.assertRaisesMessage(ValueError, "insufficient funds for gas"): - w3.eth.send_raw_transaction(safe_creation_tx.tx_raw) - - def test_safe_creation_tx_builder_with_payment(self): - logger.info( - "Test Safe Proxy creation With Payment".center(LOG_TITLE_WIDTH, "-") - ) - w3 = self.w3 - - s = generate_valid_s() - - funder_account = self.ethereum_test_account - owners = [get_eth_address_with_key()[0] for _ in range(2)] - threshold = len(owners) - 1 - gas_price = self.gas_price - - safe_creation_tx = SafeCreationTx( - w3=w3, - owners=owners, - threshold=threshold, - signature_s=s, - master_copy=self.safe_contract_V0_0_1_address, - gas_price=gas_price, - funder=funder_account.address, - ) - - user_external_account = Account.create() - # Send some ether to that account - safe_balance = w3.to_wei(0.01, "ether") - self.send_tx( - {"to": user_external_account.address, "value": safe_balance * 2}, - funder_account, - ) - - logger.info( - "Send %d ether to safe %s", - w3.from_wei(safe_balance, "ether"), - safe_creation_tx.safe_address, - ) - self.send_tx( - {"to": safe_creation_tx.safe_address, "value": safe_balance}, - user_external_account, - ) - self.assertEqual( - w3.eth.get_balance(safe_creation_tx.safe_address), safe_balance - ) - - logger.info( - "Send %d gwei to deployer %s", - w3.from_wei(safe_creation_tx.payment_ether, "gwei"), - safe_creation_tx.deployer_address, - ) - self.send_tx( - { - "to": safe_creation_tx.deployer_address, - "value": safe_creation_tx.payment_ether, - }, - funder_account, - ) - - logger.info( - "Create proxy contract with address %s", safe_creation_tx.safe_address - ) - - funder_balance = w3.eth.get_balance(funder_account.address) - - # This tx will create the Safe Proxy and return ether to the funder - tx_hash = w3.eth.send_raw_transaction(safe_creation_tx.tx_raw) - tx_receipt = w3.eth.wait_for_transaction_receipt(tx_hash) - self.assertEqual(tx_receipt.contractAddress, safe_creation_tx.safe_address) - - self.assertEqual( - w3.eth.get_balance(funder_account.address), - funder_balance + safe_creation_tx.payment, - ) - - logger.info( - "Deployer account has still %d gwei left (will be lost)", - w3.from_wei(w3.eth.get_balance(safe_creation_tx.deployer_address), "gwei"), - ) - - deployed_safe_proxy_contract = get_safe_contract(w3, tx_receipt.contractAddress) - - self.assertEqual( - deployed_safe_proxy_contract.functions.getThreshold().call(), threshold - ) - self.assertEqual( - deployed_safe_proxy_contract.functions.getOwners().call(), owners - ) - - def test_safe_creation_tx_builder_with_token_payment(self): - logger.info( - "Test Safe Proxy creation With Gas Payment".center(LOG_TITLE_WIDTH, "-") - ) - w3 = self.w3 - - s = generate_valid_s() - - erc20_deployer = Account.create() - funder_account = self.ethereum_test_account - - # Send something to the erc20 deployer - self.send_tx( - {"to": erc20_deployer.address, "value": w3.to_wei(1, "ether")}, - funder_account, - ) - - funder = funder_account.address - owners = [get_eth_address_with_key()[0] for _ in range(2)] - threshold = len(owners) - 1 - gas_price = self.gas_price - token_amount = int(1e18) - erc20_contract = self.deploy_example_erc20(token_amount, erc20_deployer.address) - self.assertEqual( - erc20_contract.functions.balanceOf(erc20_deployer.address).call(), - token_amount, - ) - - safe_creation_tx = SafeCreationTx( - w3=w3, - owners=owners, - threshold=threshold, - signature_s=s, - master_copy=self.safe_contract_V0_0_1_address, - gas_price=gas_price, - payment_token=erc20_contract.address, - funder=funder, - ) - - # In this test we will pretend that ether value = token value, so we send tokens as ether payment - payment = safe_creation_tx.payment - deployer_address = safe_creation_tx.deployer_address - safe_address = safe_creation_tx.safe_address - logger.info("Send %d tokens to safe %s", payment, safe_address) - self.send_tx( - erc20_contract.functions.transfer(safe_address, payment).build_transaction( - {"from": erc20_deployer.address} - ), - erc20_deployer, - ) - self.assertEqual( - erc20_contract.functions.balanceOf(safe_address).call(), payment - ) - - logger.info( - "Send %d ether to deployer %s", - w3.from_wei(payment, "ether"), - deployer_address, - ) - self.send_tx( - { - "to": safe_creation_tx.deployer_address, - "value": safe_creation_tx.payment, - }, - funder_account, - ) - - logger.info( - "Create proxy contract with address %s", safe_creation_tx.safe_address - ) - funder_balance = w3.eth.get_balance(funder) - - # This tx will create the Safe Proxy and return tokens to the funder - tx_hash = w3.eth.send_raw_transaction(safe_creation_tx.tx_raw) - tx_receipt = w3.eth.wait_for_transaction_receipt(tx_hash) - self.assertEqual(tx_receipt.contractAddress, safe_address) - self.assertEqual(w3.eth.get_balance(funder), funder_balance) - self.assertEqual(erc20_contract.functions.balanceOf(funder).call(), payment) - self.assertEqual(erc20_contract.functions.balanceOf(safe_address).call(), 0) - - logger.info( - "Deployer account has still %d gwei left (will be lost)", - w3.from_wei(w3.eth.get_balance(safe_creation_tx.deployer_address), "gwei"), - ) - - deployed_safe_proxy_contract = get_safe_contract(w3, tx_receipt.contractAddress) - - self.assertEqual( - deployed_safe_proxy_contract.functions.getThreshold().call(), threshold - ) - self.assertEqual( - deployed_safe_proxy_contract.functions.getOwners().call(), owners - ) - - # Payment should be <= when payment_token_eth_value is 1.0 - # Funder will already have tokens so no storage need to be paid) - safe_creation_tx_2 = SafeCreationTx( - w3=w3, - owners=owners, - threshold=threshold, - signature_s=s, - master_copy=self.safe_contract_V0_0_1_address, - gas_price=gas_price, - payment_token=erc20_contract.address, - payment_token_eth_value=1.0, - funder=funder, - ) - self.assertLessEqual(safe_creation_tx_2.payment, safe_creation_tx.payment) - - # Now payment should be equal when payment_token_eth_value is 1.0 - safe_creation_tx_3 = SafeCreationTx( - w3=w3, - owners=owners, - threshold=threshold, - signature_s=s, - master_copy=self.safe_contract_V0_0_1_address, - gas_price=gas_price, - payment_token=erc20_contract.address, - payment_token_eth_value=1.0, - funder=funder, - ) - self.assertEqual(safe_creation_tx_3.payment, safe_creation_tx_2.payment) - - # Check that payment is less when payment_token_eth_value is set(token value > ether) - safe_creation_tx_4 = SafeCreationTx( - w3=w3, - owners=owners, - threshold=threshold, - signature_s=s, - master_copy=self.safe_contract_V0_0_1_address, - gas_price=gas_price, - payment_token=erc20_contract.address, - payment_token_eth_value=1.1, - funder=funder, - ) - self.assertLess(safe_creation_tx_4.payment, safe_creation_tx.payment) - - # Check that payment is more when payment_token_eth_value is set(token value < ether) - safe_creation_tx_5 = SafeCreationTx( - w3=w3, - owners=owners, - threshold=threshold, - signature_s=s, - master_copy=self.safe_contract_V0_0_1_address, - gas_price=gas_price, - payment_token=erc20_contract.address, - payment_token_eth_value=0.1, - funder=funder, - ) - self.assertGreater(safe_creation_tx_5.payment, safe_creation_tx.payment) - - def test_safe_creation_tx_builder_with_fixed_cost(self): - logger.info( - "Test Safe Proxy creation With Fixed Cost".center(LOG_TITLE_WIDTH, "-") - ) - w3 = self.w3 - - s = generate_valid_s() - - funder_account = self.ethereum_test_account - owners = [get_eth_address_with_key()[0] for _ in range(2)] - threshold = len(owners) - 1 - gas_price = self.gas_price - fixed_creation_cost = 123 # Wei - - safe_creation_tx = SafeCreationTx( - w3=w3, - owners=owners, - threshold=threshold, - signature_s=s, - master_copy=self.safe_contract_V0_0_1_address, - gas_price=gas_price, - payment_token=None, - funder=funder_account.address, - fixed_creation_cost=fixed_creation_cost, - ) - - self.assertEqual(safe_creation_tx.payment, fixed_creation_cost) - self.assertEqual( - safe_creation_tx.payment_ether, - safe_creation_tx.gas * safe_creation_tx.gas_price, - ) - - deployer_address = safe_creation_tx.deployer_address - safe_address = safe_creation_tx.safe_address - safe_balance = w3.to_wei(0.01, "ether") - logger.info( - "Send %d ether to safe %s", w3.from_wei(safe_balance, "ether"), safe_address - ) - self.send_tx({"to": safe_address, "value": safe_balance}, funder_account) - self.assertEqual(w3.eth.get_balance(safe_address), safe_balance) - - logger.info( - "Send %d ether to deployer %s", - w3.from_wei(safe_creation_tx.payment_ether, "ether"), - deployer_address, - ) - self.send_tx( - {"to": deployer_address, "value": safe_creation_tx.payment_ether}, - funder_account, - ) - - logger.info( - "Create proxy contract with address %s", safe_creation_tx.safe_address - ) - - funder_balance = w3.eth.get_balance(funder_account.address) - - # This tx will create the Safe Proxy and return tokens to the funder - tx_hash = w3.eth.send_raw_transaction(safe_creation_tx.tx_raw) - tx_receipt = w3.eth.wait_for_transaction_receipt(tx_hash) - self.assertEqual(tx_receipt.contractAddress, safe_address) - self.assertEqual( - w3.eth.get_balance(safe_address), safe_balance - fixed_creation_cost - ) - self.assertLess( - w3.eth.get_balance(deployer_address), safe_creation_tx.payment_ether - ) - self.assertEqual( - w3.eth.get_balance(funder_account.address), - funder_balance + safe_creation_tx.payment, - ) - - logger.info( - "Deployer account has still %d gwei left (will be lost)", - w3.from_wei(w3.eth.get_balance(safe_creation_tx.deployer_address), "gwei"), - ) - - deployed_safe_proxy_contract = get_safe_contract(w3, safe_address) - - self.assertEqual( - deployed_safe_proxy_contract.functions.getThreshold().call(), threshold - ) - self.assertEqual( - deployed_safe_proxy_contract.functions.getOwners().call(), owners - ) - - def test_safe_gas_with_multiple_owners(self): - logger.info( - "Test Safe Proxy creation gas with multiple owners".center( - LOG_TITLE_WIDTH, "-" - ) - ) - w3 = self.w3 - funder_account = self.ethereum_test_account - number_of_accounts = 10 - for i in range(2, number_of_accounts): - s = generate_valid_s() - owners = [get_eth_address_with_key()[0] for _ in range(i + 1)] - threshold = len(owners) - gas_price = w3.to_wei(15, "gwei") - - safe_creation_tx = SafeCreationTx( - w3=w3, - owners=owners, - threshold=threshold, - signature_s=s, - master_copy=self.safe_contract_V0_0_1_address, - gas_price=gas_price, - funder=None, - ) - - self.send_tx( - { - "to": safe_creation_tx.deployer_address, - "value": safe_creation_tx.payment, - }, - funder_account, - ) - tx_hash = w3.eth.send_raw_transaction(safe_creation_tx.tx_raw) - tx_receipt = w3.eth.wait_for_transaction_receipt(tx_hash) - self.assertEqual(tx_receipt.contractAddress, safe_creation_tx.safe_address) - - logger.info( - "Number of owners: %d - Gas estimated %d - Gas Used %d - Difference %d", - len(owners), - safe_creation_tx.gas, - tx_receipt.gasUsed, - safe_creation_tx.gas - tx_receipt.gasUsed, - ) diff --git a/gnosis/safe/tests/utils.py b/gnosis/safe/tests/utils.py index 2c5489181..cd623872a 100644 --- a/gnosis/safe/tests/utils.py +++ b/gnosis/safe/tests/utils.py @@ -2,12 +2,7 @@ import random from logging import getLogger -from web3 import Web3 - -from gnosis.eth.tests.utils import send_tx - -from ...eth.constants import SECPK1_N -from ..safe_creation_tx import SafeCreationTx +from gnosis.eth.constants import SECPK1_N logger = getLogger(__name__) @@ -21,57 +16,3 @@ def generate_valid_s() -> int: s = int(os.urandom(30).hex(), 16) if s <= (SECPK1_N // 2): return s - - -def deploy_safe( - w3: Web3, - safe_creation_tx: SafeCreationTx, - funder: str, - initial_funding_wei: int = 0, - funder_account=None, -) -> str: - if funder_account: - send_tx( - w3, - { - "to": safe_creation_tx.deployer_address, - "value": safe_creation_tx.payment, - }, - funder_account, - ) - - send_tx( - w3, - { - "to": safe_creation_tx.safe_address, - "value": safe_creation_tx.payment + initial_funding_wei, - }, - funder_account, - ) - else: - w3.eth.wait_for_transaction_receipt( - w3.eth.send_transaction( - { - "from": funder, - "to": safe_creation_tx.deployer_address, - "value": safe_creation_tx.payment, - } - ) - ) - - w3.eth.wait_for_transaction_receipt( - w3.eth.send_transaction( - { - "from": funder, - "to": safe_creation_tx.safe_address, - "value": safe_creation_tx.payment + initial_funding_wei, - } - ) - ) - - tx_hash = w3.eth.send_raw_transaction(bytes(safe_creation_tx.tx_raw)) - tx_receipt = w3.eth.wait_for_transaction_receipt(tx_hash) - assert tx_receipt.contractAddress == safe_creation_tx.safe_address - assert tx_receipt.status - - return safe_creation_tx.safe_address