From 72b7ed6daded3aa98cd536eb157551365f903a09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ux=C3=ADo?= Date: Tue, 12 Mar 2024 17:51:40 +0100 Subject: [PATCH] Support Safe Singleton Factory (#654) - Add deterministic contract deployment - Refactor testing Optimizes testing: - Speed up tests, as it's easy to check if contracts are deployed - Less confusing, as contract addresses will be the same to production environments --- gnosis/eth/constants.py | 7 ++ gnosis/eth/ethereum_client.py | 61 +++++++-- gnosis/eth/exceptions.py | 9 ++ gnosis/eth/multicall.py | 34 ++--- gnosis/eth/tests/ethereum_test_case.py | 5 +- gnosis/safe/tests/safe_test_case.py | 118 +++++++++++++----- .../test_proxy_factory/test_proxy_factory.py | 17 ++- 7 files changed, 189 insertions(+), 62 deletions(-) diff --git a/gnosis/eth/constants.py b/gnosis/eth/constants.py index 68b0a9a62..2a19cfad0 100644 --- a/gnosis/eth/constants.py +++ b/gnosis/eth/constants.py @@ -6,6 +6,13 @@ HexAddress(HexStr("0x" + "0" * 39 + "1")) ) +SAFE_SINGLETON_FACTORY_DEPLOYER_ADDRESS: ChecksumAddress = ChecksumAddress( + HexAddress(HexStr("0xE1CB04A0fA36DdD16a06ea828007E35e1a3cBC37")) +) +SAFE_SINGLETON_FACTORY_ADDRESS: ChecksumAddress = ChecksumAddress( + HexAddress(HexStr("0x914d7Fec6aaC8cd542e72Bca78B30650d45643d7")) +) + # keccak('Transfer(address,address,uint256)') ERC20_721_TRANSFER_TOPIC: HexStr = HexStr( "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" diff --git a/gnosis/eth/ethereum_client.py b/gnosis/eth/ethereum_client.py index f70b09d2d..74428781d 100644 --- a/gnosis/eth/ethereum_client.py +++ b/gnosis/eth/ethereum_client.py @@ -57,6 +57,7 @@ fast_is_checksum_address, fast_to_checksum_address, mk_contract_address, + mk_contract_address_2, ) from gnosis.util import cache, chunks @@ -66,12 +67,14 @@ GAS_CALL_DATA_BYTE, GAS_CALL_DATA_ZERO_BYTE, NULL_ADDRESS, + SAFE_SINGLETON_FACTORY_ADDRESS, ) from .contracts import get_erc20_contract, get_erc721_contract from .ethereum_network import EthereumNetwork, EthereumNetworkNotSupported from .exceptions import ( BatchCallFunctionFailed, ChainIdIsRequired, + ContractAlreadyDeployed, FromAddressNotFound, GasLimitExceeded, InsufficientFunds, @@ -1325,6 +1328,23 @@ def get_network(self) -> EthereumNetwork: """ return EthereumNetwork(self.get_chain_id()) + @cache + def get_singleton_factory_address(self) -> Optional[ChecksumAddress]: + """ + Get singleton factory address if available. Try the singleton managed by Safe by default unless + SAFE_SINGLETON_FACTORY_ADDRESS environment variable is defined. + + More info: https://github.com/safe-global/safe-singleton-factory + + :return: Get singleton factory address if available + """ + address = os.environ.get( + "SAFE_SINGLETON_FACTORY_ADDRESS", SAFE_SINGLETON_FACTORY_ADDRESS + ) + if self.is_contract(address): + return address + return None + @cache def is_eip1559_supported(self) -> bool: """ @@ -1430,14 +1450,25 @@ def batch_call_same_function( def deploy_and_initialize_contract( self, deployer_account: LocalAccount, - constructor_data: bytes, - initializer_data: bytes = b"", + constructor_data: Union[bytes, HexStr], + initializer_data: Optional[Union[bytes, HexStr]] = None, check_receipt: bool = True, + deterministic: bool = True, ) -> EthereumTxSent: + """ + + :param deployer_account: + :param constructor_data: + :param initializer_data: + :param check_receipt: + :param deterministic: Use Safe singleton factory for CREATE2 deterministic deployment + :return: + """ contract_address: Optional[ChecksumAddress] = None for data in (constructor_data, initializer_data): # Because initializer_data is not mandatory if data: + data = HexBytes(data) tx: TxParams = { "from": deployer_account.address, "data": data, @@ -1445,7 +1476,28 @@ def deploy_and_initialize_contract( "value": Wei(0), "to": contract_address if contract_address else "", "chainId": self.get_chain_id(), + "nonce": self.get_nonce_for_account(deployer_account.address), } + if not contract_address: + if deterministic and ( + singleton_factory_address := self.get_singleton_factory_address() + ): + salt = HexBytes("0" * 64) + tx["data"] = ( + salt + data + ) # Add 32 bytes salt for singleton factory + tx["to"] = singleton_factory_address + contract_address = mk_contract_address_2( + singleton_factory_address, salt, data + ) + if self.is_contract(contract_address): + raise ContractAlreadyDeployed( + f"Contract {contract_address} already deployed", + contract_address, + ) + else: + contract_address = mk_contract_address(tx["from"], tx["nonce"]) + tx["gas"] = self.w3.eth.estimate_gas(tx) tx_hash = self.send_unsigned_transaction( tx, private_key=deployer_account.key @@ -1457,11 +1509,6 @@ def deploy_and_initialize_contract( assert tx_receipt assert tx_receipt["status"] - if not contract_address: - contract_address = ChecksumAddress( - mk_contract_address(tx["from"], tx["nonce"]) - ) - return EthereumTxSent(tx_hash, tx, contract_address) def get_nonce_for_account( diff --git a/gnosis/eth/exceptions.py b/gnosis/eth/exceptions.py index b4ed22ad8..d302d4ab7 100644 --- a/gnosis/eth/exceptions.py +++ b/gnosis/eth/exceptions.py @@ -1,3 +1,6 @@ +from eth_typing import ChecksumAddress + + class EthereumClientException(ValueError): pass @@ -54,6 +57,12 @@ class TransactionGasPriceTooLow(EthereumClientException): pass +class ContractAlreadyDeployed(EthereumClientException): + def __init__(self, message: str, address: ChecksumAddress): + super().__init__(message) + self.address = address + + class InvalidERC20Info(EthereumClientException): pass diff --git a/gnosis/eth/multicall.py b/gnosis/eth/multicall.py index 861b4430a..eb12a6967 100644 --- a/gnosis/eth/multicall.py +++ b/gnosis/eth/multicall.py @@ -20,7 +20,7 @@ from . import EthereumClient, EthereumNetwork, EthereumNetworkNotSupported from .contracts import ContractBase, get_multicall_v3_contract from .ethereum_client import EthereumTxSent -from .exceptions import BatchCallFunctionFailed +from .exceptions import BatchCallFunctionFailed, ContractAlreadyDeployed from .utils import get_empty_tx_params logger = logging.getLogger(__name__) @@ -92,13 +92,13 @@ def get_contract_fn(self) -> Callable[[Web3, Optional[ChecksumAddress]], Contrac @classmethod def deploy_contract( cls, ethereum_client: EthereumClient, deployer_account: LocalAccount - ) -> EthereumTxSent: + ) -> Optional[EthereumTxSent]: """ Deploy contract :param ethereum_client: :param deployer_account: Ethereum Account - :return: ``EthereumTxSent`` with the deployed contract address + :return: ``EthereumTxSent`` with the deployed contract address, ``None`` if already deployed """ contract_fn = cls.get_contract_fn(cls) contract = contract_fn(ethereum_client.w3) @@ -106,19 +106,23 @@ def deploy_contract( get_empty_tx_params() )["data"] - ethereum_tx_sent = ethereum_client.deploy_and_initialize_contract( - deployer_account, constructor_data - ) + try: + ethereum_tx_sent = ethereum_client.deploy_and_initialize_contract( + deployer_account, constructor_data + ) - contract_address = ethereum_tx_sent.contract_address - logger.info( - "Deployed Multicall V2 Contract %s by %s", - contract_address, - deployer_account.address, - ) - # Add address to addresses dictionary - cls.ADDRESSES[ethereum_client.get_network()] = contract_address - return ethereum_tx_sent + contract_address = ethereum_tx_sent.contract_address + logger.info( + "Deployed Multicall V2 Contract %s by %s", + contract_address, + deployer_account.address, + ) + # Add address to addresses dictionary + cls.ADDRESSES[ethereum_client.get_network()] = contract_address + return ethereum_tx_sent + except ContractAlreadyDeployed as e: + cls.ADDRESSES[ethereum_client.get_network()] = e.address + return None @staticmethod def _build_payload( diff --git a/gnosis/eth/tests/ethereum_test_case.py b/gnosis/eth/tests/ethereum_test_case.py index abb97f31f..47fd0721c 100644 --- a/gnosis/eth/tests/ethereum_test_case.py +++ b/gnosis/eth/tests/ethereum_test_case.py @@ -30,7 +30,7 @@ class EthereumTestCaseMixin: def setUpClass(cls): super().setUpClass() - cls.ethereum_test_account = cls.get_ethereum_test_account(cls) + cls.ethereum_test_account = cls.get_ethereum_test_account() # Caching ethereum_client to prevent initializing again cls.ethereum_client = _cached_data["ethereum_client"] @@ -45,7 +45,8 @@ def setUpClass(cls): cls.multicall = cls.ethereum_client.multicall assert cls.multicall, "Multicall must be defined" - def get_ethereum_test_account(self) -> LocalAccount: + @classmethod + def get_ethereum_test_account(cls): try: from django.conf import settings diff --git a/gnosis/safe/tests/safe_test_case.py b/gnosis/safe/tests/safe_test_case.py index 894a88177..fda87fa5d 100644 --- a/gnosis/safe/tests/safe_test_case.py +++ b/gnosis/safe/tests/safe_test_case.py @@ -1,6 +1,6 @@ import logging import os -from typing import List, Optional +from typing import Dict, List, Optional from eth_account import Account from eth_typing import ChecksumAddress @@ -8,7 +8,7 @@ from web3.contract import Contract from web3.types import Wei -from gnosis.eth.constants import NULL_ADDRESS +from gnosis.eth.constants import NULL_ADDRESS, SAFE_SINGLETON_FACTORY_DEPLOYER_ADDRESS from gnosis.eth.contracts import ( get_compatibility_fallback_handler_contract, get_multi_send_contract, @@ -20,7 +20,9 @@ get_sign_message_lib_contract, get_simulate_tx_accessor_V1_4_1_contract, ) +from gnosis.eth.exceptions import ContractAlreadyDeployed from gnosis.eth.tests.ethereum_test_case import EthereumTestCaseMixin +from gnosis.eth.tests.utils import send_tx from gnosis.eth.utils import get_empty_tx_params from gnosis.safe import Safe from gnosis.safe.multi_send import MultiSend @@ -31,9 +33,6 @@ logger = logging.getLogger(__name__) -_contract_addresses = {} - - class SafeTestCaseMixin(EthereumTestCaseMixin): compatibility_fallback_handler: Contract multi_send: MultiSend @@ -60,6 +59,8 @@ class SafeTestCaseMixin(EthereumTestCaseMixin): "multi_send": MultiSend.deploy_contract, } + contract_addresses: Dict[str, ChecksumAddress] = {} + @property def safe_contract(self): """ @@ -71,53 +72,59 @@ def safe_contract(self): def setUpClass(cls): super().setUpClass() - if not _contract_addresses: + cls.deploy_safe_singleton_factory() + + if not cls.contract_addresses: # First time mixin is called, deploy Safe contracts for key, function in cls.contract_deployers.items(): - _contract_addresses[key] = function( - cls.ethereum_client, cls.ethereum_test_account - ).contract_address + try: + cls.contract_addresses[key] = function( + cls.ethereum_client, cls.ethereum_test_account + ).contract_address + except ContractAlreadyDeployed as e: + cls.contract_addresses[key] = e.address - cls.configure_django_settings(cls) - cls.configure_envvars(cls) + cls.configure_django_settings() + cls.configure_envvars() cls.compatibility_fallback_handler = ( get_compatibility_fallback_handler_contract( - cls.w3, _contract_addresses["compatibility_fallback_handler"] + cls.w3, cls.contract_addresses["compatibility_fallback_handler"] ) ) cls.simulate_tx_accessor_V1_4_1 = get_simulate_tx_accessor_V1_4_1_contract( - cls.w3, _contract_addresses["simulate_tx_accessor_V1_4_1"] + cls.w3, cls.contract_addresses["simulate_tx_accessor_V1_4_1"] ) cls.safe_contract_V1_4_1 = get_safe_V1_4_1_contract( - cls.w3, _contract_addresses["safe_V1_4_1"] + cls.w3, cls.contract_addresses["safe_V1_4_1"] ) cls.safe_contract_V1_3_0 = get_safe_V1_3_0_contract( - cls.w3, _contract_addresses["safe_V1_3_0"] + cls.w3, cls.contract_addresses["safe_V1_3_0"] ) cls.safe_contract_V1_1_1 = get_safe_V1_1_1_contract( - cls.w3, _contract_addresses["safe_V1_1_1"] + cls.w3, cls.contract_addresses["safe_V1_1_1"] ) cls.safe_contract_V1_0_0 = get_safe_V1_0_0_contract( - cls.w3, _contract_addresses["safe_V1_0_0"] + cls.w3, cls.contract_addresses["safe_V1_0_0"] ) cls.safe_contract_V0_0_1 = get_safe_V1_0_0_contract( - cls.w3, _contract_addresses["safe_V0_0_1"] + cls.w3, cls.contract_addresses["safe_V0_0_1"] ) cls.proxy_factory_contract = get_proxy_factory_contract( - cls.w3, _contract_addresses["proxy_factory"] + cls.w3, cls.contract_addresses["proxy_factory"] ) cls.proxy_factory = ProxyFactory( cls.proxy_factory_contract.address, cls.ethereum_client ) cls.multi_send_contract = get_multi_send_contract( - cls.w3, _contract_addresses["multi_send"] + cls.w3, cls.contract_addresses["multi_send"] ) cls.multi_send = MultiSend( cls.ethereum_client, address=cls.multi_send_contract.address ) - def configure_django_settings(self): + @classmethod + def configure_django_settings(cls): """ Configure settings for django based applications @@ -127,18 +134,30 @@ def configure_django_settings(self): try: from django.conf import settings - settings.SAFE_CONTRACT_ADDRESS = _contract_addresses["safe_V1_4_1"] - settings.SAFE_DEFAULT_CALLBACK_HANDLER = _contract_addresses[ + settings.SAFE_CONTRACT_ADDRESS = cls.contract_addresses["safe_V1_4_1"] + settings.SAFE_DEFAULT_CALLBACK_HANDLER = cls.contract_addresses[ "compatibility_fallback_handler" ] - settings.SAFE_MULTISEND_ADDRESS = _contract_addresses["multi_send"] - settings.SAFE_PROXY_FACTORY_ADDRESS = _contract_addresses["proxy_factory"] - settings.SAFE_V0_0_1_CONTRACT_ADDRESS = _contract_addresses["safe_V0_0_1"] - settings.SAFE_V1_0_0_CONTRACT_ADDRESS = _contract_addresses["safe_V1_0_0"] - settings.SAFE_V1_1_1_CONTRACT_ADDRESS = _contract_addresses["safe_V1_1_1"] - settings.SAFE_V1_3_0_CONTRACT_ADDRESS = _contract_addresses["safe_V1_3_0"] - settings.SAFE_V1_4_1_CONTRACT_ADDRESS = _contract_addresses["safe_V1_4_1"] - settings.SAFE_SIMULATE_TX_ACCESSOR = _contract_addresses[ + settings.SAFE_MULTISEND_ADDRESS = cls.contract_addresses["multi_send"] + settings.SAFE_PROXY_FACTORY_ADDRESS = cls.contract_addresses[ + "proxy_factory" + ] + settings.SAFE_V0_0_1_CONTRACT_ADDRESS = cls.contract_addresses[ + "safe_V0_0_1" + ] + settings.SAFE_V1_0_0_CONTRACT_ADDRESS = cls.contract_addresses[ + "safe_V1_0_0" + ] + settings.SAFE_V1_1_1_CONTRACT_ADDRESS = cls.contract_addresses[ + "safe_V1_1_1" + ] + settings.SAFE_V1_3_0_CONTRACT_ADDRESS = cls.contract_addresses[ + "safe_V1_3_0" + ] + settings.SAFE_V1_4_1_CONTRACT_ADDRESS = cls.contract_addresses[ + "safe_V1_4_1" + ] + settings.SAFE_SIMULATE_TX_ACCESSOR = cls.contract_addresses[ "simulate_tx_accessor_V1_4_1" ] settings.SAFE_VALID_CONTRACT_ADDRESSES = { @@ -152,16 +171,49 @@ def configure_django_settings(self): except ModuleNotFoundError: logger.info("Django library is not installed") - def configure_envvars(self): + @classmethod + def configure_envvars(cls): """ Configure environment variables :return: """ - os.environ["SAFE_SIMULATE_TX_ACCESSOR_ADDRESS"] = _contract_addresses[ + os.environ["SAFE_SIMULATE_TX_ACCESSOR_ADDRESS"] = cls.contract_addresses[ "simulate_tx_accessor_V1_4_1" ] + @classmethod + def deploy_safe_singleton_factory(cls) -> bool: + """ + Deploy Safe Singleton Factory for deterministic deployments and speeding up tests, + due to being able to check quickly if contracts are deployed in their expected addresses + + Singleton factory with `1337` chainId is used + Deployer address: 0xE1CB04A0fA36DdD16a06ea828007E35e1a3cBC37 + Expected factory address: 0x914d7Fec6aaC8cd542e72Bca78B30650d45643d7 + + :return: `True` if deployed, `False` otherwise + """ + if cls.ethereum_client.get_singleton_factory_address(): + return False + + raw_tx = HexBytes( + "0xf8a78085174876e800830186a08080b853604580600e600039806000f350fe7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe03601600081602082378035828234f58015156039578182fd5b8082525050506014600cf3820a96a0460c6ea9b8f791e5d9e67fbf2c70aba92bf88591c39ac3747ea1bedc2ef1750ca04b08a4b5cea15a56276513da7a0c0b34f16e89811d5dd911efba5f8625a921cc" + ) + send_tx( + cls.w3, + {"to": SAFE_SINGLETON_FACTORY_DEPLOYER_ADDRESS, "value": 10000000000000000}, + cls.ethereum_test_account, + ) + tx_hash = cls.ethereum_client.send_raw_transaction(raw_tx) + tx_receipt = cls.ethereum_client.get_transaction_receipt(tx_hash, timeout=30) + assert tx_receipt["status"] == 1 + + # Clear cached empty singleton factory + cls.ethereum_client.get_singleton_factory_address.cache_clear() + + return True + def deploy_test_safe(self, *args, **kwargs) -> Safe: """ :param args: diff --git a/gnosis/safe/tests/test_proxy_factory/test_proxy_factory.py b/gnosis/safe/tests/test_proxy_factory/test_proxy_factory.py index 7d8191def..6443c46f6 100644 --- a/gnosis/safe/tests/test_proxy_factory/test_proxy_factory.py +++ b/gnosis/safe/tests/test_proxy_factory/test_proxy_factory.py @@ -1,4 +1,5 @@ import logging +import secrets from django.test import TestCase @@ -11,6 +12,7 @@ get_proxy_1_1_1_deployed_bytecode, get_proxy_1_3_0_deployed_bytecode, ) +from gnosis.eth.exceptions import ContractAlreadyDeployed from gnosis.eth.tests.utils import just_test_if_mainnet_node from gnosis.eth.utils import compare_byte_code from gnosis.safe import Safe @@ -56,11 +58,16 @@ def test_check_proxy_code(self): ] 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 - ) + try: + deployed_proxy_tx = ProxyFactoryVersion.deploy_contract( + self.ethereum_client, self.ethereum_test_account + ) + contract_address = deployed_proxy_tx.contract_address + except ContractAlreadyDeployed as e: + contract_address = e.address + proxy_factory = ProxyFactory( - deployed_proxy_tx.contract_address, + contract_address, self.ethereum_client, version=version, ) @@ -102,7 +109,7 @@ def test_check_proxy_code_mainnet(self): self.assertTrue(proxy_factory.check_proxy_code(safe)) def test_calculate_proxy_address(self): - salt_nonce = 12 + salt_nonce = secrets.randbits(256) address = self.proxy_factory.calculate_proxy_address( self.safe_contract_V1_4_1.address, b"", salt_nonce )