diff --git a/.env b/.env index 01811543..ae698c97 100644 --- a/.env +++ b/.env @@ -10,7 +10,7 @@ ETHEREUM_NODE_URL=http://ganache:8545 REDIS_URL=redis://redis/0 SAFE_CONTRACT_ADDRESS=0xe78A0F7E598Cc8b0Bb87894B0F60dD2a88d6a8Ab SAFE_FUNDER_PRIVATE_KEY=4f3edf983ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b1d -SAFE_OLD_CONTRACT_ADDRESS=0xCfEB869F69431e42cdB54A4F4f105C19C080A601 +SAFE_V1_0_0_CONTRACT_ADDRESS=0xCfEB869F69431e42cdB54A4F4f105C19C080A601 SAFE_PROXY_FACTORY_ADDRESS=0xC89Ce4735882C9F0f0FE26686c53074E09B0D550 SAFE_TX_SENDER_PRIVATE_KEY=6cbed15c793ce57650b9877cf6fa156fbef513c4e6134f022a85b1ffdd59b2a1 DEPLOY_MASTER_COPY_ON_INIT=1 diff --git a/.env_local b/.env_local index 01c3027b..8e4507f0 100644 --- a/.env_local +++ b/.env_local @@ -10,7 +10,7 @@ ETHEREUM_NODE_URL=http://localhost:8545 REDIS_URL=redis://localhost/0 SAFE_CONTRACT_ADDRESS=0xb6029EA3B2c51D09a50B53CA8012FeEB05bDa35A SAFE_FUNDER_PRIVATE_KEY=4f3edf983ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b1d -SAFE_OLD_CONTRACT_ADDRESS=0x8942595A2dC5181Df0465AF0D7be08c8f23C93af +SAFE_V1_0_0_CONTRACT_ADDRESS=0x8942595A2dC5181Df0465AF0D7be08c8f23C93af SAFE_PROXY_FACTORY_ADDRESS=0x12302fE9c02ff50939BaAaaf415fc226C078613C SAFE_TX_SENDER_PRIVATE_KEY=6cbed15c793ce57650b9877cf6fa156fbef513c4e6134f022a85b1ffdd59b2a1 DEPLOY_MASTER_COPY_ON_INIT=0 diff --git a/.gitignore b/.gitignore index 34309cd3..c7275fec 100644 --- a/.gitignore +++ b/.gitignore @@ -71,5 +71,6 @@ typings/ .vscode venv/ +.venv config/settings/dev.py db.sqlite3 diff --git a/.travis.yml b/.travis.yml index b7d93b22..b0b23721 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,7 +2,7 @@ dist: xenial language: python cache: pip python: - - "3.7" + - "3.8" env: global: - DOCKERHUB_PROJECT=safe-relay-service @@ -24,7 +24,7 @@ install: before_script: - psql -c 'create database travisci;' -U postgres script: - - coverage run --source=$SOURCE_FOLDER -m py.test -W ignore::DeprecationWarning + - coverage run --source=$SOURCE_FOLDER -m py.test -rxXs deploy: - provider: script script: bash scripts/deploy_docker.sh staging diff --git a/config/settings/base.py b/config/settings/base.py index 643a9f34..265d41cd 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -195,6 +195,8 @@ CELERY_TASK_SERIALIZER = 'json' # http://docs.celeryproject.org/en/latest/userguide/configuration.html#std:setting-result_serializer CELERY_RESULT_SERIALIZER = 'json' +# http://docs.celeryproject.org/en/latest/userguide/configuration.html#std:setting-task_ignore_result +CELERY_TASK_IGNORE_RESULT = True # Django REST Framework # ------------------------------------------------------------------------------ @@ -299,11 +301,23 @@ SAFE_FUNDER_MAX_ETH = env.int('SAFE_FUNDER_MAX_ETH', default=0.1) SAFE_FUNDING_CONFIRMATIONS = env.int('SAFE_FUNDING_CONFIRMATIONS', default=0) # Set to at least 3 # Master Copy Address of Safe Contract -SAFE_CONTRACT_ADDRESS = env('SAFE_CONTRACT_ADDRESS', default='0x' + '0' * 39 + '1') -SAFE_OLD_CONTRACT_ADDRESS = env('SAFE_OLD_CONTRACT_ADDRESS', default='0x' + '0' * 39 + '1') +SAFE_CONTRACT_ADDRESS = env('SAFE_CONTRACT_ADDRESS', default='0x34CfAC646f301356fAa8B21e94227e3583Fe3F5F') +SAFE_V1_0_0_CONTRACT_ADDRESS = env('SAFE_V1_0_0_CONTRACT_ADDRESS', default='0xb6029EA3B2c51D09a50B53CA8012FeEB05bDa35A') +SAFE_V0_0_1_CONTRACT_ADDRESS = env('SAFE_V0_0_1_CONTRACT_ADDRESS', default='0x8942595A2dC5181Df0465AF0D7be08c8f23C93af') SAFE_VALID_CONTRACT_ADDRESSES = set(env.list('SAFE_VALID_CONTRACT_ADDRESSES', - default=[])) | {SAFE_CONTRACT_ADDRESS, SAFE_OLD_CONTRACT_ADDRESS} -SAFE_PROXY_FACTORY_ADDRESS = env('SAFE_PROXY_FACTORY_ADDRESS', default='0x' + '0' * 39 + '2') + default=['0xaE32496491b53841efb51829d6f886387708F99B', + '0xb6029EA3B2c51D09a50B53CA8012FeEB05bDa35A' + '0x8942595A2dC5181Df0465AF0D7be08c8f23C93af', + '0xAC6072986E985aaBE7804695EC2d8970Cf7541A2']) + ) | {SAFE_CONTRACT_ADDRESS, + SAFE_V1_0_0_CONTRACT_ADDRESS, + SAFE_V0_0_1_CONTRACT_ADDRESS} +SAFE_PROXY_FACTORY_ADDRESS = env('SAFE_PROXY_FACTORY_ADDRESS', default='0x76E2cFc1F5Fa8F6a5b3fC4c8F4788F0116861F9B') +SAFE_PROXY_FACTORY_V1_0_0_ADDRESS = env('SAFE_PROXY_FACTORY_V1_0_0_ADDRESS', + default='0x12302fE9c02ff50939BaAaaf415fc226C078613C') +SAFE_DEFAULT_CALLBACK_HANDLER = env('SAFE_DEFAULT_CALLBACK_HANDLER', + default='0xd5D82B6aDDc9027B22dCA772Aa68D5d74cdBdF44') + # If FIXED_GAS_PRICE is None, GasStation will be used FIXED_GAS_PRICE = env.int('FIXED_GAS_PRICE', default=None) SAFE_TX_SENDER_PRIVATE_KEY = env('SAFE_TX_SENDER_PRIVATE_KEY', default=None) @@ -312,6 +326,7 @@ SAFE_CHECK_DEPLOYER_FUNDED_RETRIES = env.int('SAFE_CHECK_DEPLOYER_FUNDED_RETRIES', default=10) SAFE_FIXED_CREATION_COST = env.int('SAFE_FIXED_CREATION_COST', default=None) SAFE_ACCOUNTS_BALANCE_WARNING = env.int('SAFE_ACCOUNTS_BALANCE_WARNING', default=200000000000000000) # 0.2 Eth +SAFE_TX_NOT_MINED_ALERT_MINUTES = env('SAFE_TX_NOT_MINED_ALERT_MINUTES', default=15) NOTIFICATION_SERVICE_URI = env('NOTIFICATION_SERVICE_URI', default=None) NOTIFICATION_SERVICE_PASS = env('NOTIFICATION_SERVICE_PASS', default=None) diff --git a/config/settings/test.py b/config/settings/test.py index f66bcf08..41c47882 100644 --- a/config/settings/test.py +++ b/config/settings/test.py @@ -49,6 +49,3 @@ SAFE_TX_SENDER_PRIVATE_KEY = '0x6370fd033278c143179d81c5526140625662b8daa446c22ee2d73db3707e620c' SAFE_FUNDING_CONFIRMATIONS = 0 FIXED_GAS_PRICE = 1 -SAFE_CONTRACT_ADDRESS = '0x2727D69C0BD14B1dDd28371B8D97e808aDc1C2f7' -SAFE_OLD_CONTRACT_ADDRESS = '0x8942595A2dC5181Df0465AF0D7be08c8f23C93af' -SAFE_PROXY_FACTORY_ADDRESS = '0x3327d69c0bd14B1DDD28371B8D97E808Adc1c2F7' diff --git a/config/urls.py b/config/urls.py index 31c68ddc..b83d7a26 100644 --- a/config/urls.py +++ b/config/urls.py @@ -29,6 +29,7 @@ url(settings.ADMIN_URL, admin.site.urls), url(r'^api/v1/', include('safe_relay_service.relay.urls', namespace='v1')), url(r'^api/v2/', include('safe_relay_service.relay.urls_v2', namespace='v2')), + url(r'^api/v3/', include('safe_relay_service.relay.urls_v3', namespace='v3')), url(r'^check/', lambda request: HttpResponse("Ok"), name='check'), ] diff --git a/docker/web/Dockerfile b/docker/web/Dockerfile index 71a9ef62..90a9e27e 100644 --- a/docker/web/Dockerfile +++ b/docker/web/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.7-slim-stretch +FROM python:3.8-slim ENV PYTHONUNBUFFERED 1 WORKDIR /app diff --git a/docker/web/run_web.sh b/docker/web/run_web.sh index fc3bb28e..95e88bc4 100755 --- a/docker/web/run_web.sh +++ b/docker/web/run_web.sh @@ -7,7 +7,7 @@ python manage.py migrate --noinput echo "==> Setup Gas Station..." python manage.py setup_gas_station echo "==> Setup Safe Relay Task..." -python manage.py setup_safe_relay +python manage.py setup_service if [ "${DEPLOY_MASTER_COPY_ON_INIT:-0}" = 1 ]; then echo "==> Deploy Safe master copy..." python manage.py deploy_safe_contracts diff --git a/requirements-test.txt b/requirements-test.txt index cd53bc0a..6ec15fe4 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,5 +1,5 @@ -r requirements.txt -pytest==5.2.2 -pytest-django==3.6.0 +pytest==5.3.2 +pytest-django==3.8.0 pytest-sugar==0.9.2 coverage==4.5.3 diff --git a/requirements.txt b/requirements.txt index 7eefa7e8..85be54f1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,33 +1,32 @@ -Django==2.2.6 -cachetools==3.1.1 -celery==4.3.0 +Django==2.2.8 +cachetools==4.0.0 +celery==4.4.0 django-authtools==1.7.0 django-celery-beat==1.5.0 django-debug-toolbar django-debug-toolbar-force django-environ==0.4.5 -django-filter==2.1.0 -django-model-utils==3.2.0 -django-redis==4.10.0 -djangorestframework-camel-case==1.1.2 -djangorestframework==3.10.3 django-cors-headers==3.1.1 +django-filter==2.2.0 +django-model-utils==4.0.0 +django-redis==4.11.0 +djangorestframework-camel-case==1.1.2 +djangorestframework==3.11.0 docutils==0.14 drf-yasg[validation]==1.17.0 -eth-abi==1.3.0 ethereum==2.3.2 factory-boy==2.12.0 -faker==1.0.7 flake8 -gevent==1.4.0 git+git://github.com/AugurProject/gnosis-py.git#gnosis-py -gunicorn==19.9.0 -hexbytes==0.2.0 ipdb ipython isort -lxml==4.2.5 -numpy==1.17.3 +faker==3.0.0 +gevent==1.4.0 +gunicorn==20.0.4 +hexbytes==0.2.0 +lxml==4.4.1 +numpy==1.18.1 packaging>=19.0 psycopg2-binary==2.8.4 pytest-django==3.5.1 @@ -35,4 +34,4 @@ pytest-sugar==0.9.2 pytest==5.1.2 redis==3.3.8 requests==2.22.0 -web3==4.9.2 +web3==5.4.0 diff --git a/safe_relay_service/gas_station/gas_station.py b/safe_relay_service/gas_station/gas_station.py index 39167ba7..ef26cee2 100644 --- a/safe_relay_service/gas_station/gas_station.py +++ b/safe_relay_service/gas_station/gas_station.py @@ -53,10 +53,10 @@ def __init__(self, self.w3 = Web3(HTTPProvider(http_provider_uri)) try: if self.w3.net.version != 1: - self.w3.middleware_stack.inject(geth_poa_middleware, layer=0) + self.w3.middleware_onion.inject(geth_poa_middleware, layer=0) # For tests using dummy connections (like IPC) except (ConnectionError, FileNotFoundError): - self.w3.middleware_stack.inject(geth_poa_middleware, layer=0) + self.w3.middleware_onion.inject(geth_poa_middleware, layer=0) def _get_block_cache_key(self, block_number): return 'block:%d' % block_number diff --git a/safe_relay_service/gas_station/views.py b/safe_relay_service/gas_station/views.py index 8452f26e..4edd2dcc 100644 --- a/safe_relay_service/gas_station/views.py +++ b/safe_relay_service/gas_station/views.py @@ -32,7 +32,7 @@ def get(self, request, format=None): gas_station = GasStationProvider() gas_prices = gas_station.get_gas_prices() serializer = GasPriceSerializer(gas_prices) - return Response(serializer.data) + return Response(serializer.data, headers={'Cache-Control': f'max-age={60 * 4}'}) class GasStationHistoryView(ListAPIView): diff --git a/safe_relay_service/relay/management/commands/deploy_safe_contracts.py b/safe_relay_service/relay/management/commands/deploy_safe_contracts.py index 2a4d8db7..6c033064 100644 --- a/safe_relay_service/relay/management/commands/deploy_safe_contracts.py +++ b/safe_relay_service/relay/management/commands/deploy_safe_contracts.py @@ -11,7 +11,7 @@ class Command(BaseCommand): help = 'Deploys master copy using first unlocked account on the node if `ganache -d` is found and contract ' \ 'is not deployed. If not you need to set a private key using `--deployer-key`' GANACHE_FIRST_ACCOUNT_KEY = '0x4f3edf983ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b1d' - DEFAULT_ACCOUNT = Account.privateKeyToAccount(GANACHE_FIRST_ACCOUNT_KEY) + DEFAULT_ACCOUNT = Account.from_key(GANACHE_FIRST_ACCOUNT_KEY) def add_arguments(self, parser): # Positional arguments @@ -20,11 +20,11 @@ def add_arguments(self, parser): def handle(self, *args, **options): ethereum_client = EthereumClientProvider() deployer_key = options['deployer_key'] - deployer_account = Account.privateKeyToAccount(deployer_key) if deployer_key else self.DEFAULT_ACCOUNT + deployer_account = Account.from_key(deployer_key) if deployer_key else self.DEFAULT_ACCOUNT master_copies_with_deploy_fn = { settings.SAFE_CONTRACT_ADDRESS: Safe.deploy_master_contract, - settings.SAFE_OLD_CONTRACT_ADDRESS: Safe.deploy_old_master_contract, + settings.SAFE_V1_0_0_CONTRACT_ADDRESS: Safe.deploy_old_master_contract, settings.SAFE_PROXY_FACTORY_ADDRESS: ProxyFactory.deploy_proxy_factory_contract, } diff --git a/safe_relay_service/relay/management/commands/setup_safe_relay.py b/safe_relay_service/relay/management/commands/setup_service.py similarity index 65% rename from safe_relay_service/relay/management/commands/setup_safe_relay.py rename to safe_relay_service/relay/management/commands/setup_service.py index b69e03ad..e908f878 100644 --- a/safe_relay_service/relay/management/commands/setup_safe_relay.py +++ b/safe_relay_service/relay/management/commands/setup_service.py @@ -19,21 +19,32 @@ def create_task(self) -> Tuple[PeriodicTask, bool]: 'interval': interval }) + def delete_task(self) -> int: + deleted, _ = PeriodicTask.objects.filter(task=self.name).delete() + return deleted + class Command(BaseCommand): - help = 'Setup Safe relay required tasks' + help = 'Setup service required tasks' tasks = [CeleryTaskConfiguration('safe_relay_service.relay.tasks.deploy_safes_task', 'Deploy Safes', 20, IntervalSchedule.SECONDS), CeleryTaskConfiguration('safe_relay_service.relay.tasks.check_balance_of_accounts_task', 'Check Balance of realy accounts', 1, IntervalSchedule.HOURS), CeleryTaskConfiguration('safe_relay_service.relay.tasks.check_create2_deployed_safes_task', 'Check and deploy Create2 Safes', 1, IntervalSchedule.MINUTES), - CeleryTaskConfiguration('safe_relay_service.relay.tasks.find_internal_txs_task', - 'Process Internal Txs for Safes', 2, IntervalSchedule.MINUTES), CeleryTaskConfiguration('safe_relay_service.relay.tasks.find_erc_20_721_transfers_task', 'Process ERC20/721 transfers for Safes', 2, IntervalSchedule.MINUTES), + CeleryTaskConfiguration('safe_relay_service.relay.tasks.check_pending_transactions', + 'Check transactions not mined after a while', 10, IntervalSchedule.MINUTES), + CeleryTaskConfiguration('safe_relay_service.relay.tasks.check_and_update_pending_transactions', + 'Check and update transactions when mined', 1, IntervalSchedule.MINUTES), ] + tasks_to_delete = [ + CeleryTaskConfiguration('safe_relay_service.relay.tasks.find_internal_txs_task', + 'Process Internal Txs for Safes', 2, IntervalSchedule.MINUTES), + ] + def handle(self, *args, **options): for task in self.tasks: _, created = task.create_task() @@ -41,3 +52,10 @@ def handle(self, *args, **options): self.stdout.write(self.style.SUCCESS('Created Periodic Task %s' % task.name)) else: self.stdout.write(self.style.SUCCESS('Task %s was already created' % task.name)) + + for task in self.tasks_to_delete: + deleted = task.delete_task() + if deleted: + self.stdout.write(self.style.SUCCESS('Deleted Periodic Task %s' % task.name)) + else: + self.stdout.write(self.style.SUCCESS('Task %s was already deleted' % task.name)) diff --git a/safe_relay_service/relay/management/commands/test_relay_server.py b/safe_relay_service/relay/management/commands/test_relay_server.py index 3ae48a51..30236195 100644 --- a/safe_relay_service/relay/management/commands/test_relay_server.py +++ b/safe_relay_service/relay/management/commands/test_relay_server.py @@ -7,39 +7,17 @@ import requests from eth_account import Account -from web3 import HTTPProvider, Web3 +from web3 import Web3 -from gnosis.eth.contracts import get_erc20_contract +from gnosis.eth import EthereumClient from gnosis.safe import SafeTx from gnosis.safe.tests.utils import generate_valid_s -def send_eth(w3, account, to, value, nonce=None): - tx = { - 'to': to, - 'value': value, - 'gas': 23000, - 'gasPrice': w3.eth.gasPrice, - 'nonce': nonce if nonce is not None else w3.eth.getTransactionCount(account.address, - 'pending'), - } - - signed_tx = w3.eth.account.signTransaction(tx, private_key=account.privateKey) - return w3.eth.sendRawTransaction(signed_tx.rawTransaction) - - -def send_token(w3, account, to, amount_to_send, token_address, nonce=None): - erc20_contract = get_erc20_contract(w3, token_address) - nonce = nonce if nonce is not None else w3.eth.getTransactionCount(account.address, 'pending') - tx = erc20_contract.functions.transfer(to, amount_to_send).buildTransaction({'from': account.address, - 'nonce': nonce}) - signed_tx = w3.eth.account.signTransaction(tx, private_key=account.privateKey) - return w3.eth.sendRawTransaction(signed_tx.rawTransaction) - - class Command(BaseCommand): base_url: str w3: Web3 + ethereum_client: EthereumClient main_account: Account main_account_nonce: int @@ -50,10 +28,11 @@ def add_arguments(self, parser): # Positional arguments parser.add_argument('base_url', help='Base url of relay (e.g. http://safe-relay.gnosistest.com)') parser.add_argument('private_key', help='Private key') - parser.add_argument('--node_url', default='http://localhost:8545', + parser.add_argument('--node-url', default='http://localhost:8545', help='Ethereum node in the same net that the relay') parser.add_argument('--payment-token', help='Use payment token for creating/testing') - parser.add_argument('--create2', help='Use CREATE2 for safe creation', action='store_true', default=False) + parser.add_argument('--v2', help='Use v2 endpoints for safe V1.0.0 creation. By default V1.1.1 will be used', + action='store_true', default=False) parser.add_argument('--multiple-txs', help='Test sending multiple txs at the same time', action='store_true', default=False) @@ -71,28 +50,31 @@ def get_signal_url(self, address): def handle(self, *args, **options): self.base_url = options['base_url'] - create2 = options['create2'] + v2 = options['v2'] payment_token = options['payment_token'] multiple_txs = options['multiple_txs'] - self.w3 = Web3(HTTPProvider(options['node_url'])) - self.main_account = Account.privateKeyToAccount(options['private_key']) + self.ethereum_client = EthereumClient(options['node_url']) + self.w3 = self.ethereum_client.w3 + self.main_account = Account.from_key(options['private_key']) self.main_account_nonce = self.w3.eth.getTransactionCount(self.main_account.address, 'pending') main_account_balance = self.w3.eth.getBalance(self.main_account.address) - self.stdout.write(self.style.SUCCESS('Using %s as main account with balance=%d' % (self.main_account.address, - main_account_balance))) + main_account_balance_eth = self.w3.fromWei(main_account_balance, 'ether') + self.stdout.write(self.style.SUCCESS(f'Using {self.main_account.address} as main account with ' + f'balance={main_account_balance_eth}')) about_url = urljoin(self.base_url, reverse('v1:about')) about_json = requests.get(about_url).json() - if create2: - master_copy_address = about_json['settings']['SAFE_CONTRACT_ADDRESS'] + if v2: + master_copy_address = about_json['settings']['SAFE_V1_0_0_CONTRACT_ADDRESS'] else: - master_copy_address = about_json['settings']['SAFE_OLD_CONTRACT_ADDRESS'] + master_copy_address = about_json['settings']['SAFE_CONTRACT_ADDRESS'] + self.stdout.write(self.style.SUCCESS(f'Using master-copy={master_copy_address}')) accounts = [Account.create() for _ in range(3)] for account in accounts: self.stdout.write(self.style.SUCCESS('Created account=%s with key=%s' % (account.address, - account.privateKey.hex()))) + account.key.hex()))) accounts.append(self.main_account) accounts.sort(key=lambda acc: acc.address.lower()) owners = [account.address for account in accounts] @@ -101,8 +83,7 @@ def handle(self, *args, **options): safe_addresses = [] self.stdout.write(self.style.SUCCESS('Creating multiple safes')) for _ in range(10): - safe_address, payment = self.create_safe(owners, threshold=2, payment_token=payment_token, - create2=create2) + safe_address, payment = self.create_safe(owners, threshold=2, payment_token=payment_token, v2=v2) self.fund_safe(safe_address, payment, payment_token, wait_for_receipt=False) safe_addresses.append(safe_address) @@ -122,34 +103,28 @@ def handle(self, *args, **options): timeout=500).status == 1, 'Error on tx-hash=%s' % tx_hash self.stdout.write(self.style.SUCCESS('Success with tx-hash=%s' % tx_hash)) else: - safe_address, payment = self.create_safe(owners, threshold=2, payment_token=payment_token, create2=create2) + safe_address, payment = self.create_safe(owners, threshold=2, payment_token=payment_token, v2=v2) self.fund_safe(safe_address, payment, payment_token) safe_info = self.check_safe_deployed(safe_address, owners, master_copy_address) safe_version = safe_info['version'] self.send_safe_tx(safe_address, safe_version, accounts, payment_token) - def create_safe(self, owners: List[Account], threshold: int = 2, - payment_token: Optional[None] = None, create2: bool = True): - if create2: + def create_safe(self, owners: List[Account], threshold: int = 2, payment_token: Optional[None] = None, + v2: bool = False): + if v2: creation_url = urljoin(self.base_url, reverse('v2:safe-creation')) - data = { - 'saltNonce': generate_valid_s(), - 'owners': owners, - 'threshold': threshold, - } - if payment_token: - data['paymentToken'] = payment_token else: - creation_url = urljoin(self.base_url, reverse('v1:safe-creation')) - data = { - 's': generate_valid_s(), - 'owners': owners, - 'threshold': threshold, - } - if payment_token: - data['paymentToken'] = payment_token + creation_url = urljoin(self.base_url, reverse('v3:safe-creation')) + data = { + 'saltNonce': generate_valid_s(), + 'owners': owners, + 'threshold': threshold, + } + if payment_token: + data['paymentToken'] = payment_token + self.stdout.write(self.style.SUCCESS(f'Calling creation url {creation_url}')) r = requests.post(creation_url, json=data) - assert r.ok, "Error creating safe %s" % r.content + assert r.ok, f"Error creating safe {r.content} using url f{creation_url}" safe_address, payment = r.json()['safe'], int(r.json()['payment']) return safe_address, payment @@ -157,16 +132,18 @@ def create_safe(self, owners: List[Account], threshold: int = 2, def fund_safe(self, safe_address, payment, payment_token: Optional[None] = None, wait_for_receipt: bool = True): self.stdout.write(self.style.SUCCESS('Created safe=%s, need payment=%d' % (safe_address, payment))) if payment_token: - tx_hash = send_token(self.w3, self.main_account, safe_address, int(payment * 1.4), payment_token, - nonce=self.main_account_nonce) + tx_hash = self.ethereum_client.erc20.send_tokens(safe_address, int(payment * 1.4), payment_token, + self.main_account.key, nonce=self.main_account_nonce) self.main_account_nonce += 1 self.stdout.write(self.style.SUCCESS('Sent payment of payment-token=%s, waiting for ' 'receipt with tx-hash=%s' % (payment_token, tx_hash.hex()))) self.w3.eth.waitForTransactionReceipt(tx_hash, timeout=500) - tx_hash = send_eth(self.w3, self.main_account, safe_address, payment * 2, nonce=self.main_account_nonce) + tx_hash = self.ethereum_client.send_eth_to(self.main_account.key, safe_address, + self.ethereum_client.w3.eth.gasPrice, payment * 2, + nonce=self.main_account_nonce) self.main_account_nonce += 1 - self.stdout.write(self.style.SUCCESS('Sent payment * 2, waiting for receipt with tx-hash=%s' % tx_hash.hex())) + self.stdout.write(self.style.SUCCESS(f'Sent payment * 2, waiting for receipt with tx-hash={tx_hash.hex()}')) if wait_for_receipt: self.w3.eth.waitForTransactionReceipt(tx_hash, timeout=500) self.stdout.write(self.style.SUCCESS('Payment sent and mined. Waiting for safe to be deployed')) @@ -181,7 +158,7 @@ def check_safe_deployed(self, safe_address, owners, master_copy_address) -> Dict break time.sleep(10) # Check safe was created successfully - self.stdout.write(self.style.SUCCESS('Safe=%s was deployed' % safe_address)) + self.stdout.write(self.style.SUCCESS(f'Safe={safe_address} was deployed')) r = requests.get(self.get_safe_url(safe_address)) assert r.ok, "Safe deployed is not working" safe_info = r.json() @@ -189,6 +166,7 @@ def check_safe_deployed(self, safe_address, owners, master_copy_address) -> Dict assert safe_info['threshold'] == 2 assert safe_info['nonce'] == 0 assert safe_info['masterCopy'] == master_copy_address + self.stdout.write(self.style.SUCCESS(safe_info)) return safe_info @@ -250,8 +228,10 @@ def send_safe_tx(self, safe_address: str, safe_version: str, accounts: List[Acco def send_multiple_txs(self, safe_address: str, safe_version: str, accounts: List[Account], payment_token: Optional[str] = None, number_txs: int = 100) -> List[bytes]: - tx_hash = send_eth(self.w3, self.main_account, safe_address, self.w3.toWei(1, 'ether'), - nonce=self.main_account_nonce) + tx_hash = self.ethereum_client.send_eth_to(self.main_account.key, safe_address, + self.ethereum_client.w3.eth.gasPrice, + self.w3.toWei(1, 'ether'), + nonce=self.main_account_nonce) self.main_account_nonce += 1 self.stdout.write(self.style.SUCCESS('Sent 1 ether for testing sending multiple txs, ' diff --git a/safe_relay_service/relay/models.py b/safe_relay_service/relay/models.py index 68d5b67b..37e28743 100644 --- a/safe_relay_service/relay/models.py +++ b/safe_relay_service/relay/models.py @@ -15,7 +15,7 @@ from gnosis.eth.constants import ERC20_721_TRANSFER_TOPIC from gnosis.eth.django.models import (EthereumAddressField, Sha3HashField, Uint256Field) -from gnosis.safe import SafeOperation +from gnosis.safe import SafeOperation, SafeTx from .models_raw import SafeContractManagerRaw, SafeContractQuerySetRaw @@ -288,6 +288,9 @@ class EthereumBlock(models.Model): timestamp = models.DateTimeField() block_hash = Sha3HashField(unique=True) + def __str__(self): + return f'Block={self.number} on {self.timestamp}' + class EthereumTxManager(models.Manager): def create_from_tx(self, tx: Dict[str, Any], tx_hash: Union[bytes, str], gas_used: Optional[int] = None, @@ -422,6 +425,13 @@ def __str__(self): return '{} - {} - Safe {}'.format(self.ethereum_tx.tx_hash, SafeOperation(self.operation).name, self.safe.address) + def get_safe_tx(self) -> SafeTx: + return SafeTx(None, self.safe_id, self.to, self.value, self.data.tobytes() if self.data else b'', + self.operation, self.safe_tx_gas, self.data_gas, self.gas_price, self.gas_token, + self.refund_receiver, + signatures=self.signatures.tobytes() if self.signatures else b'', + safe_nonce=self.nonce) + class InternalTxManager(models.Manager): def get_or_create_from_trace(self, trace: Dict[str, Any], ethereum_tx: EthereumTx): diff --git a/safe_relay_service/relay/serializers.py b/safe_relay_service/relay/serializers.py index a157580e..a3e1fdb3 100644 --- a/safe_relay_service/relay/serializers.py +++ b/safe_relay_service/relay/serializers.py @@ -149,6 +149,7 @@ class SafeBalanceResponseSerializer(serializers.Serializer): class SafeResponseSerializer(serializers.Serializer): address = EthereumAddressField() master_copy = EthereumAddressField() + fallback_handler = EthereumAddressField() nonce = serializers.IntegerField(min_value=0) threshold = serializers.IntegerField(min_value=1) owners = serializers.ListField(child=EthereumAddressField(), min_length=1) diff --git a/safe_relay_service/relay/services/erc20_events_service.py b/safe_relay_service/relay/services/erc20_events_service.py index c415397c..ef2ef588 100644 --- a/safe_relay_service/relay/services/erc20_events_service.py +++ b/safe_relay_service/relay/services/erc20_events_service.py @@ -26,6 +26,15 @@ class Erc20EventsService(TransactionScanService): """ Indexes ERC20 and ERC721 `Transfer` Event (as ERC721 has the same topic) """ + + def __init__(self, ethereum_client: EthereumClient, block_process_limit: int = 10000, + updated_blocks_behind: int = 200, query_chunk_size: int = 500, **kwargs): + super().__init__(ethereum_client, + block_process_limit=block_process_limit, + updated_blocks_behind=updated_blocks_behind, + query_chunk_size=query_chunk_size, + **kwargs) + @property def database_field(self): return 'erc_20_block_number' diff --git a/safe_relay_service/relay/services/funding_service.py b/safe_relay_service/relay/services/funding_service.py index dd71ca14..8946d423 100644 --- a/safe_relay_service/relay/services/funding_service.py +++ b/safe_relay_service/relay/services/funding_service.py @@ -44,7 +44,7 @@ def __init__(self, ethereum_client: EthereumClient, gas_station: GasStation, red self.ethereum_client = ethereum_client self.gas_station = gas_station self.redis = redis - self.funder_account = Account.privateKeyToAccount(funder_private_key) + self.funder_account = Account.from_key(funder_private_key) self.max_eth_to_send = max_eth_to_send def send_eth_to(self, to: str, value: int, gas: int = 22000, gas_price=None, @@ -57,7 +57,7 @@ def send_eth_to(self, to: str, value: int, gas: int = 22000, gas_price=None, with EthereumNonceLock(self.redis, self.ethereum_client, self.funder_account.address, timeout=60 * 2) as tx_nonce: - return self.ethereum_client.send_eth_to(self.funder_account.privateKey, to, gas_price, value, + return self.ethereum_client.send_eth_to(self.funder_account.key, to, gas_price, value, gas=gas, retry=retry, block_identifier=block_identifier, diff --git a/safe_relay_service/relay/services/internal_tx_service.py b/safe_relay_service/relay/services/internal_tx_service.py index 17b6c96f..57c61427 100644 --- a/safe_relay_service/relay/services/internal_tx_service.py +++ b/safe_relay_service/relay/services/internal_tx_service.py @@ -3,7 +3,7 @@ from gnosis.eth import EthereumClient -from ..models import EthereumTxCallType, EthereumTxType, InternalTx +from ..models import InternalTx from .transaction_scan_service import TransactionScanService logger = getLogger(__name__) diff --git a/safe_relay_service/relay/services/notification_service.py b/safe_relay_service/relay/services/notification_service.py index b011a1e2..6362fee1 100644 --- a/safe_relay_service/relay/services/notification_service.py +++ b/safe_relay_service/relay/services/notification_service.py @@ -51,6 +51,7 @@ def send_create_notification(self, safe_address: str, owners: List[str]) -> bool message = { "type": "safeCreation", "safe": safe_address, + "owners": ','.join(owners), # Firebase just allows strings } return self.send_notification(message, owners) diff --git a/safe_relay_service/relay/services/safe_creation_service.py b/safe_relay_service/relay/services/safe_creation_service.py index 391f1bae..bff6dae8 100644 --- a/safe_relay_service/relay/services/safe_creation_service.py +++ b/safe_relay_service/relay/services/safe_creation_service.py @@ -17,8 +17,7 @@ from safe_relay_service.tokens.models import Token from safe_relay_service.tokens.price_oracles import CannotGetTokenPriceFromApi -from ..models import (EthereumTx, SafeContract, SafeCreation, SafeCreation2, - SafeTxStatus) +from ..models import EthereumTx, SafeContract, SafeCreation2, SafeTxStatus from ..repositories.redis_repository import EthereumNonceLock, RedisRepository logger = getLogger(__name__) @@ -51,6 +50,7 @@ class SafeInfo(NamedTuple): owners: List[str] master_copy: str version: str + fallback_handler: str class SafeCreationServiceProvider: @@ -60,8 +60,27 @@ def __new__(cls): EthereumClientProvider(), RedisRepository().redis, settings.SAFE_CONTRACT_ADDRESS, - settings.SAFE_OLD_CONTRACT_ADDRESS, settings.SAFE_PROXY_FACTORY_ADDRESS, + settings.SAFE_DEFAULT_CALLBACK_HANDLER, + settings.SAFE_FUNDER_PRIVATE_KEY, + settings.SAFE_FIXED_CREATION_COST) + return cls.instance + + @classmethod + def del_singleton(cls): + if hasattr(cls, "instance"): + del cls.instance + + +class SafeCreationV1_0_0ServiceProvider: + def __new__(cls): + if not hasattr(cls, 'instance'): + cls.instance = SafeCreationService(GasStationProvider(), + EthereumClientProvider(), + RedisRepository().redis, + settings.SAFE_V1_0_0_CONTRACT_ADDRESS, + settings.SAFE_PROXY_FACTORY_V1_0_0_ADDRESS, + settings.SAFE_DEFAULT_CALLBACK_HANDLER, settings.SAFE_FUNDER_PRIVATE_KEY, settings.SAFE_FIXED_CREATION_COST) return cls.instance @@ -74,15 +93,15 @@ def del_singleton(cls): class SafeCreationService: def __init__(self, gas_station: GasStation, ethereum_client: EthereumClient, redis: Redis, - safe_contract_address: str, safe_old_contract_address: str, proxy_factory_address: str, + safe_contract_address: str, proxy_factory_address: str, default_callback_handler: str, safe_funder_private_key: str, safe_fixed_creation_cost: int): self.gas_station = gas_station self.ethereum_client = ethereum_client self.redis = redis self.safe_contract_address = safe_contract_address - self.safe_old_contract_address = safe_old_contract_address self.proxy_factory = ProxyFactory(proxy_factory_address, self.ethereum_client) - self.funder_account = Account.privateKeyToAccount(safe_funder_private_key) + self.default_callback_handler = default_callback_handler + self.funder_account = Account.from_key(safe_funder_private_key) self.safe_fixed_creation_cost = safe_fixed_creation_cost def _get_token_eth_value_or_raise(self, address: str) -> float: @@ -109,62 +128,6 @@ def _get_configured_gas_price(self) -> int: """ return self.gas_station.get_gas_prices().fast - def create_safe_tx(self, s: int, owners: List[str], threshold: int, - payment_token: Optional[str]) -> SafeCreation: - """ - Prepare creation tx for a new safe using classic CREATE method. Deprecated, it's recommended - to use `create2_safe_tx` - :param s: Random s value for ecdsa signature - :param owners: Owners of the new Safe - :param threshold: Minimum number of users required to operate the Safe - :param payment_token: Address of the payment token, if ether is not used - :rtype: SafeCreation - :raises: InvalidPaymentToken - """ - - payment_token = payment_token or NULL_ADDRESS - payment_token_eth_value = self._get_token_eth_value_or_raise(payment_token) - gas_price: int = self._get_configured_gas_price() - current_block_number = self.ethereum_client.current_block_number - - logger.debug('Building safe creation tx with gas price %d' % gas_price) - safe_creation_tx = Safe.build_safe_creation_tx(self.ethereum_client, self.safe_old_contract_address, - s, owners, threshold, gas_price, payment_token, - self.funder_account.address, - payment_token_eth_value=payment_token_eth_value, - fixed_creation_cost=self.safe_fixed_creation_cost) - - safe_contract = SafeContract.objects.create( - address=safe_creation_tx.safe_address, - master_copy=safe_creation_tx.master_copy - ) - - # Enable tx and erc20 tracing - SafeTxStatus.objects.create(safe=safe_contract, - initial_block_number=current_block_number, - tx_block_number=current_block_number, - erc_20_block_number=current_block_number) - - return SafeCreation.objects.create( - deployer=safe_creation_tx.deployer_address, - safe=safe_contract, - master_copy=safe_creation_tx.master_copy, - funder=safe_creation_tx.funder, - owners=owners, - threshold=threshold, - payment=safe_creation_tx.payment, - tx_hash=safe_creation_tx.tx_hash.hex(), - gas=safe_creation_tx.gas, - gas_price=safe_creation_tx.gas_price, - payment_token=None if safe_creation_tx.payment_token == NULL_ADDRESS else safe_creation_tx.payment_token, - value=safe_creation_tx.tx_pyethereum.value, - v=safe_creation_tx.v, - r=safe_creation_tx.r, - s=safe_creation_tx.s, - data=safe_creation_tx.tx_pyethereum.data, - signed_tx=safe_creation_tx.tx_raw - ) - def create2_safe_tx(self, salt_nonce: int, owners: Iterable[str], threshold: int, payment_token: Optional[str], setup_data: Optional[str], to: Optional[str], callback: Optional[str]) -> SafeCreation2: @@ -185,11 +148,12 @@ def create2_safe_tx(self, salt_nonce: int, owners: Iterable[str], threshold: int current_block_number = self.ethereum_client.current_block_number logger.debug('Building safe create2 tx with gas price %d', gas_price) safe_creation_tx = Safe.build_safe_create2_tx(self.ethereum_client, self.safe_contract_address, - self.proxy_factory.address, salt_nonce, owners, threshold, + self.proxy_factory.address, salt_nonce, list(owners), threshold, gas_price, payment_token, + fallback_handler=self.default_callback_handler, payment_token_eth_value=payment_token_eth_value, fixed_creation_cost=self.safe_fixed_creation_cost, - setup_data=HexBytes(setup_data if setup_data else '0x'), + setup_data=setup_data or '0x', to=to, callback=callback ) @@ -233,9 +197,7 @@ def deploy_create2_safe_tx(self, safe_address: str) -> SafeCreation2: :param safe_address: :return: tx_hash """ - - - safe_creation2 = SafeCreation2.objects.get(safe=safe_address) + safe_creation2: SafeCreation2 = SafeCreation2.objects.get(safe=safe_address) if safe_creation2.tx_hash: logger.info('Safe=%s has already been deployed with tx-hash=%s', safe_address, safe_creation2.tx_hash) @@ -304,28 +266,20 @@ def deploy_create2_safe_tx(self, safe_address: str) -> SafeCreation2: safe_creation2.gas_price_estimated, nonce=tx_nonce, callback=safe_creation2.callback) + # proxy_factory = ProxyFactory(safe_creation2.proxy_factory, self.ethereum_client) + # ethereum_tx_sent = proxy_factory.deploy_proxy_contract_with_nonce(self.funder_account, + # safe_creation2.master_copy, + # setup_data, + # safe_creation2.salt_nonce, + # safe_creation2.gas_estimated, + # safe_creation2.gas_price_estimated, + # nonce=tx_nonce) EthereumTx.objects.create_from_tx(ethereum_tx_sent.tx, ethereum_tx_sent.tx_hash) safe_creation2.tx_hash = ethereum_tx_sent.tx_hash safe_creation2.save() logger.info('Deployed safe=%s with tx-hash=%s', safe_address, ethereum_tx_sent.tx_hash.hex()) return safe_creation2 - def estimate_safe_creation(self, number_owners: int, payment_token: Optional[str] = None) -> SafeCreationEstimate: - """ - :param number_owners: - :param payment_token: - :return: - :raises: InvalidPaymentToken - """ - payment_token = payment_token or NULL_ADDRESS - payment_token_eth_value = self._get_token_eth_value_or_raise(payment_token) - gas_price = self._get_configured_gas_price() - fixed_creation_cost = self.safe_fixed_creation_cost - return Safe.estimate_safe_creation(self.ethereum_client, - self.safe_old_contract_address, number_owners, gas_price, payment_token, - payment_token_eth_value=payment_token_eth_value, - fixed_creation_cost=fixed_creation_cost) - def estimate_safe_creation2(self, number_owners: int, payment_token: Optional[str] = None) -> SafeCreationEstimate: """ :param number_owners: @@ -340,6 +294,7 @@ def estimate_safe_creation2(self, number_owners: int, payment_token: Optional[st return Safe.estimate_safe_creation_2(self.ethereum_client, self.safe_contract_address, self.proxy_factory.address, number_owners, gas_price, payment_token, + fallback_handler=self.default_callback_handler, payment_token_eth_value=payment_token_eth_value, fixed_creation_cost=fixed_creation_cost) @@ -371,4 +326,5 @@ def retrieve_safe_info(self, address: str) -> SafeInfo: owners = safe.retrieve_owners() master_copy = safe.retrieve_master_copy_address() version = safe.retrieve_version() - return SafeInfo(address, nonce, threshold, owners, master_copy, version) + fallback_handler = safe.retrieve_fallback_handler() + return SafeInfo(address, nonce, threshold, owners, master_copy, version, fallback_handler) diff --git a/safe_relay_service/relay/services/transaction_scan_service.py b/safe_relay_service/relay/services/transaction_scan_service.py index 20e6a478..b0c698d6 100644 --- a/safe_relay_service/relay/services/transaction_scan_service.py +++ b/safe_relay_service/relay/services/transaction_scan_service.py @@ -18,7 +18,7 @@ class TransactionScanService(ABC): def __init__(self, ethereum_client: EthereumClient, confirmations: int = 10, block_process_limit: int = 10000, updated_blocks_behind: int = 100, - query_chunk_size: int = 100, safe_creation_threshold: int = 150000): + query_chunk_size: int = 500, safe_creation_threshold: int = 150000): """ :param ethereum_client: :param confirmations: Threshold of blocks to scan to prevent reorgs diff --git a/safe_relay_service/relay/services/transaction_service.py b/safe_relay_service/relay/services/transaction_service.py index 4fdfd804..c97c8330 100644 --- a/safe_relay_service/relay/services/transaction_service.py +++ b/safe_relay_service/relay/services/transaction_service.py @@ -1,7 +1,9 @@ +from datetime import timedelta from logging import getLogger from typing import Any, Dict, List, NamedTuple, Optional, Set, Tuple from django.db import IntegrityError +from django.db.models import Q from django.utils import timezone from eth_account import Account @@ -19,7 +21,7 @@ from safe_relay_service.tokens.models import Token from safe_relay_service.tokens.price_oracles import CannotGetTokenPriceFromApi -from ..models import EthereumTx, SafeContract, SafeMultisigTx +from ..models import EthereumBlock, EthereumTx, SafeContract, SafeMultisigTx from ..repositories.redis_repository import EthereumNonceLock, RedisRepository logger = getLogger(__name__) @@ -122,7 +124,7 @@ def __init__(self, gas_station: GasStation, ethereum_client: EthereumClient, red self.redis = redis self.safe_valid_contract_addresses = safe_valid_contract_addresses self.proxy_factory = ProxyFactory(proxy_factory_address, self.ethereum_client) - self.tx_sender_account = Account.privateKeyToAccount(tx_sender_private_key) + self.tx_sender_account = Account.from_key(tx_sender_private_key) @staticmethod def _check_refund_receiver(refund_receiver: str) -> bool: @@ -146,7 +148,7 @@ def _is_valid_gas_token(address: Optional[str]) -> float: Token.objects.get(address=address, gas=True) return True except Token.DoesNotExist: - logger.warning('Cannot retrieve gas token from db: Gas token %s not valid' % address) + logger.warning('Cannot retrieve gas token from db: Gas token %s not valid', address) return False def _check_safe_gas_price(self, gas_token: Optional[str], safe_gas_price: int) -> bool: @@ -173,7 +175,7 @@ def _check_safe_gas_price(self, gas_token: Optional[str], safe_gas_price: int) - # We use gas station tx gas price. We cannot use internal tx's because is calculated # based on the gas token except Token.DoesNotExist: - logger.warning('Cannot retrieve gas token from db: Gas token %s not valid' % gas_token) + logger.warning('Cannot retrieve gas token from db: Gas token %s not valid', gas_token) raise InvalidGasToken('Gas token %s not valid' % gas_token) else: if safe_gas_price < minimum_accepted_gas_price: @@ -385,8 +387,8 @@ def _send_multisig_tx(self, safe_base_gas_estimation = safe.estimate_tx_base_gas(to, value, data, operation, gas_token, safe_tx_gas_estimation) if safe_tx_gas < safe_tx_gas_estimation or base_gas < safe_base_gas_estimation: - raise InvalidGasEstimation("Gas should be at least equal to safe-tx-gas=%d and data-gas=%d. Current is " - "safe-tx-gas=%d and data-gas=%d" % + raise InvalidGasEstimation("Gas should be at least equal to safe-tx-gas=%d and base-gas=%d. Current is " + "safe-tx-gas=%d and base-gas=%d" % (safe_tx_gas_estimation, safe_base_gas_estimation, safe_tx_gas, base_gas)) # Use user provided gasPrice for TX if more than our stardard gas price @@ -398,6 +400,8 @@ def _send_multisig_tx(self, tx_sender_private_key = self.tx_sender_account.privateKey tx_sender_address = Account.privateKeyToAccount(tx_sender_private_key).address + # tx_sender_private_key = self.tx_sender_account.key + # tx_sender_address = Account.from_key(tx_sender_private_key).address safe_tx = safe.build_multisig_tx( to, @@ -416,7 +420,7 @@ def _send_multisig_tx(self, if safe_tx.signers != safe_tx.sorted_signers: raise SignaturesNotSorted('Safe-tx-hash=%s - Signatures are not sorted by owner: %s' % - (safe_tx.safe_tx_hash, safe_tx.signers)) + (safe_tx.safe_tx_hash.hex(), safe_tx.signers)) safe_tx.call(tx_sender_address=tx_sender_address, block_identifier=block_identifier) @@ -424,4 +428,43 @@ def _send_multisig_tx(self, timeout=60 * 2) as tx_nonce: tx_hash, tx = safe_tx.execute(tx_sender_private_key, tx_gas=tx_gas, tx_gas_price=tx_gas_price, tx_nonce=tx_nonce, block_identifier=block_identifier) - return tx_hash, safe_tx.tx_hash, tx + return tx_hash, safe_tx.safe_tx_hash, tx + + def get_pending_multisig_transactions(self, older_than: int) -> List[SafeMultisigTx]: + """ + Get multisig txs that have not been mined after `older_than` seconds + :param older_than: Time in seconds for a tx to be considered pending, if 0 all will be returned + """ + return SafeMultisigTx.objects.filter( + Q(ethereum_tx__block=None) | Q(ethereum_tx=None) + ).filter( + created__lte=timezone.now() - timedelta(seconds=older_than), + ) + + # TODO Refactor and test + def create_or_update_ethereum_tx(self, tx_hash: str) -> EthereumTx: + try: + ethereum_tx = EthereumTx.objects.get(tx_hash=tx_hash) + if ethereum_tx.block is None: + tx_receipt = self.ethereum_client.get_transaction_receipt(tx_hash) + if tx_receipt: + ethereum_tx.block = self.get_or_create_ethereum_block(tx_receipt.blockNumber) + ethereum_tx.gas_used = tx_receipt.gasUsed + ethereum_tx.save() + return ethereum_tx + except EthereumTx.DoesNotExist: + tx = self.ethereum_client.get_transaction(tx_hash) + if tx: + if tx_receipt: + tx_receipt = self.ethereum_client.get_transaction_receipt(tx_hash) + ethereum_block = self.get_or_create_ethereum_block(tx_receipt.blockNumber) + return EthereumTx.objects.create_from_tx(tx, tx_hash, tx_receipt.gasUsed, ethereum_block) + return EthereumTx.objects.create_from_tx(tx, tx_hash) + + # TODO Refactor and test + def get_or_create_ethereum_block(self, block_number: int): + try: + return EthereumBlock.objects.get(number=block_number) + except EthereumBlock.DoesNotExist: + block = self.ethereum_client.get_block(block_number) + return EthereumBlock.objects.create_from_block(block) diff --git a/safe_relay_service/relay/tasks.py b/safe_relay_service/relay/tasks.py index ac5a4759..3648262f 100644 --- a/safe_relay_service/relay/tasks.py +++ b/safe_relay_service/relay/tasks.py @@ -292,7 +292,8 @@ def check_create2_deployed_safes_task() -> None: safe_creation2.block_number = block_number safe_creation2.save() else: - # If safe was not included in any block after 35 minutes (mempool limit is 30), we try to deploy it again + # If safe was not included in any block after 35 minutes (mempool limit is 30) + # we try to deploy it again if safe_creation2.modified + timedelta(minutes=35) < timezone.now(): logger.info('Safe=%s with tx-hash=%s was not deployed after 10 minutes', safe_address, safe_creation2.tx_hash) @@ -300,7 +301,8 @@ def check_create2_deployed_safes_task() -> None: safe_creation2.save() deploy_create2_safe_task.delay(safe_address, retry=False) - for safe_creation2 in SafeCreation2.objects.not_deployed(): + for safe_creation2 in SafeCreation2.objects.not_deployed().filter( + created__gte=timezone.now() - timedelta(days=10)): deploy_create2_safe_task.delay(safe_creation2.safe.address, retry=False) except LockError: pass @@ -369,3 +371,46 @@ def find_erc_20_721_transfers_task() -> int: except LockError: pass return number_safes + + +@app.shared_task(soft_time_limit=60) +def check_pending_transactions() -> int: + """ + Find txs that have not been mined after a while + :return: Number of pending transactions + """ + number_txs = 0 + try: + redis = RedisRepository().redis + with redis.lock('tasks:check_pending_transactions', blocking_timeout=1, timeout=60): + tx_not_mined_alert = settings.SAFE_TX_NOT_MINED_ALERT_MINUTES + txs = TransactionServiceProvider().get_pending_multisig_transactions(older_than=tx_not_mined_alert * 60) + for tx in txs: + logger.error('Tx with tx-hash=%s and safe-tx-hash=%s has not been mined after a while, created=%s', + tx.ethereum_tx_id, tx.safe_tx_hash, tx.created) + number_txs += 1 + except LockError: + pass + return number_txs + + +@app.shared_task(soft_time_limit=60) +def check_and_update_pending_transactions() -> int: + """ + Check if pending txs have been mined and update them + :return: Number of pending transactions + """ + number_txs = 0 + try: + redis = RedisRepository().redis + with redis.lock('tasks:check_and_update_pending_transactions', blocking_timeout=1, timeout=60): + transaction_service = TransactionServiceProvider() + txs = TransactionServiceProvider().get_pending_multisig_transactions(older_than=15) + for tx in txs: + ethereum_tx = transaction_service.create_or_update_ethereum_tx(tx.ethereum_tx_id) + if ethereum_tx and ethereum_tx.block_id: + logger.info('Updated tx with tx-hash=%s and block=%d', ethereum_tx.tx_hash, ethereum_tx.block_id) + number_txs += 1 + except LockError: + pass + return number_txs diff --git a/safe_relay_service/relay/tests/factories.py b/safe_relay_service/relay/tests/factories.py index 95859dca..0bd85ccc 100644 --- a/safe_relay_service/relay/tests/factories.py +++ b/safe_relay_service/relay/tests/factories.py @@ -43,7 +43,7 @@ class Meta: owners = factory.LazyFunction(lambda: [Account.create().address, Account.create().address]) threshold = 2 payment = factory.fuzzy.FuzzyInteger(100, 1000) - tx_hash = factory.Sequence(lambda n: Web3.sha3(n)) + tx_hash = factory.Sequence(lambda n: Web3.keccak(n)) gas = factory.fuzzy.FuzzyInteger(100000, 200000) gas_price = factory.fuzzy.FuzzyInteger(Web3.toWei(1, 'gwei'), Web3.toWei(20, 'gwei')) payment_token = None @@ -74,7 +74,7 @@ class Meta: setup_data = factory.Sequence(lambda n: HexBytes('%x' % (n + 1000))) gas_estimated = factory.fuzzy.FuzzyInteger(100000, 200000) gas_price_estimated = factory.fuzzy.FuzzyInteger(Web3.toWei(1, 'gwei'), Web3.toWei(20, 'gwei')) - tx_hash = factory.Sequence(lambda n: Web3.sha3(text='safe-creation-2-%d' % n)) + tx_hash = factory.Sequence(lambda n: Web3.keccak(text='safe-creation-2-%d' % n)) block_number = None @@ -93,7 +93,7 @@ class Meta: gas_limit = factory.fuzzy.FuzzyInteger(100000000, 200000000) gas_used = factory.fuzzy.FuzzyInteger(100000, 500000) timestamp = factory.LazyFunction(timezone.now) - block_hash = factory.Sequence(lambda n: Web3.sha3(text='block%d' % n)) + block_hash = factory.Sequence(lambda n: Web3.keccak(text='block%d' % n)) class EthereumTxFactory(factory.DjangoModelFactory): @@ -101,7 +101,7 @@ class Meta: model = EthereumTx block = factory.SubFactory(EthereumBlockFactory) - tx_hash = factory.Sequence(lambda n: Web3.sha3(text='ethereum_tx_hash%d' % n)) + tx_hash = factory.Sequence(lambda n: Web3.keccak(text='ethereum_tx_hash%d' % n)) _from = factory.LazyFunction(lambda: Account.create().address) gas = factory.fuzzy.FuzzyInteger(1000, 5000) gas_price = factory.fuzzy.FuzzyInteger(1, 100) @@ -127,7 +127,7 @@ class Meta: gas_token = None refund_receiver = factory.LazyFunction(lambda: Account.create().address) nonce = factory.Sequence(lambda n: n) - safe_tx_hash = factory.Sequence(lambda n: Web3.sha3(text='safe_tx_hash%d' % n)) + safe_tx_hash = factory.Sequence(lambda n: Web3.keccak(text='safe_tx_hash%d' % n)) class InternalTxFactory(factory.DjangoModelFactory): diff --git a/safe_relay_service/relay/tests/relay_test_case.py b/safe_relay_service/relay/tests/relay_test_case.py index fda39f05..68e54cd1 100644 --- a/safe_relay_service/relay/tests/relay_test_case.py +++ b/safe_relay_service/relay/tests/relay_test_case.py @@ -24,15 +24,6 @@ def setUpTestData(cls): cls.safe_creation_service = SafeCreationServiceProvider() cls.transaction_service = TransactionServiceProvider() - def create_test_safe_in_db(self, owners=None, number_owners=3, threshold=None, - payment_token=None) -> SafeCreation: - s = generate_valid_s() - owners = owners or [Account.create().address for _ in range(number_owners)] - threshold = threshold if threshold else len(owners) - - safe_creation = self.safe_creation_service.create_safe_tx(s, owners, threshold, payment_token) - return safe_creation - def create2_test_safe_in_db(self, owners=None, number_owners=3, threshold=None, payment_token=None, salt_nonce=None) -> SafeCreation2: diff --git a/safe_relay_service/relay/tests/test_commands.py b/safe_relay_service/relay/tests/test_commands.py index 2d303954..aadfdc39 100644 --- a/safe_relay_service/relay/tests/test_commands.py +++ b/safe_relay_service/relay/tests/test_commands.py @@ -41,9 +41,9 @@ def test_setup_internal_txs(self): call_command('setup_internal_txs', stdout=buf) self.assertIn('Generated 1 SafeTxStatus', buf.getvalue()) - def test_setup_safe_relay(self): - from ..management.commands.setup_safe_relay import Command + def test_setup_service(self): + from ..management.commands.setup_service import Command number_tasks = len(Command.tasks) self.assertEqual(PeriodicTask.objects.all().count(), 0) - call_command('setup_safe_relay') + call_command('setup_service') self.assertEqual(PeriodicTask.objects.all().count(), number_tasks) diff --git a/safe_relay_service/relay/tests/test_safe_creation_service.py b/safe_relay_service/relay/tests/test_safe_creation_service.py index d3f9f6cf..31d0ed01 100644 --- a/safe_relay_service/relay/tests/test_safe_creation_service.py +++ b/safe_relay_service/relay/tests/test_safe_creation_service.py @@ -49,42 +49,6 @@ def test_deploy_create2_safe_tx(self): def test_creation_service_provider_singleton(self): self.assertEqual(SafeCreationServiceProvider(), SafeCreationServiceProvider()) - def test_estimate_safe_creation(self): - gas_price = self.safe_creation_service._get_configured_gas_price() - - number_owners = 4 - payment_token = None - safe_creation_estimate = self.safe_creation_service.estimate_safe_creation(number_owners, payment_token) - self.assertGreater(safe_creation_estimate.gas, 0) - self.assertEqual(safe_creation_estimate.gas_price, gas_price) - self.assertGreater(safe_creation_estimate.payment, 0) - self.assertEqual(safe_creation_estimate.payment_token, NULL_ADDRESS) - estimated_payment = safe_creation_estimate.payment - - number_owners = 8 - payment_token = None - safe_creation_estimate = self.safe_creation_service.estimate_safe_creation(number_owners, payment_token) - self.assertGreater(safe_creation_estimate.gas, 0) - self.assertEqual(safe_creation_estimate.gas_price, gas_price) - self.assertGreater(safe_creation_estimate.payment, estimated_payment) - self.assertEqual(safe_creation_estimate.payment_token, NULL_ADDRESS) - - payment_token = get_eth_address_with_key()[0] - with self.assertRaisesMessage(InvalidPaymentToken, payment_token): - self.safe_creation_service.estimate_safe_creation(number_owners, payment_token) - - number_tokens = 1000 - owner = Account.create() - erc20 = self.deploy_example_erc20(number_tokens, owner.address) - number_owners = 4 - payment_token = erc20.address - payment_token_db = TokenFactory(address=payment_token, fixed_eth_conversion=0.1) - safe_creation_estimate = self.safe_creation_service.estimate_safe_creation(number_owners, payment_token) - self.assertGreater(safe_creation_estimate.gas, 0) - self.assertEqual(safe_creation_estimate.gas_price, gas_price) - self.assertGreater(safe_creation_estimate.payment, estimated_payment) - self.assertEqual(safe_creation_estimate.payment_token, payment_token) - def test_estimate_safe_creation2(self): gas_price = self.safe_creation_service._get_configured_gas_price() @@ -149,6 +113,9 @@ def test_retrieve_safe_info(self): self.safe_creation_service.retrieve_safe_info(fake_safe_address) threshold = 1 - safe_address = self.deploy_test_safe(threshold=threshold).safe_address + random_fallback_handler = Account.create().address + safe_address = self.deploy_test_safe(threshold=threshold, + fallback_handler=random_fallback_handler).safe_address safe_info = self.safe_creation_service.retrieve_safe_info(safe_address) self.assertEqual(safe_info.threshold, threshold) + self.assertEqual(safe_info.fallback_handler, random_fallback_handler) diff --git a/safe_relay_service/relay/tests/test_tasks.py b/safe_relay_service/relay/tests/test_tasks.py index c46a4d46..bf11638e 100644 --- a/safe_relay_service/relay/tests/test_tasks.py +++ b/safe_relay_service/relay/tests/test_tasks.py @@ -5,15 +5,19 @@ from django.test import TestCase from django.utils import timezone -from ..models import SafeContract, SafeFunding +from eth_account import Account + +from ..models import EthereumTx, SafeContract, SafeFunding from ..services import Erc20EventsServiceProvider, InternalTxServiceProvider -from ..tasks import (check_balance_of_accounts_task, - check_deployer_funded_task, deploy_create2_safe_task, - deploy_safes_task, find_erc_20_721_transfers_task, - find_internal_txs_task, fund_deployer_task) +from ..tasks import (check_and_update_pending_transactions, + check_balance_of_accounts_task, + check_deployer_funded_task, check_pending_transactions, + deploy_create2_safe_task, deploy_safes_task, + find_erc_20_721_transfers_task, find_internal_txs_task, + fund_deployer_task) from .factories import (SafeContractFactory, SafeCreation2Factory, SafeCreationFactory, SafeFundingFactory, - SafeTxStatusFactory) + SafeMultisigTxFactory, SafeTxStatusFactory) from .relay_test_case import RelayTestCaseMixin from .test_internal_tx_service import EthereumClientMock @@ -23,191 +27,6 @@ class TestTasks(RelayTestCaseMixin, TestCase): - def test_balance_in_deployer(self): - safe_creation = self.create_test_safe_in_db() - safe, deployer, payment = safe_creation.safe.address, safe_creation.deployer, safe_creation.payment - - self.send_ether(to=safe, value=payment) - - # If deployer has balance already no ether is sent to the account - deployer_payment = 1 - self.send_ether(to=deployer, value=deployer_payment) - - self.assertEqual(self.ethereum_client.get_balance(deployer), deployer_payment) - - fund_deployer_task.delay(safe, retry=False).get() - - self.assertEqual(self.ethereum_client.get_balance(deployer), deployer_payment) - - def test_deploy_safe(self): - safe_creation = self.create_test_safe_in_db() - safe, deployer, payment = safe_creation.safe.address, safe_creation.deployer, safe_creation.payment - - self.send_ether(to=safe, value=payment) - - fund_deployer_task.delay(safe).get() - - safe_funding = SafeFunding.objects.get(safe=safe) - - self.assertTrue(safe_funding.deployer_funded) - self.assertTrue(safe_funding.safe_funded) - self.assertFalse(safe_funding.safe_deployed) - self.assertIsNone(safe_funding.safe_deployed_tx_hash) - - # Safe code is not deployed - self.assertEqual(self.w3.eth.getCode(safe), b'') - - # This task will check safes with deployer funded and confirmed and send safe raw contract creation tx - deploy_safes_task.delay().get() - - safe_funding.refresh_from_db() - self.assertTrue(safe_funding.safe_deployed_tx_hash) - self.assertFalse(safe_funding.safe_deployed) - - # This time task will check the tx_hash for the safe - deploy_safes_task.delay().get() - - safe_funding.refresh_from_db() - self.assertTrue(safe_funding.safe_deployed_tx_hash) - self.assertTrue(safe_funding.safe_deployed) - - # Safe code is deployed - self.assertTrue(len(self.w3.eth.getCode(safe)) > 10) - - # Nothing happens if safe is funded - fund_deployer_task.delay(safe).get() - - # Check deployer tx is checked again - old_deployer_tx_hash = safe_funding.deployer_funded_tx_hash - safe_funding.deployer_funded = False - safe_funding.save() - fund_deployer_task.delay(safe).get() - - safe_funding.refresh_from_db() - self.assertEqual(old_deployer_tx_hash, safe_funding.deployer_funded_tx_hash) - self.assertTrue(safe_funding.deployer_funded) - - def test_safe_with_no_funds(self): - safe_creation = self.create_test_safe_in_db() - safe, deployer, payment = safe_creation.safe.address, safe_creation.deployer, safe_creation.payment - - self.assertEqual(self.ethereum_client.get_balance(deployer), 0) - # No ether is sent to the deployer is safe is empty - fund_deployer_task.delay(safe, retry=False).get() - self.assertEqual(self.ethereum_client.get_balance(deployer), 0) - - # No ether is sent to the deployer is safe has less balance than needed - self.send_ether(to=safe, value=payment - 1) - fund_deployer_task.delay(safe, retry=False).get() - self.assertEqual(self.ethereum_client.get_balance(deployer), 0) - - def test_check_deployer_funded(self): - safe_creation = self.create_test_safe_in_db() - safe, deployer, payment = safe_creation.safe.address, safe_creation.deployer, safe_creation.payment - - safe_contract = SafeContract.objects.get(address=safe) - safe_funding = SafeFunding.objects.create(safe=safe_contract) - - safe_funding.safe_funded = True - safe_funding.deployer_funded_tx_hash = self.w3.sha3(0).hex() - safe_funding.save() - - # If tx hash is not found should be deleted from database - check_deployer_funded_task.delay(safe, retry=False).get() - - safe_funding.refresh_from_db() - self.assertFalse(safe_funding.deployer_funded_tx_hash) - - def test_reorg_before_safe_deploy(self): - safe_creation = self.create_test_safe_in_db() - safe, deployer, payment = safe_creation.safe.address, safe_creation.deployer, safe_creation.payment - - self.send_ether(to=safe, value=payment) - - fund_deployer_task.delay(safe).get() - check_deployer_funded_task.delay(safe).get() - - safe_funding = SafeFunding.objects.get(safe=safe) - - self.assertTrue(safe_funding.safe_funded) - self.assertTrue(safe_funding.deployer_funded_tx_hash) - self.assertTrue(safe_funding.deployer_funded) - self.assertFalse(safe_funding.safe_deployed) - - # Set invalid tx_hash for deployer funding tx - safe_funding.deployer_funded_tx_hash = self.w3.sha3(0).hex() - safe_funding.save() - - deploy_safes_task.delay(retry=False).get() - - safe_funding.refresh_from_db() - - # Safe is deployed even if deployer tx is not valid, if balance is found - self.assertTrue(safe_funding.safe_funded) - self.assertTrue(safe_funding.deployer_funded_tx_hash) - self.assertTrue(safe_funding.deployer_funded) - self.assertTrue(safe_funding.safe_deployed_tx_hash) - self.assertFalse(safe_funding.safe_deployed) - - # Try to deploy safe with no balance and invalid tx-hash. It will not be deployed and - # `deployer_funded` will be set to `False` and `deployer_funded_tx_hash` to `None` - safe_creation = SafeCreationFactory() - safe_funding = SafeFundingFactory(safe=safe_creation.safe, - safe_funded=True, - deployer_funded=True, - deployer_funded_tx_hash=self.w3.sha3(1).hex()) - - self.assertTrue(safe_funding.safe_funded) - self.assertTrue(safe_funding.deployer_funded) - self.assertTrue(safe_funding.deployer_funded_tx_hash) - self.assertFalse(safe_funding.safe_deployed_tx_hash) - self.assertFalse(safe_funding.safe_deployed) - - deploy_safes_task.delay(retry=False).get() - safe_funding.refresh_from_db() - self.assertTrue(safe_funding.safe_funded) - self.assertFalse(safe_funding.deployer_funded) - self.assertFalse(safe_funding.deployer_funded_tx_hash) - self.assertFalse(safe_funding.safe_deployed_tx_hash) - self.assertFalse(safe_funding.safe_deployed) - - def test_reorg_after_safe_deployed(self): - safe_creation = self.create_test_safe_in_db() - safe, deployer, payment = safe_creation.safe.address, safe_creation.deployer, safe_creation.payment - - self.send_ether(to=safe, value=payment) - - fund_deployer_task.delay(safe).get() - check_deployer_funded_task.delay(safe).get() - deploy_safes_task.delay().get() - - safe_funding = SafeFunding.objects.get(safe=safe) - - self.assertTrue(safe_funding.safe_deployed_tx_hash) - self.assertFalse(safe_funding.safe_deployed) - - # Set an invalid tx - safe_funding.safe_deployed_tx_hash = self.w3.sha3(0).hex() - safe_funding.save() - - # If tx is not found before 10 minutes, nothing should happen - deploy_safes_task.delay().get() - - safe_funding.refresh_from_db() - self.assertTrue(safe_funding.safe_deployed_tx_hash) - self.assertFalse(safe_funding.safe_deployed) - - # If tx is not found after 10 minutes, safe will be marked to deploy again - SafeFunding.objects.update(modified=timezone.now() - timedelta(minutes=11)) - deploy_safes_task.delay().get() - - safe_funding.refresh_from_db() - self.assertFalse(safe_funding.safe_deployed_tx_hash) - self.assertFalse(safe_funding.safe_deployed) - - # No error when trying to deploy again the contract - deploy_safes_task.delay().get() - def test_deploy_create2_safe_task(self): safe_creation2 = self.create2_test_safe_in_db() @@ -266,3 +85,27 @@ def test_find_erc_20_721_transfers_task(self): SafeTxStatusFactory(safe=safe) self.assertEqual(find_erc_20_721_transfers_task.delay().get(), 1) Erc20EventsServiceProvider.del_singleton() + + def test_check_pending_transactions(self): + not_mined_alert_minutes = settings.SAFE_TX_NOT_MINED_ALERT_MINUTES + self.assertEqual(check_pending_transactions.delay().get(), 0) + + SafeMultisigTxFactory(created=timezone.now() - timedelta(minutes=not_mined_alert_minutes - 1), + ethereum_tx__block=None) + self.assertEqual(check_pending_transactions.delay().get(), 0) + + SafeMultisigTxFactory(created=timezone.now() - timedelta(minutes=not_mined_alert_minutes + 1), + ethereum_tx__block=None) + self.assertEqual(check_pending_transactions.delay().get(), 1) + + def test_check_and_update_pending_transactions(self): + SafeMultisigTxFactory(created=timezone.now() - timedelta(seconds=16), + ethereum_tx__block=None) + self.assertEqual(check_and_update_pending_transactions.delay().get(), 0) + + tx_hash = self.send_ether(Account.create().address, 1) + SafeMultisigTxFactory(created=timezone.now() - timedelta(seconds=16), + ethereum_tx__tx_hash=tx_hash, + ethereum_tx__block=None) + self.assertEqual(check_and_update_pending_transactions.delay().get(), 1) + self.assertGreaterEqual(EthereumTx.objects.get(tx_hash=tx_hash).block_id, 0) diff --git a/safe_relay_service/relay/tests/test_transaction_service.py b/safe_relay_service/relay/tests/test_transaction_service.py index 53f0e150..f5d1bccc 100644 --- a/safe_relay_service/relay/tests/test_transaction_service.py +++ b/safe_relay_service/relay/tests/test_transaction_service.py @@ -1,4 +1,7 @@ +from datetime import timedelta + from django.test import TestCase +from django.utils import timezone from eth_account import Account from hexbytes import HexBytes @@ -17,7 +20,7 @@ NotEnoughFundsForMultisigTx, RefundMustBeEnabled, SignaturesNotSorted) -from .factories import SafeContractFactory +from .factories import SafeContractFactory, SafeMultisigTxFactory from .relay_test_case import RelayTestCaseMixin @@ -110,7 +113,7 @@ def test_create_multisig_tx(self): NULL_ADDRESS, NULL_ADDRESS, 0 ).buildTransaction({'from': self.ethereum_test_account.address}) - tx_hash = self.ethereum_client.send_unsigned_transaction(proxy_create_tx, private_key=self.ethereum_test_account.privateKey) + tx_hash = self.ethereum_client.send_unsigned_transaction(proxy_create_tx, private_key=self.ethereum_test_account.key) tx_receipt = self.ethereum_client.get_transaction_receipt(tx_hash, timeout=60) proxy_address = tx_receipt.contractAddress with self.assertRaises(InvalidMasterCopyAddress): @@ -370,3 +373,18 @@ def test_estimate_tx_for_all_tokent(self): self.assertAlmostEqual(estimation_token.gas_price, estimation_ether.gas_price // 2, delta=1.0) self.assertGreater(estimation_token.base_gas, estimation_ether.base_gas) self.assertEqual(estimation_token.gas_token, valid_token.address) + + def test_get_pending_multisig_transactions(self): + self.assertFalse(self.transaction_service.get_pending_multisig_transactions(0)) + + SafeMultisigTxFactory(created=timezone.now()) + self.assertFalse(self.transaction_service.get_pending_multisig_transactions(0)) + + SafeMultisigTxFactory(created=timezone.now(), ethereum_tx__block=None) + self.assertEqual(self.transaction_service.get_pending_multisig_transactions(0).count(), 1) + self.assertFalse(self.transaction_service.get_pending_multisig_transactions(30)) + + SafeMultisigTxFactory(created=timezone.now() - timedelta(seconds=60), ethereum_tx__block=None) + self.assertEqual(self.transaction_service.get_pending_multisig_transactions(30).count(), 1) + SafeMultisigTxFactory(created=timezone.now() - timedelta(minutes=60), ethereum_tx__block=None) + self.assertEqual(self.transaction_service.get_pending_multisig_transactions(30).count(), 2) diff --git a/safe_relay_service/relay/tests/test_views.py b/safe_relay_service/relay/tests/test_views.py index d38b576f..6cee587a 100644 --- a/safe_relay_service/relay/tests/test_views.py +++ b/safe_relay_service/relay/tests/test_views.py @@ -6,7 +6,6 @@ from django.utils import dateparse, timezone from eth_account import Account -from ethereum.utils import check_checksum from faker import Faker from rest_framework import status from rest_framework.test import APITestCase @@ -16,18 +15,14 @@ get_eth_address_with_key) from gnosis.safe import SafeOperation, SafeTx from gnosis.safe.signatures import signatures_to_bytes -from gnosis.safe.tests.utils import generate_valid_s from safe_relay_service.gas_station.tests.factories import GasPriceFactory from safe_relay_service.tokens.tests.factories import TokenFactory -from ..models import SafeContract, SafeCreation, SafeMultisigTx -from ..serializers import SafeCreationSerializer -from ..services.safe_creation_service import SafeCreationServiceProvider +from ..models import SafeMultisigTx from .factories import (EthereumEventFactory, EthereumTxFactory, InternalTxFactory, SafeContractFactory, - SafeCreation2Factory, SafeFundingFactory, - SafeMultisigTxFactory) + SafeCreation2Factory, SafeMultisigTxFactory) from .relay_test_case import RelayTestCaseMixin faker = Faker() @@ -43,6 +38,7 @@ def test_about(self): def test_gas_station(self): response = self.client.get(reverse('v1:gas-station')) self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(response.has_header('Cache-Control')) def test_gas_station_history(self): response = self.client.get(reverse('v1:gas-station-history'), format='json') @@ -96,218 +92,6 @@ def test_safe_balances(self): self.assertCountEqual(response.json(), [{'tokenAddress': None, 'balance': str(value)}, {'tokenAddress': erc20.address, 'balance': str(tokens_value)}]) - def test_safe_creation(self): - s = generate_valid_s() - owner1, _ = get_eth_address_with_key() - owner2, _ = get_eth_address_with_key() - serializer = SafeCreationSerializer(data={ - 's': s, - 'owners': [owner1, owner2], - 'threshold': 2 - }) - self.assertTrue(serializer.is_valid()) - response = self.client.post(reverse('v1:safe-creation'), data=serializer.data, format='json') - response_json = response.json() - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - deployer = response_json['deployer'] - self.assertTrue(check_checksum(deployer)) - self.assertTrue(check_checksum(response_json['safe'])) - self.assertTrue(check_checksum(response_json['funder'])) - self.assertEqual(response_json['paymentToken'], NULL_ADDRESS) - self.assertGreater(int(response_json['payment']), 0) - - self.assertTrue(SafeContract.objects.filter(address=response.data['safe'])) - self.assertTrue(SafeCreation.objects.filter(owners__contains=[owner1])) - safe_creation = SafeCreation.objects.get(deployer=deployer) - self.assertEqual(safe_creation.payment_token, None) - # Payment includes deployment gas + gas to send eth to the deployer - self.assertGreater(safe_creation.payment, safe_creation.wei_deploy_cost()) - - serializer = SafeCreationSerializer(data={ - 's': -1, - 'owners': [owner1, owner2], - 'threshold': 2 - }) - self.assertFalse(serializer.is_valid()) - response = self.client.post(reverse('v1:safe-creation'), data=serializer.data, format='json') - self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY) - - def test_safe_creation_with_fixed_cost(self): - s = generate_valid_s() - owner1, _ = get_eth_address_with_key() - owner2, _ = get_eth_address_with_key() - serializer = SafeCreationSerializer(data={ - 's': s, - 'owners': [owner1, owner2], - 'threshold': 2 - }) - self.assertTrue(serializer.is_valid()) - fixed_creation_cost = 123 - with self.settings(SAFE_FIXED_CREATION_COST=fixed_creation_cost): - SafeCreationServiceProvider.del_singleton() - response = self.client.post(reverse('v1:safe-creation'), data=serializer.data, format='json') - response_json = response.json() - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - deployer = response_json['deployer'] - self.assertTrue(check_checksum(deployer)) - self.assertTrue(check_checksum(response_json['safe'])) - self.assertTrue(check_checksum(response_json['funder'])) - self.assertEqual(response_json['paymentToken'], NULL_ADDRESS) - self.assertEqual(int(response_json['payment']), fixed_creation_cost) - - safe_creation = SafeCreation.objects.get(deployer=deployer) - self.assertEqual(safe_creation.payment_token, None) - self.assertEqual(safe_creation.payment, fixed_creation_cost) - self.assertGreater(safe_creation.wei_deploy_cost(), safe_creation.payment) - SafeCreationServiceProvider.del_singleton() - - def test_safe_creation_with_payment_token(self): - s = generate_valid_s() - owner1, _ = get_eth_address_with_key() - owner2, _ = get_eth_address_with_key() - payment_token, _ = get_eth_address_with_key() - serializer = SafeCreationSerializer(data={ - 's': s, - 'owners': [owner1, owner2], - 'threshold': 2, - 'payment_token': payment_token, - }) - self.assertTrue(serializer.is_valid()) - response = self.client.post(reverse('v1:safe-creation'), data=serializer.data, format='json') - self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY) - response_json = response.json() - self.assertIn('InvalidPaymentToken', response_json['exception']) - self.assertIn(payment_token, response_json['exception']) - - # With previous versions of ganache it failed, because token was on DB but not in blockchain, - # so gas cannot be estimated. With new versions of ganache estimation is working - token_model = TokenFactory(address=payment_token, fixed_eth_conversion=0.1) - response = self.client.post(reverse('v1:safe-creation'), data=serializer.data, format='json') - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - - erc20_contract = self.deploy_example_erc20(10000, NULL_ADDRESS) - payment_token = erc20_contract.address - serializer = SafeCreationSerializer(data={ - 's': s, - 'owners': [owner1, owner2], - 'threshold': 2, - 'payment_token': payment_token, - }) - self.assertTrue(serializer.is_valid()) - token_model = TokenFactory(address=payment_token, fixed_eth_conversion=0.1) - response = self.client.post(reverse('v1:safe-creation'), data=serializer.data, format='json') - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - - response_json = response.json() - deployer = response_json['deployer'] - self.assertTrue(check_checksum(deployer)) - self.assertTrue(check_checksum(response_json['safe'])) - self.assertEqual(response_json['paymentToken'], payment_token) - - self.assertTrue(SafeContract.objects.filter(address=response.data['safe'])) - safe_creation = SafeCreation.objects.get(deployer=deployer) - self.assertIn(owner1, safe_creation.owners) - self.assertEqual(safe_creation.payment_token, payment_token) - self.assertGreater(safe_creation.payment, safe_creation.wei_deploy_cost()) - - # Check that payment is more than with ether - token_payment = response_json['payment'] - serializer = SafeCreationSerializer(data={ - 's': s, - 'owners': [owner1, owner2], - 'threshold': 2, - }) - self.assertTrue(serializer.is_valid()) - response = self.client.post(reverse('v1:safe-creation'), data=serializer.data, format='json') - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - response_json = response.json() - payment_using_ether = response_json['payment'] - self.assertGreater(token_payment, payment_using_ether) - - # Check that token with fixed conversion price to 1 is a little higher than with ether - # (We need to pay for storage for token transfer, as funder does not own any token yet) - erc20_contract = self.deploy_example_erc20(10000, NULL_ADDRESS) - payment_token = erc20_contract.address - token_model = TokenFactory(address=payment_token, fixed_eth_conversion=1) - serializer = SafeCreationSerializer(data={ - 's': s, - 'owners': [owner1, owner2], - 'threshold': 2, - 'payment_token': payment_token - }) - self.assertTrue(serializer.is_valid()) - response = self.client.post(reverse('v1:safe-creation'), data=serializer.data, format='json') - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - response_json = response.json() - deployer = response_json['deployer'] - payment_using_token = response_json['payment'] - self.assertGreater(payment_using_token, payment_using_ether) - safe_creation = SafeCreation.objects.get(deployer=deployer) - # Payment includes also the gas to send ether to the safe deployer - self.assertGreater(safe_creation.payment, safe_creation.wei_deploy_cost()) - - def test_safe_creation_estimate(self): - data = { - 'number_owners': 4, - 'payment_token': None, - } - - response = self.client.post(reverse('v1:safe-creation-estimate'), data=data, format='json') - response_json = response.json() - for field in ['payment', 'gasPrice', 'gas']: - self.assertIn(field, response_json) - self.assertGreater(int(response_json[field]), 0) - estimated_payment = response_json['payment'] - - # With payment token - erc20_contract = self.deploy_example_erc20(10000, NULL_ADDRESS) - payment_token = erc20_contract.address - token_model = TokenFactory(address=payment_token, gas=True, fixed_eth_conversion=0.1) - data = { - 'number_owners': 4, - 'payment_token': payment_token, - } - - response = self.client.post(reverse('v1:safe-creation-estimate'), data=data, format='json') - response_json = response.json() - for field in ['payment', 'gasPrice', 'gas']: - self.assertIn(field, response_json) - self.assertGreater(int(response_json[field]), 0) - self.assertGreater(response_json['payment'], estimated_payment) - - def test_safe_view(self): - owners_with_keys = [get_eth_address_with_key(), - get_eth_address_with_key(), - get_eth_address_with_key()] - owners = [x[0] for x in owners_with_keys] - threshold = len(owners) - 1 - safe_creation = self.deploy_test_safe(owners=owners, threshold=threshold) - my_safe_address = safe_creation.safe_address - SafeContractFactory(address=my_safe_address) - SafeFundingFactory(safe=SafeContract.objects.get(address=my_safe_address), safe_deployed=True) - response = self.client.get(reverse('v1:safe', args=(my_safe_address,)), format='json') - self.assertEqual(response.status_code, status.HTTP_200_OK) - safe_json = response.json() - self.assertEqual(safe_json['address'], my_safe_address) - self.assertEqual(safe_json['masterCopy'], self.safe_contract_address) - self.assertEqual(safe_json['nonce'], 0) - self.assertEqual(safe_json['threshold'], threshold) - self.assertEqual(safe_json['owners'], owners) - self.assertIn('version', safe_json) - - random_address, _ = get_eth_address_with_key() - response = self.client.get(reverse('v1:safe', args=(random_address,)), format='json') - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - response = self.client.get(reverse('v1:safe', args=(my_safe_address + ' ',)), format='json') - self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY) - - response = self.client.get(reverse('v1:safe', args=('0xabfG',)), format='json') - self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY) - - response = self.client.get(reverse('v1:safe', args=('batman',)), format='json') - self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY) - def test_safe_multisig_tx_post(self): # Create Safe ------------------------------------------------ w3 = self.ethereum_client.w3 @@ -540,7 +324,7 @@ def test_safe_multisig_tx_post_gas_token(self): ).safe_tx_hash signatures = [w3.eth.account.signHash(multisig_tx_hash, private_key) - for private_key in [owner_account.privateKey]] + for private_key in [owner_account.key]] signatures_json = [{'v': s['v'], 'r': s['r'], 's': s['s']} for s in signatures] data = { @@ -585,7 +369,7 @@ def test_safe_multisig_tx_errors(self): format='json') self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - my_safe_address = self.create_test_safe_in_db().safe.address + my_safe_address = self.create2_test_safe_in_db().safe.address response = self.client.post(reverse('v1:safe-multisig-txs', args=(my_safe_address,)), data={}, format='json') @@ -699,23 +483,6 @@ def test_safe_multisig_tx_estimates(self): self.assertAlmostEqual(int(estimation_token['gasPrice']), int(estimation_ether['gasPrice']) // 2, delta=1.0) self.assertEqual(estimation_token['gasToken'], valid_token.address) - def test_get_safe_signal(self): - safe_address, _ = get_eth_address_with_key() - - response = self.client.get(reverse('v1:safe-signal', args=(safe_address,))) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - invalid_address = get_eth_address_with_invalid_checksum() - - response = self.client.get(reverse('v1:safe-signal', args=(invalid_address,))) - self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY) - - my_safe_address = self.create_test_safe_in_db().safe.address - response = self.client.post(reverse('v1:safe-multisig-txs', args=(my_safe_address,)), - data={}, - format='json') - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - def test_get_all_txs(self): safe_address = Account().create().address SafeContractFactory(address=safe_address) diff --git a/safe_relay_service/relay/tests/test_views_v2.py b/safe_relay_service/relay/tests/test_views_v2.py index c90b7b45..af73e979 100644 --- a/safe_relay_service/relay/tests/test_views_v2.py +++ b/safe_relay_service/relay/tests/test_views_v2.py @@ -1,5 +1,6 @@ import logging +from django.conf import settings from django.urls import reverse from eth_account import Account @@ -10,12 +11,13 @@ from gnosis.eth.constants import NULL_ADDRESS from gnosis.eth.utils import get_eth_address_with_invalid_checksum +from gnosis.safe import Safe from gnosis.safe.tests.utils import generate_salt_nonce from safe_relay_service.tokens.tests.factories import TokenFactory from ..models import SafeContract, SafeCreation2 -from ..services.safe_creation_service import SafeCreationServiceProvider +from ..services.safe_creation_service import SafeCreationV1_0_0ServiceProvider from .factories import SafeContractFactory from .relay_test_case import RelayTestCaseMixin @@ -78,6 +80,7 @@ def test_safe_creation(self): self.assertGreater(int(response_json['gasEstimated']), 0) self.assertGreater(int(response_json['gasPriceEstimated']), 0) self.assertGreater(len(response_json['setupData']), 2) + self.assertEqual(response_json['masterCopy'], settings.SAFE_V1_0_0_CONTRACT_ADDRESS) self.assertTrue(SafeContract.objects.filter(address=safe_address)) self.assertTrue(SafeCreation2.objects.filter(owners__contains=[owners[0]])) @@ -86,6 +89,14 @@ def test_safe_creation(self): # Payment includes deployment gas + gas to send eth to the deployer self.assertEqual(safe_creation.payment, safe_creation.wei_estimated_deploy_cost()) + # Deploy the Safe to check it + self.send_ether(safe_address, int(response_json['payment'])) + safe_creation2 = SafeCreationV1_0_0ServiceProvider().deploy_create2_safe_tx(safe_address) + self.ethereum_client.get_transaction_receipt(safe_creation2.tx_hash, timeout=20) + safe = Safe(safe_address, self.ethereum_client) + self.assertEqual(safe.retrieve_master_copy_address(), response_json['masterCopy']) + self.assertEqual(safe.retrieve_owners(), owners) + # Test exception when same Safe is created response = self.client.post(reverse('v2:safe-creation'), data, format='json') self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY) @@ -110,9 +121,9 @@ def test_safe_creation_with_fixed_cost(self): fixed_creation_cost = 123 with self.settings(SAFE_FIXED_CREATION_COST=fixed_creation_cost): - SafeCreationServiceProvider.del_singleton() + SafeCreationV1_0_0ServiceProvider.del_singleton() response = self.client.post(reverse('v2:safe-creation'), data, format='json') - SafeCreationServiceProvider.del_singleton() + SafeCreationV1_0_0ServiceProvider.del_singleton() self.assertEqual(response.status_code, status.HTTP_201_CREATED) response_json = response.json() @@ -120,7 +131,7 @@ def test_safe_creation_with_fixed_cost(self): self.assertTrue(check_checksum(safe_address)) self.assertTrue(check_checksum(response_json['paymentReceiver'])) self.assertEqual(response_json['paymentToken'], NULL_ADDRESS) - self.assertEqual(response_json['payment'], '123') + self.assertEqual(response_json['payment'], str(fixed_creation_cost)) self.assertGreater(int(response_json['gasEstimated']), 0) self.assertGreater(int(response_json['gasPriceEstimated']), 0) self.assertGreater(len(response_json['setupData']), 2) diff --git a/safe_relay_service/relay/tests/test_views_v3.py b/safe_relay_service/relay/tests/test_views_v3.py new file mode 100644 index 00000000..095652d6 --- /dev/null +++ b/safe_relay_service/relay/tests/test_views_v3.py @@ -0,0 +1,175 @@ +import logging + +from django.conf import settings +from django.urls import reverse + +from eth_account import Account +from ethereum.utils import check_checksum +from faker import Faker +from rest_framework import status +from rest_framework.test import APITestCase + +from gnosis.eth.constants import NULL_ADDRESS +from gnosis.safe import Safe +from gnosis.safe.tests.utils import generate_salt_nonce + +from safe_relay_service.tokens.tests.factories import TokenFactory + +from ..models import SafeContract, SafeCreation2 +from ..services.safe_creation_service import SafeCreationServiceProvider +from .relay_test_case import RelayTestCaseMixin + +faker = Faker() + +logger = logging.getLogger(__name__) + + +class TestViewsV3(RelayTestCaseMixin, APITestCase): + def test_safe_creation_estimate(self): + url = reverse('v3:safe-creation-estimates') + number_owners = 4 + data = { + 'numberOwners': number_owners + } + + response = self.client.post(url, data=data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + safe_creation_estimates = response.json() + self.assertEqual(len(safe_creation_estimates), 1) + safe_creation_estimate = safe_creation_estimates[0] + self.assertEqual(safe_creation_estimate['paymentToken'], NULL_ADDRESS) + + token = TokenFactory(gas=True, fixed_eth_conversion=None) + response = self.client.post(url, data=data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + safe_creation_estimates = response.json() + # No price oracles, so no estimation + self.assertEqual(len(safe_creation_estimates), 1) + + fixed_price_token = TokenFactory(gas=True, fixed_eth_conversion=1.0) + response = self.client.post(url, data=data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + safe_creation_estimates = response.json() + # Fixed price oracle, so estimation will work + self.assertEqual(len(safe_creation_estimates), 2) + safe_creation_estimate = safe_creation_estimates[1] + self.assertEqual(safe_creation_estimate['paymentToken'], fixed_price_token.address) + self.assertGreater(int(safe_creation_estimate['payment']), 0) + self.assertGreater(int(safe_creation_estimate['gasPrice']), 0) + self.assertGreater(int(safe_creation_estimate['gas']), 0) + + def test_safe_creation(self): + salt_nonce = generate_salt_nonce() + owners = [Account.create().address for _ in range(2)] + data = { + 'saltNonce': salt_nonce, + 'owners': owners, + 'threshold': len(owners) + } + response = self.client.post(reverse('v3:safe-creation'), data, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + response_json = response.json() + safe_address = response_json['safe'] + self.assertTrue(check_checksum(safe_address)) + self.assertTrue(check_checksum(response_json['paymentReceiver'])) + self.assertEqual(response_json['paymentToken'], NULL_ADDRESS) + self.assertEqual(int(response_json['payment']), + int(response_json['gasEstimated']) * int(response_json['gasPriceEstimated'])) + self.assertGreater(int(response_json['gasEstimated']), 0) + self.assertGreater(int(response_json['gasPriceEstimated']), 0) + self.assertGreater(len(response_json['setupData']), 2) + self.assertEqual(response_json['masterCopy'], settings.SAFE_CONTRACT_ADDRESS) + + self.assertTrue(SafeContract.objects.filter(address=safe_address)) + self.assertTrue(SafeCreation2.objects.filter(owners__contains=[owners[0]])) + safe_creation = SafeCreation2.objects.get(safe=safe_address) + self.assertEqual(safe_creation.payment_token, None) + # Payment includes deployment gas + gas to send eth to the deployer + self.assertEqual(safe_creation.payment, safe_creation.wei_estimated_deploy_cost()) + + # Deploy the Safe to check it + self.send_ether(safe_address, int(response_json['payment'])) + safe_creation2 = SafeCreationServiceProvider().deploy_create2_safe_tx(safe_address) + self.ethereum_client.get_transaction_receipt(safe_creation2.tx_hash, timeout=20) + safe = Safe(safe_address, self.ethereum_client) + self.assertEqual(safe.retrieve_master_copy_address(), response_json['masterCopy']) + self.assertEqual(safe.retrieve_owners(), owners) + + # Test exception when same Safe is created + response = self.client.post(reverse('v3:safe-creation'), data, format='json') + self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY) + self.assertIn('SafeAlreadyExistsException', response.json()['exception']) + + data = { + 'salt_nonce': -1, + 'owners': owners, + 'threshold': 2 + } + response = self.client.post(reverse('v3:safe-creation'), data, format='json') + self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY) + + def test_safe_creation_with_fixed_cost(self): + salt_nonce = generate_salt_nonce() + owners = [Account.create().address for _ in range(2)] + data = { + 'saltNonce': salt_nonce, + 'owners': owners, + 'threshold': len(owners) + } + + fixed_creation_cost = 456 + with self.settings(SAFE_FIXED_CREATION_COST=fixed_creation_cost): + SafeCreationServiceProvider.del_singleton() + response = self.client.post(reverse('v3:safe-creation'), data, format='json') + SafeCreationServiceProvider.del_singleton() + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + response_json = response.json() + safe_address = response_json['safe'] + self.assertTrue(check_checksum(safe_address)) + self.assertTrue(check_checksum(response_json['paymentReceiver'])) + self.assertEqual(response_json['paymentToken'], NULL_ADDRESS) + self.assertEqual(response_json['payment'], str(fixed_creation_cost)) + self.assertGreater(int(response_json['gasEstimated']), 0) + self.assertGreater(int(response_json['gasPriceEstimated']), 0) + self.assertGreater(len(response_json['setupData']), 2) + + def test_safe_creation_with_payment_token(self): + salt_nonce = generate_salt_nonce() + owners = [Account.create().address for _ in range(2)] + payment_token = Account.create().address + data = { + 'saltNonce': salt_nonce, + 'owners': owners, + 'threshold': len(owners), + 'paymentToken': payment_token, + } + + response = self.client.post(reverse('v3:safe-creation'), data=data, format='json') + self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY) + response_json = response.json() + self.assertIn('InvalidPaymentToken', response_json['exception']) + self.assertIn(payment_token, response_json['exception']) + + fixed_eth_conversion = 0.1 + token_model = TokenFactory(address=payment_token, fixed_eth_conversion=fixed_eth_conversion) + response = self.client.post(reverse('v3:safe-creation'), data, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + response_json = response.json() + safe_address = response_json['safe'] + self.assertTrue(check_checksum(safe_address)) + self.assertTrue(check_checksum(response_json['paymentReceiver'])) + self.assertEqual(response_json['paymentToken'], payment_token) + self.assertEqual(int(response_json['payment']), + int(response_json['gasEstimated']) * int(response_json['gasPriceEstimated']) * + (1 / fixed_eth_conversion)) + self.assertGreater(int(response_json['gasEstimated']), 0) + self.assertGreater(int(response_json['gasPriceEstimated']), 0) + self.assertGreater(len(response_json['setupData']), 2) + + self.assertTrue(SafeContract.objects.filter(address=safe_address)) + self.assertTrue(SafeCreation2.objects.filter(owners__contains=[owners[0]])) + safe_creation = SafeCreation2.objects.get(safe=safe_address) + self.assertEqual(safe_creation.payment_token, payment_token) + # Payment includes deployment gas + gas to send eth to the deployer + self.assertEqual(safe_creation.payment, safe_creation.wei_estimated_deploy_cost() * (1 / fixed_eth_conversion)) diff --git a/safe_relay_service/relay/urls.py b/safe_relay_service/relay/urls.py index ab546441..ec5bb084 100644 --- a/safe_relay_service/relay/urls.py +++ b/safe_relay_service/relay/urls.py @@ -19,8 +19,6 @@ path('gas-station/history/', GasStationHistoryView.as_view(), name='gas-station-history'), path('tokens/', TokensView.as_view(), name='tokens'), path('tokens//', TokenView.as_view(), name='tokens'), - path('safes/', views.SafeCreationView.as_view(), name='safe-creation'), - path('safes/estimate/', views.SafeCreationEstimateView.as_view(), name='safe-creation-estimate'), path('safes//', views.SafeView.as_view(), name='safe'), path('safes//balances/', views.SafeBalanceView.as_view(), name='safe-balances'), path('safes//funded/', views.SafeSignalView.as_view(), name='safe-signal'), diff --git a/safe_relay_service/relay/urls_v3.py b/safe_relay_service/relay/urls_v3.py new file mode 100644 index 00000000..5735fffe --- /dev/null +++ b/safe_relay_service/relay/urls_v3.py @@ -0,0 +1,12 @@ +from django.urls import path + +from . import views_v3 + +app_name = "safe" + +timestamp_regex = '\\d{4}[-]?\\d{1,2}[-]?\\d{1,2} \\d{1,2}:\\d{1,2}:\\d{1,2}' + +urlpatterns = [ + path('safes/', views_v3.SafeCreationView.as_view(), name='safe-creation'), + path('safes/estimates/', views_v3.SafeCreationEstimateView.as_view(), name='safe-creation-estimates'), +] diff --git a/safe_relay_service/relay/views.py b/safe_relay_service/relay/views.py index 9dad4287..c1e7d73f 100644 --- a/safe_relay_service/relay/views.py +++ b/safe_relay_service/relay/views.py @@ -29,8 +29,7 @@ from .serializers import ( ERC20Serializer, ERC721Serializer, EthereumTxWithInternalTxsSerializer, InternalTxWithEthereumTxSerializer, SafeBalanceResponseSerializer, - SafeContractSerializer, SafeCreationEstimateResponseSerializer, - SafeCreationEstimateSerializer, SafeCreationResponseSerializer, + SafeContractSerializer, SafeCreationResponseSerializer, SafeCreationSerializer, SafeFundingResponseSerializer, SafeMultisigEstimateTxResponseSerializer, SafeMultisigTxResponseSerializer, SafeRelayMultisigTxSerializer, SafeResponseSerializer, @@ -39,8 +38,7 @@ from .services.funding_service import FundingServiceException from .services.safe_creation_service import (SafeCreationServiceException, SafeCreationServiceProvider) -from .services.transaction_service import (SafeMultisigTxExists, - TransactionServiceException, +from .services.transaction_service import (TransactionServiceException, TransactionServiceProvider) from .tasks import fund_deployer_task @@ -66,9 +64,10 @@ def custom_exception_handler(exc, context): exception_str = exc.__class__.__name__ response.data = {'exception': exception_str} - logger.warning('%s - Exception: %s - Data received %s' % (context['request'].build_absolute_uri(), - exception_str, - context['request'].data)) + logger.warning('%s - Exception: %s - Data received %s', + context['request'].build_absolute_uri(), + exception_str, + context['request'].data) return response @@ -76,9 +75,9 @@ class AboutView(APIView): renderer_classes = (JSONRenderer,) def get(self, request, format=None): - safe_funder_public_key = Account.privateKeyToAccount(settings.SAFE_FUNDER_PRIVATE_KEY).address \ + safe_funder_public_key = Account.from_key(settings.SAFE_FUNDER_PRIVATE_KEY).address \ if settings.SAFE_FUNDER_PRIVATE_KEY else None - safe_sender_public_key = Account.privateKeyToAccount(settings.SAFE_TX_SENDER_PRIVATE_KEY).address \ + safe_sender_public_key = Account.from_key(settings.SAFE_TX_SENDER_PRIVATE_KEY).address \ if settings.SAFE_TX_SENDER_PRIVATE_KEY else None content = { 'name': 'Safe Relay Service', @@ -97,13 +96,17 @@ def get(self, request, format=None): 'SAFE_CHECK_DEPLOYER_FUNDED_DELAY': settings.SAFE_CHECK_DEPLOYER_FUNDED_DELAY, 'SAFE_CHECK_DEPLOYER_FUNDED_RETRIES': settings.SAFE_CHECK_DEPLOYER_FUNDED_RETRIES, 'SAFE_CONTRACT_ADDRESS': settings.SAFE_CONTRACT_ADDRESS, + 'SAFE_DEFAULT_CALLBACK_HANDLER': settings.SAFE_DEFAULT_CALLBACK_HANDLER, 'SAFE_FIXED_CREATION_COST': settings.SAFE_FIXED_CREATION_COST, 'SAFE_FUNDER_MAX_ETH': settings.SAFE_FUNDER_MAX_ETH, 'SAFE_FUNDER_PUBLIC_KEY': safe_funder_public_key, 'SAFE_FUNDING_CONFIRMATIONS': settings.SAFE_FUNDING_CONFIRMATIONS, - 'SAFE_OLD_CONTRACT_ADDRESS': settings.SAFE_OLD_CONTRACT_ADDRESS, 'SAFE_PROXY_FACTORY_ADDRESS': settings.SAFE_PROXY_FACTORY_ADDRESS, + 'SAFE_PROXY_FACTORY_V1_0_0_ADDRESS': settings.SAFE_PROXY_FACTORY_V1_0_0_ADDRESS, + 'SAFE_TX_NOT_MINED_ALERT_MINUTES': settings.SAFE_TX_NOT_MINED_ALERT_MINUTES, 'SAFE_TX_SENDER_PUBLIC_KEY': safe_sender_public_key, + 'SAFE_V0_0_1_CONTRACT_ADDRESS': settings.SAFE_V0_0_1_CONTRACT_ADDRESS, + 'SAFE_V1_0_0_CONTRACT_ADDRESS': settings.SAFE_V1_0_0_CONTRACT_ADDRESS, 'SAFE_VALID_CONTRACT_ADDRESSES': settings.SAFE_VALID_CONTRACT_ADDRESSES, } } @@ -156,27 +159,6 @@ def post(self, request, *args, **kwargs): return Response(status=http_status, data=serializer.errors) -class SafeCreationEstimateView(CreateAPIView): - permission_classes = (AllowAny,) - serializer_class = SafeCreationEstimateSerializer - - @swagger_auto_schema(responses={201: SafeCreationEstimateResponseSerializer(), - 400: 'Invalid data', - 422: 'Cannot process data'}) - def post(self, request, *args, **kwargs): - """ - Estimates creation of a Safe - """ - serializer = self.serializer_class(data=request.data) - if serializer.is_valid(): - number_owners, payment_token = serializer.data['number_owners'], serializer.data['payment_token'] - safe_creation_estimate = SafeCreationServiceProvider().estimate_safe_creation(number_owners, payment_token) - safe_creation_estimate_response_data = SafeCreationEstimateResponseSerializer(safe_creation_estimate) - return Response(status=status.HTTP_200_OK, data=safe_creation_estimate_response_data.data) - else: - return Response(status=status.HTTP_422_UNPROCESSABLE_ENTITY, data=serializer.errors) - - class SafeView(APIView): permission_classes = (AllowAny,) serializer_class = SafeResponseSerializer diff --git a/safe_relay_service/relay/views_v2.py b/safe_relay_service/relay/views_v2.py index 7be97764..e15d2524 100644 --- a/safe_relay_service/relay/views_v2.py +++ b/safe_relay_service/relay/views_v2.py @@ -3,7 +3,7 @@ from drf_yasg.utils import swagger_auto_schema from hexbytes import HexBytes from rest_framework import status -from rest_framework.generics import CreateAPIView, ListAPIView +from rest_framework.generics import CreateAPIView from rest_framework.permissions import AllowAny from rest_framework.response import Response from rest_framework.views import APIView @@ -20,9 +20,8 @@ SafeCreationEstimateResponseSerializer, SafeCreationEstimateV2Serializer, SafeFunding2ResponseSerializer, - SafeMultisigEstimateTxResponseSerializer, SafeMultisigEstimateTxResponseV2Serializer) -from .services.safe_creation_service import SafeCreationServiceProvider +from .services.safe_creation_service import SafeCreationV1_0_0ServiceProvider from .tasks import deploy_create2_safe_task logger = getLogger(__name__) @@ -42,7 +41,7 @@ def post(self, request, *args, **kwargs): serializer = self.serializer_class(data=request.data) if serializer.is_valid(): number_owners = serializer.data['number_owners'] - safe_creation_estimates = SafeCreationServiceProvider().estimate_safe_creation_for_all_tokens(number_owners) + safe_creation_estimates = SafeCreationV1_0_0ServiceProvider().estimate_safe_creation_for_all_tokens(number_owners) safe_creation_estimate_response_data = SafeCreationEstimateResponseSerializer(safe_creation_estimates, many=True) return Response(status=status.HTTP_200_OK, data=safe_creation_estimate_response_data.data) @@ -70,7 +69,7 @@ def post(self, request, *args, **kwargs): serializer.data['to'], serializer.data['callback']) - safe_creation_service = SafeCreationServiceProvider() + safe_creation_service = SafeCreationV1_0_0ServiceProvider() safe_creation = safe_creation_service.create2_safe_tx(salt_nonce, owners, threshold, payment_token, setup_data, to, callback) safe_creation_response_data = SafeCreation2ResponseSerializer(data={ diff --git a/safe_relay_service/relay/views_v3.py b/safe_relay_service/relay/views_v3.py new file mode 100644 index 00000000..1a5e33b1 --- /dev/null +++ b/safe_relay_service/relay/views_v3.py @@ -0,0 +1,77 @@ +from logging import getLogger + +from drf_yasg.utils import swagger_auto_schema +from hexbytes import HexBytes +from rest_framework import status +from rest_framework.generics import CreateAPIView +from rest_framework.permissions import AllowAny +from rest_framework.response import Response + +from gnosis.eth.constants import NULL_ADDRESS + +from .serializers import (SafeCreation2ResponseSerializer, + SafeCreation2Serializer, + SafeCreationEstimateResponseSerializer, + SafeCreationEstimateV2Serializer) +from .services.safe_creation_service import SafeCreationServiceProvider + +logger = getLogger(__name__) + + +class SafeCreationEstimateView(CreateAPIView): + permission_classes = (AllowAny,) + serializer_class = SafeCreationEstimateV2Serializer + + @swagger_auto_schema(responses={201: SafeCreationEstimateResponseSerializer(), + 400: 'Invalid data', + 422: 'Cannot process data'}) + def post(self, request, *args, **kwargs): + """ + Estimates creation of a Safe + """ + serializer = self.serializer_class(data=request.data) + if serializer.is_valid(): + number_owners = serializer.data['number_owners'] + safe_creation_estimates = SafeCreationServiceProvider().estimate_safe_creation_for_all_tokens(number_owners) + safe_creation_estimate_response_data = SafeCreationEstimateResponseSerializer(safe_creation_estimates, + many=True) + return Response(status=status.HTTP_200_OK, data=safe_creation_estimate_response_data.data) + else: + return Response(status=status.HTTP_422_UNPROCESSABLE_ENTITY, data=serializer.errors) + + +class SafeCreationView(CreateAPIView): + permission_classes = (AllowAny,) + serializer_class = SafeCreation2Serializer + + @swagger_auto_schema(responses={201: SafeCreation2ResponseSerializer(), + 400: 'Invalid data', + 422: 'Cannot process data'}) + def post(self, request, *args, **kwargs): + """ + Begins creation of a Safe + """ + serializer = self.serializer_class(data=request.data) + if serializer.is_valid(): + salt_nonce, owners, threshold, payment_token = (serializer.data['salt_nonce'], serializer.data['owners'], + serializer.data['threshold'], + serializer.data['payment_token']) + + safe_creation_service = SafeCreationServiceProvider() + safe_creation = safe_creation_service.create2_safe_tx(salt_nonce, owners, threshold, payment_token) + safe_creation_response_data = SafeCreation2ResponseSerializer(data={ + 'safe': safe_creation.safe.address, + 'master_copy': safe_creation.master_copy, + 'proxy_factory': safe_creation.proxy_factory, + 'payment': safe_creation.payment, + 'payment_token': safe_creation.payment_token or NULL_ADDRESS, + 'payment_receiver': safe_creation.payment_receiver or NULL_ADDRESS, + 'setup_data': HexBytes(safe_creation.setup_data).hex(), + 'gas_estimated': safe_creation.gas_estimated, + 'gas_price_estimated': safe_creation.gas_price_estimated, + }) + safe_creation_response_data.is_valid(raise_exception=True) + return Response(status=status.HTTP_201_CREATED, data=safe_creation_response_data.data) + else: + return Response(status=status.HTTP_422_UNPROCESSABLE_ENTITY, + data=serializer.errors) diff --git a/safe_relay_service/tokens/admin.py b/safe_relay_service/tokens/admin.py index fdbc9f50..2398d412 100644 --- a/safe_relay_service/tokens/admin.py +++ b/safe_relay_service/tokens/admin.py @@ -6,7 +6,7 @@ @admin.register(PriceOracle) class PriceOracleAdmin(admin.ModelAdmin): - list_display = ('name', ) + list_display = ('name', 'configuration') ordering = ('name',) diff --git a/safe_relay_service/tokens/migrations/0012_priceoracle_configuration.py b/safe_relay_service/tokens/migrations/0012_priceoracle_configuration.py new file mode 100644 index 00000000..2f9a7fbe --- /dev/null +++ b/safe_relay_service/tokens/migrations/0012_priceoracle_configuration.py @@ -0,0 +1,31 @@ +# Generated by Django 2.2.7 on 2019-11-22 15:07 + +import django.contrib.postgres.fields.jsonb +from django.db import migrations + + +def create_uniswap_price_oracle(apps, schema_editor): + PriceOracle = apps.get_model('tokens', 'PriceOracle') + # Use uniswap mainnet address + PriceOracle.objects.create(name='Uniswap', configuration={'uniswap_exchange_address': + '0xc0a47dFe034B400B47bDaD5FecDa2621de6c4d95'}) + PriceOracle.objects.create(name='Kyber', configuration={'kyber_network_proxy_address': + '0x818E6FECD516Ecc3849DAf6845e3EC868087B755', + 'weth_token_address': + '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'}) + + +class Migration(migrations.Migration): + + dependencies = [ + ('tokens', '0011_auto_20190225_1646'), + ] + + operations = [ + migrations.AddField( + model_name='priceoracle', + name='configuration', + field=django.contrib.postgres.fields.jsonb.JSONField(default=dict), + ), + migrations.RunPython(create_uniswap_price_oracle) + ] diff --git a/safe_relay_service/tokens/models.py b/safe_relay_service/tokens/models.py index 037523da..317936df 100644 --- a/safe_relay_service/tokens/models.py +++ b/safe_relay_service/tokens/models.py @@ -4,6 +4,7 @@ from urllib.parse import urljoin, urlparse from django.conf import settings +from django.contrib.postgres.fields import JSONField from django.db import models from gnosis.eth.django.models import EthereumAddressField @@ -16,9 +17,10 @@ class PriceOracle(models.Model): name = models.CharField(max_length=50, unique=True) + configuration = JSONField(null=False, default=dict) def __str__(self): - return self.name + return f'{self.name} configuration={self.configuration}' class PriceOracleTicker(models.Model): @@ -32,7 +34,7 @@ def __str__(self): def _price(self) -> Optional[float]: try: - price = get_price_oracle(self.price_oracle.name).get_price(self.ticker) + price = get_price_oracle(self.price_oracle.name, self.price_oracle.configuration).get_price(self.ticker) if price and self.inverse: # Avoid 1 / 0 price = 1 / price except ExchangeApiException: diff --git a/safe_relay_service/tokens/price_oracles.py b/safe_relay_service/tokens/price_oracles.py index 3938cf25..e7048d9a 100644 --- a/safe_relay_service/tokens/price_oracles.py +++ b/safe_relay_service/tokens/price_oracles.py @@ -1,9 +1,13 @@ import logging from abc import ABC, abstractmethod +from typing import Any, Dict import requests from cachetools import TTLCache, cached +from gnosis.eth import EthereumClientProvider +from gnosis.eth.oracles import KyberOracle, OracleException, UniswapOracle + logger = logging.getLogger(__name__) @@ -37,7 +41,7 @@ def get_price(self, ticker) -> float: response = requests.get(url) api_json = response.json() if not response.ok: - logger.warning('Cannot get price from url=%s' % url) + logger.warning('Cannot get price from url=%s', url) raise CannotGetTokenPriceFromApi(api_json.get('msg')) return float(api_json['price']) @@ -59,7 +63,7 @@ def get_price(self, ticker: str) -> float: response = requests.get(url) api_json = response.json() if not response.ok or api_json is None: - logger.warning('Cannot get price from url=%s' % url) + logger.warning('Cannot get price from url=%s', url) raise CannotGetTokenPriceFromApi(api_json) return float(api_json) @@ -76,7 +80,7 @@ def get_price(self, ticker) -> float: api_json = response.json() error = api_json.get('err-msg') if not response.ok or error: - logger.warning('Cannot get price from url=%s' % url) + logger.warning('Cannot get price from url=%s', url) raise CannotGetTokenPriceFromApi(error) return float(api_json['tick']['close']) @@ -90,7 +94,7 @@ def get_price(self, ticker) -> float: api_json = response.json() error = api_json.get('error') if not response.ok or error: - logger.warning('Cannot get price from url=%s' % url) + logger.warning('Cannot get price from url=%s', url) raise CannotGetTokenPriceFromApi(str(api_json['error'])) result = api_json['result'] @@ -98,16 +102,54 @@ def get_price(self, ticker) -> float: return float(result[new_ticker]['c'][0]) -def get_price_oracle(name) -> PriceOracle: +class Uniswap(PriceOracle): + def __init__(self, uniswap_exchange_address: str, **kwargs): + self.uniswap_exchange_address = uniswap_exchange_address + + @cached(cache=TTLCache(maxsize=1024, ttl=60)) + def get_price(self, ticker: str) -> float: + """ + :param ticker: Address of the token + :return: price + """ + ethereum_client = EthereumClientProvider() + uniswap = UniswapOracle(ethereum_client, self.uniswap_exchange_address) + try: + return uniswap.get_price(ticker) + except OracleException as e: + raise CannotGetTokenPriceFromApi from e + + +class Kyber(PriceOracle): + def __init__(self, kyber_network_proxy_address: str, **kwargs): + self.kyber_network_proxy_address = kyber_network_proxy_address + + @cached(cache=TTLCache(maxsize=1024, ttl=60)) + def get_price(self, ticker: str) -> float: + """ + :param ticker: Address of the token + :return: price + """ + ethereum_client = EthereumClientProvider() + kyber = KyberOracle(ethereum_client, self.kyber_network_proxy_address) + try: + return kyber.get_price(ticker) + except OracleException as e: + raise CannotGetTokenPriceFromApi from e + + +def get_price_oracle(name: str, configuration: Dict[Any, Any] = {}) -> PriceOracle: oracles = { 'binance': Binance, 'dutchx': DutchX, 'huobi': Huobi, 'kraken': Kraken, + 'kyber': Kyber, + 'uniswap': Uniswap, } oracle = oracles.get(name.lower()) if oracle: - return oracle() + return oracle(**configuration) else: raise NotImplementedError("Oracle '%s' not found" % name) diff --git a/safe_relay_service/tokens/tests/test_exchanges.py b/safe_relay_service/tokens/tests/test_exchanges.py index be3e0bdd..a10a6210 100644 --- a/safe_relay_service/tokens/tests/test_exchanges.py +++ b/safe_relay_service/tokens/tests/test_exchanges.py @@ -35,13 +35,14 @@ def test_binance(self): exchange = Binance() self.exchange_helper(exchange, ['BTCUSDT', 'ETHUSDT'], ['BADTICKER']) + @pytest.mark.xfail def test_dutchx(self): exchange = DutchX() # Dai address is 0x89d24a6b4ccb1b6faa2625fe562bdd9a23260359 # Gno address is 0x6810e776880C02933D47DB1b9fc05908e5386b96 self.exchange_helper(exchange, - [# 'WETH-0x89d24a6b4ccb1b6faa2625fe562bdd9a23260359', - '0x89d24A6b4CcB1B6fAA2625fE562bDD9a23260359-WETH', + [# 'WETH-0x89d24a6b4ccb1b6faa2625fe562bdd9a23260359', # DAI + '0x89d24A6b4CcB1B6fAA2625fE562bDD9a23260359-WETH', # DAI # 'WETH-0x6810e776880C02933D47DB1b9fc05908e5386b96' '0x6810e776880C02933D47DB1b9fc05908e5386b96-WETH', ], diff --git a/safe_relay_service/tokens/tests/test_models.py b/safe_relay_service/tokens/tests/test_models.py index 273010ee..95291003 100644 --- a/safe_relay_service/tokens/tests/test_models.py +++ b/safe_relay_service/tokens/tests/test_models.py @@ -12,7 +12,7 @@ class TestModels(TestCase): def test_price_oracles(self): - self.assertEqual(PriceOracle.objects.count(), 4) + self.assertEqual(PriceOracle.objects.count(), 6) def test_token_calculate_payment(self): token = TokenFactory(fixed_eth_conversion=0.1) diff --git a/safe_relay_service/tokens/views.py b/safe_relay_service/tokens/views.py index 25555396..5e1d76b0 100644 --- a/safe_relay_service/tokens/views.py +++ b/safe_relay_service/tokens/views.py @@ -28,3 +28,7 @@ class TokensView(ListAPIView): ordering_fields = '__all__' ordering = ('relevance', 'name') queryset = Token.objects.all() + + @method_decorator(cache_page(60 * 5)) # Cache 5 minutes + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) diff --git a/safe_relay_service/version.py b/safe_relay_service/version.py index 65f069d3..723bebec 100644 --- a/safe_relay_service/version.py +++ b/safe_relay_service/version.py @@ -1,2 +1,2 @@ -__version__ = '3.6.9' +__version__ = '3.8.2' __version_info__ = tuple([int(num) if num.isdigit() else num for num in __version__.replace('-', '.', 1).split('.')])