From f509b916f4d9991ce0aa8f4c2af58435fccd62df Mon Sep 17 00:00:00 2001 From: Uxio Fuentefria Date: Thu, 25 Oct 2018 17:48:19 +0200 Subject: [PATCH 1/9] WIP implement gas token support --- safe_relay_service/relay/models.py | 4 ++- safe_relay_service/relay/serializers.py | 4 --- safe_relay_service/relay/views.py | 36 +++++++++++++++++-- safe_relay_service/tokens/__init__.py | 0 safe_relay_service/tokens/admin.py | 5 +++ safe_relay_service/tokens/apps.py | 5 +++ .../tokens/management/__init__.py | 0 .../tokens/management/commands/__init__.py | 0 safe_relay_service/tokens/models.py | 20 +++++++++++ safe_relay_service/tokens/tests/__init__.py | 0 .../tokens/tests/test_tokens.py | 6 ++++ 11 files changed, 72 insertions(+), 8 deletions(-) create mode 100644 safe_relay_service/tokens/__init__.py create mode 100644 safe_relay_service/tokens/admin.py create mode 100644 safe_relay_service/tokens/apps.py create mode 100644 safe_relay_service/tokens/management/__init__.py create mode 100644 safe_relay_service/tokens/management/commands/__init__.py create mode 100644 safe_relay_service/tokens/models.py create mode 100644 safe_relay_service/tokens/tests/__init__.py create mode 100644 safe_relay_service/tokens/tests/test_tokens.py diff --git a/safe_relay_service/relay/models.py b/safe_relay_service/relay/models.py index 9b7cefcf..797ff78c 100644 --- a/safe_relay_service/relay/models.py +++ b/safe_relay_service/relay/models.py @@ -167,7 +167,8 @@ def create_multisig_tx(self, gas_token: str, refund_receiver: str, nonce: int, - signatures: List[Dict[str, int]]): + signatures: List[Dict[str, int]], + tx_gas_price: int): """ :return: Database model of SafeMultisigTx :raises: SafeMultisigTxExists: If Safe Multisig Tx with nonce already exists @@ -195,6 +196,7 @@ def create_multisig_tx(self, gas_token, refund_receiver, signatures_packed, + tx_gas_price=tx_gas_price ) except InvalidMultisigTx as exc: raise self.SafeMultisigTxError(str(exc)) from exc diff --git a/safe_relay_service/relay/serializers.py b/safe_relay_service/relay/serializers.py index dedba64b..4276286e 100644 --- a/safe_relay_service/relay/serializers.py +++ b/safe_relay_service/relay/serializers.py @@ -49,10 +49,6 @@ def validate(self, data): safe_service = SafeServiceProvider() - gas_token = data.get('gas_token') - if gas_token and gas_token != NULL_ADDRESS: - raise ValidationError('Gas Token is still not supported') - refund_receiver = data.get('refund_receiver') if refund_receiver and refund_receiver != NULL_ADDRESS: raise ValidationError('Refund Receiver is not configurable') diff --git a/safe_relay_service/relay/views.py b/safe_relay_service/relay/views.py index 555297d1..6569b426 100644 --- a/safe_relay_service/relay/views.py +++ b/safe_relay_service/relay/views.py @@ -1,4 +1,5 @@ import ethereum.utils +from django_eth.constants import NULL_ADDRESS from django.conf import settings from drf_yasg.utils import swagger_auto_schema from gnosis.safe.safe_service import SafeServiceException, SafeServiceProvider @@ -13,6 +14,8 @@ from safe_relay_service.gas_station.gas_station import GasStationProvider from safe_relay_service.relay.models import (SafeContract, SafeCreation, SafeFunding, SafeMultisigTx) +from safe_relay_service.tokens.models import Token + from safe_relay_service.relay.tasks import fund_deployer_task from safe_relay_service.version import __version__ @@ -222,6 +225,23 @@ def post(self, request, address, format=None): return Response(status=status.HTTP_400_BAD_REQUEST, data=serializer.errors) else: data = serializer.validated_data + + gas_token = data['gas_token'] + gas_price = data['gas_price'] + if gas_token and gas_token != NULL_ADDRESS: + try: + token = Token.objects.get(address=gas_token) + token_eth = token.get_eth_value() + estimated_gas_price = gas_price / token_eth + if estimated_gas_price < gas_price: + return Response(status=status.HTTP_422_UNPROCESSABLE_ENTITY, + data='Required gas-price>=%d to use gas-token' % estimated_gas_price) + tx_gas_price = GasStationProvider().get_gas_prices().fast + except Token.DoesNotExist: + return Response(status=status.HTTP_422_UNPROCESSABLE_ENTITY, data='Gas token not valid') + else: + tx_gas_price = gas_price + try: safe_multisig_tx = SafeMultisigTx.objects.create_multisig_tx( safe_address=data['safe'], @@ -236,6 +256,7 @@ def post(self, request, address, format=None): nonce=data['nonce'], refund_receiver=data['refund_receiver'], signatures=data['signatures'], + tx_gas_price=tx_gas_price ) response_serializer = SafeMultisigTxResponseSerializer(data={'transaction_hash': safe_multisig_tx.tx_hash}) @@ -273,18 +294,27 @@ def post(self, request, address, format=None): serializer = self.serializer_class(data=request.data) if serializer.is_valid(): - data = serializer.validated_data safe_service = SafeServiceProvider() - gas_token = safe_service.get_gas_token() + data = serializer.validated_data + gas_token = data['gas_token'] last_used_nonce = SafeMultisigTx.objects.get_last_nonce_for_safe(address) safe_tx_gas = safe_service.estimate_tx_gas(address, data['to'], data['value'], data['data'], data['operation']) safe_data_tx_gas = safe_service.estimate_tx_data_gas(address, data['to'], data['value'], data['data'], - data['operation'], safe_tx_gas) + data['operation'], gas_token, safe_tx_gas) safe_operational_tx_gas = safe_service.estimate_tx_operational_gas(address, len(data['data']) if data['data'] else 0) gas_price = GasStationProvider().get_gas_prices().fast + if gas_token and gas_token != NULL_ADDRESS: + try: + token = Token.objects.get(address=gas_token) + token_eth = token.get_eth_value() + price_margin = 1 + 1 / 100 # TODO Make it configurable + gas_price = gas_price / token_eth * price_margin # Gas price needs to be adjusted for the token + except Token.DoesNotExist: + return Response(status=status.HTTP_422_UNPROCESSABLE_ENTITY, data='Gas token not valid') + response_data = {'safe_tx_gas': safe_tx_gas, 'data_gas': safe_data_tx_gas, 'operational_gas': safe_operational_tx_gas, diff --git a/safe_relay_service/tokens/__init__.py b/safe_relay_service/tokens/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/safe_relay_service/tokens/admin.py b/safe_relay_service/tokens/admin.py new file mode 100644 index 00000000..f8dccb7c --- /dev/null +++ b/safe_relay_service/tokens/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin + +from .models import Token + +admin.site.register(Token) diff --git a/safe_relay_service/tokens/apps.py b/safe_relay_service/tokens/apps.py new file mode 100644 index 00000000..7fd77e70 --- /dev/null +++ b/safe_relay_service/tokens/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class TokensConfig(AppConfig): + name = 'safe_relay_service.tokens' diff --git a/safe_relay_service/tokens/management/__init__.py b/safe_relay_service/tokens/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/safe_relay_service/tokens/management/commands/__init__.py b/safe_relay_service/tokens/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/safe_relay_service/tokens/models.py b/safe_relay_service/tokens/models.py new file mode 100644 index 00000000..a2bf4d17 --- /dev/null +++ b/safe_relay_service/tokens/models.py @@ -0,0 +1,20 @@ +from django.db import models +from django_eth.models import EthereumAddressField +import requests + + +class Token(models.Model): + name = models.CharField(max_length=15) + code = models.CharField(max_length=5) + address = EthereumAddressField() + gas_token = models.BooleanField(default=False) + + def __str__(self): + return '%s - %s' % (self.name, self.address) + + # TODO Cache + # TODO Mock for tests + def get_eth_value(self): + pair = '{}ETH'.format(self.code) + price = float(requests.get('https://api.kraken.com/0/public/Ticker?pair=' + pair).json()['result']['c'][0]) + return price diff --git a/safe_relay_service/tokens/tests/__init__.py b/safe_relay_service/tokens/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/safe_relay_service/tokens/tests/test_tokens.py b/safe_relay_service/tokens/tests/test_tokens.py new file mode 100644 index 00000000..6c8c536e --- /dev/null +++ b/safe_relay_service/tokens/tests/test_tokens.py @@ -0,0 +1,6 @@ +from django.test import TestCase + + +class TestTokens(TestCase): + def test_tokens(self): + pass From e4ec6d1ab4fb81e265556c0d54ec683cf1f7e816 Mon Sep 17 00:00:00 2001 From: Uxio Fuentefria Date: Thu, 25 Oct 2018 18:00:41 +0200 Subject: [PATCH 2/9] WIP Add comment --- safe_relay_service/relay/views.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/safe_relay_service/relay/views.py b/safe_relay_service/relay/views.py index 6569b426..a252195a 100644 --- a/safe_relay_service/relay/views.py +++ b/safe_relay_service/relay/views.py @@ -226,6 +226,9 @@ def post(self, request, address, format=None): else: data = serializer.validated_data + # If gas_token is specified, we see if the gas_price matches the current token value and use as the + # external tx gas the fast gas price from the gas station. + # If not, we just use the internal tx gas_price for the gas_price gas_token = data['gas_token'] gas_price = data['gas_price'] if gas_token and gas_token != NULL_ADDRESS: From 28b6d0c97b74053e605f45590bbf76628bead02b Mon Sep 17 00:00:00 2001 From: Uxio Fuentefria Date: Fri, 26 Oct 2018 17:54:36 +0200 Subject: [PATCH 3/9] WIP --- config/settings/base.py | 1 + .../relay/migrations/0001_initial.py | 3 +- .../0002_safemultisigtx_refund_receiver.py | 3 +- safe_relay_service/relay/models.py | 2 +- safe_relay_service/relay/serializers.py | 2 +- safe_relay_service/relay/tests/factories.py | 27 ++++-- .../relay/tests/test_serializers.py | 3 +- .../relay/tests/test_validators.py | 1 + safe_relay_service/relay/tests/test_views.py | 97 ++++++++++++++++++- safe_relay_service/relay/views.py | 9 +- safe_relay_service/tokens/models.py | 15 ++- safe_relay_service/tokens/tests/factories.py | 22 +++++ 12 files changed, 162 insertions(+), 23 deletions(-) create mode 100644 safe_relay_service/tokens/tests/factories.py diff --git a/config/settings/base.py b/config/settings/base.py index 8ea0b58b..8c636ced 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -70,6 +70,7 @@ ] LOCAL_APPS = [ 'safe_relay_service.relay.apps.RelayConfig', + 'safe_relay_service.tokens.apps.TokensConfig', 'safe_relay_service.gas_station.apps.GasStationConfig', ] # https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps diff --git a/safe_relay_service/relay/migrations/0001_initial.py b/safe_relay_service/relay/migrations/0001_initial.py index 8e7262b1..1bb684fd 100644 --- a/safe_relay_service/relay/migrations/0001_initial.py +++ b/safe_relay_service/relay/migrations/0001_initial.py @@ -3,10 +3,11 @@ import django.contrib.postgres.fields import django.db.models.deletion import django.utils.timezone -import django_eth.models import model_utils.fields from django.db import migrations, models +import django_eth.models + class Migration(migrations.Migration): diff --git a/safe_relay_service/relay/migrations/0002_safemultisigtx_refund_receiver.py b/safe_relay_service/relay/migrations/0002_safemultisigtx_refund_receiver.py index f99bb624..dda1fc13 100644 --- a/safe_relay_service/relay/migrations/0002_safemultisigtx_refund_receiver.py +++ b/safe_relay_service/relay/migrations/0002_safemultisigtx_refund_receiver.py @@ -1,8 +1,9 @@ # Generated by Django 2.0.8 on 2018-10-02 14:22 -import django_eth.models from django.db import migrations +import django_eth.models + class Migration(migrations.Migration): diff --git a/safe_relay_service/relay/models.py b/safe_relay_service/relay/models.py index 797ff78c..c45518aa 100644 --- a/safe_relay_service/relay/models.py +++ b/safe_relay_service/relay/models.py @@ -2,12 +2,12 @@ from django.contrib.postgres.fields import ArrayField from django.db import models -from django_eth.models import EthereumAddressField, Sha3HashField, Uint256Field from gnosis.safe.ethereum_service import EthereumServiceProvider from gnosis.safe.safe_service import (InvalidMultisigTx, SafeOperation, SafeServiceProvider) from model_utils.models import TimeStampedModel +from django_eth.models import EthereumAddressField, Sha3HashField, Uint256Field from safe_relay_service.gas_station.gas_station import GasStationProvider diff --git a/safe_relay_service/relay/serializers.py b/safe_relay_service/relay/serializers.py index 4276286e..3b368d67 100644 --- a/safe_relay_service/relay/serializers.py +++ b/safe_relay_service/relay/serializers.py @@ -114,4 +114,4 @@ class SafeMultisigEstimateTxResponseSerializer(serializers.Serializer): operational_gas = serializers.IntegerField(min_value=0) gas_price = serializers.IntegerField(min_value=0) last_used_nonce = serializers.IntegerField(min_value=0, allow_null=True) - gas_token = HexadecimalField(allow_blank=True, allow_null=True) + gas_token = EthereumAddressField(allow_null=True, allow_zero_address=True) diff --git a/safe_relay_service/relay/tests/factories.py b/safe_relay_service/relay/tests/factories.py index 528e0ad8..23ebed70 100644 --- a/safe_relay_service/relay/tests/factories.py +++ b/safe_relay_service/relay/tests/factories.py @@ -1,11 +1,12 @@ import os from logging import getLogger -from django_eth.tests.factories import get_eth_address_with_key from ethereum.transactions import secpk1n from faker import Factory as FakerFactory from faker import Faker +from gnosis.safe.tests.factories import generate_valid_s +from django_eth.tests.factories import get_eth_address_with_key from safe_relay_service.relay.models import SafeCreation fakerFactory = FakerFactory.create() @@ -36,7 +37,8 @@ def generate_safe(owners=None, number_owners=3, threshold=None) -> SafeCreation: return safe_creation -def deploy_safe(w3, safe_creation, funder) -> str: +#FIXME Use the functions in gnosis-py +def deploy_safe(w3, safe_creation, funder: str, initial_funding_wei: int=0) -> str: w3.eth.waitForTransactionReceipt( w3.eth.sendTransaction({ 'from': funder, @@ -45,15 +47,26 @@ def deploy_safe(w3, safe_creation, funder) -> str: }) ) - w3.eth.sendTransaction({ - 'from': funder, - 'to': safe_creation.safe.address, - 'value': safe_creation.payment - }) + w3.eth.waitForTransactionReceipt( + w3.eth.sendTransaction({ + 'from': funder, + 'to': safe_creation.safe.address, + 'value': safe_creation.payment + }) + ) tx_hash = w3.eth.sendRawTransaction(bytes(safe_creation.signed_tx)) tx_receipt = w3.eth.waitForTransactionReceipt(tx_hash) assert tx_receipt.contractAddress == safe_creation.safe.address assert tx_receipt.status + if initial_funding_wei > 0: + w3.eth.waitForTransactionReceipt( + w3.eth.sendTransaction({ + 'from': funder, + 'to': safe_creation.safe.address, + 'value': initial_funding_wei + }) + ) + return safe_creation.safe.address diff --git a/safe_relay_service/relay/tests/test_serializers.py b/safe_relay_service/relay/tests/test_serializers.py index 40310653..b36ac26a 100644 --- a/safe_relay_service/relay/tests/test_serializers.py +++ b/safe_relay_service/relay/tests/test_serializers.py @@ -1,10 +1,11 @@ from django.test import TestCase -from django_eth.tests.factories import get_eth_address_with_key from ethereum.transactions import secpk1n from faker import Faker from gnosis.safe.safe_service import SafeServiceProvider from hexbytes import HexBytes +from django_eth.tests.factories import get_eth_address_with_key + from ..models import SafeContract, SafeFunding from ..serializers import (SafeCreationSerializer, SafeFundingResponseSerializer, diff --git a/safe_relay_service/relay/tests/test_validators.py b/safe_relay_service/relay/tests/test_validators.py index 8ef08626..42bc241c 100644 --- a/safe_relay_service/relay/tests/test_validators.py +++ b/safe_relay_service/relay/tests/test_validators.py @@ -1,5 +1,6 @@ from django.core.exceptions import ValidationError from django.test import TestCase + from django_eth.tests.factories import get_eth_address_with_key from ..validators import validate_checksumed_address diff --git a/safe_relay_service/relay/tests/test_views.py b/safe_relay_service/relay/tests/test_views.py index 8daf5aa6..bc26e69d 100644 --- a/safe_relay_service/relay/tests/test_views.py +++ b/safe_relay_service/relay/tests/test_views.py @@ -4,12 +4,13 @@ from ethereum.utils import check_checksum from faker import Faker from gnosis.safe.safe_service import SafeServiceProvider +from gnosis.safe.tests.factories import deploy_example_erc20 from rest_framework import status from rest_framework.test import APITestCase -from django_eth.constants import NULL_ADDRESS from django_eth.tests.factories import (get_eth_address_with_invalid_checksum, get_eth_address_with_key) +from safe_relay_service.tokens.tests.factories import TokenFactory from ..models import SafeContract, SafeCreation, SafeMultisigTx from ..serializers import SafeCreationSerializer @@ -193,6 +194,98 @@ def test_safe_multisig_tx(self): self.assertEqual(request.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY) self.assertTrue('exists' in request.data) + def test_safe_multisig_tx_gas_token(self): + # Create Safe ------------------------------------------------ + safe_service = SafeServiceProvider() + w3 = safe_service.w3 + funder = w3.eth.accounts[0] + owner, owner_key = get_eth_address_with_key() + threshold = 1 + + safe_balance = w3.toWei(0.01, 'ether') + safe_creation = generate_safe(owners=[owner], threshold=threshold) + my_safe_address = deploy_safe(w3, safe_creation, funder, initial_funding_wei=safe_balance) + + # Get tokens for the safe + safe_token_balance = int(1e18) + erc20_contract = deploy_example_erc20(self.w3, safe_token_balance, my_safe_address, funder) + + # Send something to the owner, who will be sending the tx + owner0_balance = safe_balance + w3.eth.waitForTransactionReceipt(w3.eth.sendTransaction({ + 'from': funder, + 'to': owner, + 'value': owner0_balance + })) + + # Safe prepared -------------------------------------------- + to, _ = get_eth_address_with_key() + value = safe_balance + tx_data = None + operation = 0 + refund_receiver = None + nonce = 0 + gas_token = erc20_contract.address + + data = { + "to": to, + "value": value, + "data": tx_data, + "operation": operation, + "gasToken": gas_token + } + + # Get estimation for gas. Token does not exist + response = self.client.post(reverse('v1:safe-multisig-tx-estimate', args=(my_safe_address,)), + data=data, + format='json') + self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY) + self.assertIn('Gas token', response.json()) + + # Create token + token_model = TokenFactory(address=gas_token) + response = self.client.post(reverse('v1:safe-multisig-tx-estimate', args=(my_safe_address,)), + data=data, + format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + estimation_json = response.json() + + safe_tx_gas = estimation_json['safeTxGas'] + estimation_json['operationalGas'] + data_gas = estimation_json['dataGas'] + gas_price = estimation_json['gasPrice'] + gas_token = estimation_json['gasToken'] + + signatures_json = [{'v': 1, 'r': int(owner, 16), 's': 0}] + + data = { + "to": to, + "value": value, + "data": tx_data, + "operation": operation, + "safe_tx_gas": safe_tx_gas, + "data_gas": data_gas, + "gas_price": gas_price, + "gas_token": gas_token, + "nonce": nonce, + "signatures": signatures_json + } + + response = self.client.post(reverse('v1:safe-multisig-tx', args=(my_safe_address,)), + data=data, + format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + tx_hash = response.json()['transactionHash'][2:] # Remove leading 0x + safe_multisig_tx = SafeMultisigTx.objects.get(tx_hash=tx_hash) + self.assertEqual(safe_multisig_tx.to, to) + self.assertEqual(safe_multisig_tx.value, value) + self.assertEqual(safe_multisig_tx.data, tx_data) + self.assertEqual(safe_multisig_tx.operation, operation) + self.assertEqual(safe_multisig_tx.safe_tx_gas, safe_tx_gas) + self.assertEqual(safe_multisig_tx.data_gas, data_gas) + self.assertEqual(safe_multisig_tx.gas_price, gas_price) + self.assertEqual(safe_multisig_tx.gas_token, gas_token) + self.assertEqual(safe_multisig_tx.nonce, nonce) + def test_safe_multisig_tx_errors(self): my_safe_address = get_eth_address_with_invalid_checksum() request = self.client.post(reverse('v1:safe-multisig-tx', args=(my_safe_address,)), @@ -245,7 +338,7 @@ def test_safe_multisig_tx_estimate(self): self.assertGreater(response['dataGas'], 0) self.assertGreater(response['gasPrice'], 0) self.assertIsNone(response['lastUsedNonce']) - self.assertEqual(response['gasToken'], NULL_ADDRESS) + self.assertEqual(response['gasToken'], None) to, _ = get_eth_address_with_key() data = { diff --git a/safe_relay_service/relay/views.py b/safe_relay_service/relay/views.py index a252195a..b77c7401 100644 --- a/safe_relay_service/relay/views.py +++ b/safe_relay_service/relay/views.py @@ -1,5 +1,6 @@ +import math + import ethereum.utils -from django_eth.constants import NULL_ADDRESS from django.conf import settings from drf_yasg.utils import swagger_auto_schema from gnosis.safe.safe_service import SafeServiceException, SafeServiceProvider @@ -11,12 +12,12 @@ from rest_framework.response import Response from rest_framework.views import APIView, exception_handler +from django_eth.constants import NULL_ADDRESS from safe_relay_service.gas_station.gas_station import GasStationProvider from safe_relay_service.relay.models import (SafeContract, SafeCreation, SafeFunding, SafeMultisigTx) -from safe_relay_service.tokens.models import Token - from safe_relay_service.relay.tasks import fund_deployer_task +from safe_relay_service.tokens.models import Token from safe_relay_service.version import __version__ from .serializers import (SafeCreationSerializer, @@ -314,7 +315,7 @@ def post(self, request, address, format=None): token = Token.objects.get(address=gas_token) token_eth = token.get_eth_value() price_margin = 1 + 1 / 100 # TODO Make it configurable - gas_price = gas_price / token_eth * price_margin # Gas price needs to be adjusted for the token + gas_price = math.ceil(gas_price / token_eth * price_margin) # Gas price needs to be adjusted for the token except Token.DoesNotExist: return Response(status=status.HTTP_422_UNPROCESSABLE_ENTITY, data='Gas token not valid') diff --git a/safe_relay_service/tokens/models.py b/safe_relay_service/tokens/models.py index a2bf4d17..ff4f530b 100644 --- a/safe_relay_service/tokens/models.py +++ b/safe_relay_service/tokens/models.py @@ -1,6 +1,7 @@ +import requests from django.db import models + from django_eth.models import EthereumAddressField -import requests class Token(models.Model): @@ -8,13 +9,17 @@ class Token(models.Model): code = models.CharField(max_length=5) address = EthereumAddressField() gas_token = models.BooleanField(default=False) + fixed_eth_conversion = models.DecimalField(null=True, default=None, max_digits=20, decimal_places=15) def __str__(self): return '%s - %s' % (self.name, self.address) # TODO Cache # TODO Mock for tests - def get_eth_value(self): - pair = '{}ETH'.format(self.code) - price = float(requests.get('https://api.kraken.com/0/public/Ticker?pair=' + pair).json()['result']['c'][0]) - return price + def get_eth_value(self) -> float: + if self.fixed_eth_conversion is None: + pair = '{}ETH'.format(self.code) + price = float(requests.get('https://api.kraken.com/0/public/Ticker?pair=' + pair).json()['result']['c'][0]) + return price + else: + return float(self.fixed_eth_conversion) diff --git a/safe_relay_service/tokens/tests/factories.py b/safe_relay_service/tokens/tests/factories.py new file mode 100644 index 00000000..cf268426 --- /dev/null +++ b/safe_relay_service/tokens/tests/factories.py @@ -0,0 +1,22 @@ +import factory as factory_boy +from faker import Factory as FakerFactory +from faker import Faker + +from django_eth.tests.factories import get_eth_address_with_key + +from .. import models + +fakerFactory = FakerFactory.create() +faker = Faker() + + +class TokenFactory(factory_boy.DjangoModelFactory): + + class Meta: + model = models.Token + + address = get_eth_address_with_key()[0] + name = factory_boy.Sequence(lambda n: 'TOKEN NAME %d' %n) + code = factory_boy.Sequence(lambda n: 'TKN%d' % n) + gas_token = True + fixed_eth_conversion = 1 From 0d797c54f825fac487d2e2053b593d6219cc8dfb Mon Sep 17 00:00:00 2001 From: Uxio Fuentefria Date: Fri, 26 Oct 2018 17:54:43 +0200 Subject: [PATCH 4/9] Add migrations --- .../tokens/migrations/0001_initial.py | 27 +++++++++++++++++++ .../tokens/migrations/__init__.py | 0 2 files changed, 27 insertions(+) create mode 100644 safe_relay_service/tokens/migrations/0001_initial.py create mode 100644 safe_relay_service/tokens/migrations/__init__.py diff --git a/safe_relay_service/tokens/migrations/0001_initial.py b/safe_relay_service/tokens/migrations/0001_initial.py new file mode 100644 index 00000000..ea3bedfd --- /dev/null +++ b/safe_relay_service/tokens/migrations/0001_initial.py @@ -0,0 +1,27 @@ +# Generated by Django 2.1.2 on 2018-10-26 15:22 + +from django.db import migrations, models + +import django_eth.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Token', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=15)), + ('code', models.CharField(max_length=5)), + ('address', django_eth.models.EthereumAddressField()), + ('gas_token', models.BooleanField(default=False)), + ('fixed_eth_conversion', models.DecimalField(decimal_places=15, default=None, max_digits=20, null=True)), + ], + ), + ] diff --git a/safe_relay_service/tokens/migrations/__init__.py b/safe_relay_service/tokens/migrations/__init__.py new file mode 100644 index 00000000..e69de29b From a3dbcd943e84bf8fd5fe281d769dd7b08351feed Mon Sep 17 00:00:00 2001 From: Uxio Fuentefria Date: Mon, 5 Nov 2018 13:46:34 +0100 Subject: [PATCH 5/9] Fix gas token view --- safe_relay_service/relay/views.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/safe_relay_service/relay/views.py b/safe_relay_service/relay/views.py index b77c7401..09c6d9ab 100644 --- a/safe_relay_service/relay/views.py +++ b/safe_relay_service/relay/views.py @@ -301,6 +301,14 @@ def post(self, request, address, format=None): safe_service = SafeServiceProvider() data = serializer.validated_data gas_token = data['gas_token'] + if gas_token and gas_token != NULL_ADDRESS: + try: + gas_token_model = Token.objects.get(address=gas_token) + except Token.DoesNotExist: + return Response(status=status.HTTP_422_UNPROCESSABLE_ENTITY, data='Gas token not valid') + else: + gas_token_model = None + last_used_nonce = SafeMultisigTx.objects.get_last_nonce_for_safe(address) safe_tx_gas = safe_service.estimate_tx_gas(address, data['to'], data['value'], data['data'], data['operation']) @@ -310,14 +318,11 @@ def post(self, request, address, format=None): len(data['data']) if data['data'] else 0) gas_price = GasStationProvider().get_gas_prices().fast - if gas_token and gas_token != NULL_ADDRESS: - try: - token = Token.objects.get(address=gas_token) - token_eth = token.get_eth_value() - price_margin = 1 + 1 / 100 # TODO Make it configurable - gas_price = math.ceil(gas_price / token_eth * price_margin) # Gas price needs to be adjusted for the token - except Token.DoesNotExist: - return Response(status=status.HTTP_422_UNPROCESSABLE_ENTITY, data='Gas token not valid') + if gas_token_model: + token_eth = gas_token_model.get_eth_value() + price_margin = 1 + 1 / 100 # TODO Make it configurable + # Gas price needs to be adjusted for the token + gas_price = math.ceil(gas_price / token_eth * price_margin) response_data = {'safe_tx_gas': safe_tx_gas, 'data_gas': safe_data_tx_gas, From ba6dfd1c4cb01c3586de1aba6c348c2f63e42aa6 Mon Sep 17 00:00:00 2001 From: Uxio Fuentefria Date: Mon, 5 Nov 2018 18:22:09 +0100 Subject: [PATCH 6/9] Refactor calculation of gas price --- safe_relay_service/relay/views.py | 10 +++------- safe_relay_service/tokens/models.py | 13 ++++++++++++- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/safe_relay_service/relay/views.py b/safe_relay_service/relay/views.py index 09c6d9ab..5bb1b367 100644 --- a/safe_relay_service/relay/views.py +++ b/safe_relay_service/relay/views.py @@ -1,5 +1,3 @@ -import math - import ethereum.utils from django.conf import settings from drf_yasg.utils import swagger_auto_schema @@ -234,9 +232,8 @@ def post(self, request, address, format=None): gas_price = data['gas_price'] if gas_token and gas_token != NULL_ADDRESS: try: - token = Token.objects.get(address=gas_token) - token_eth = token.get_eth_value() - estimated_gas_price = gas_price / token_eth + gas_token_model = Token.objects.get(address=gas_token) + estimated_gas_price = gas_token_model.calculate_gas_price(gas_price) if estimated_gas_price < gas_price: return Response(status=status.HTTP_422_UNPROCESSABLE_ENTITY, data='Required gas-price>=%d to use gas-token' % estimated_gas_price) @@ -319,10 +316,9 @@ def post(self, request, address, format=None): gas_price = GasStationProvider().get_gas_prices().fast if gas_token_model: - token_eth = gas_token_model.get_eth_value() price_margin = 1 + 1 / 100 # TODO Make it configurable # Gas price needs to be adjusted for the token - gas_price = math.ceil(gas_price / token_eth * price_margin) + gas_price = gas_token_model.calculate_gas_price(gas_price, price_margin=price_margin) response_data = {'safe_tx_gas': safe_tx_gas, 'data_gas': safe_data_tx_gas, diff --git a/safe_relay_service/tokens/models.py b/safe_relay_service/tokens/models.py index ff4f530b..1e8ac5ca 100644 --- a/safe_relay_service/tokens/models.py +++ b/safe_relay_service/tokens/models.py @@ -1,3 +1,5 @@ +import math + import requests from django.db import models @@ -15,7 +17,6 @@ def __str__(self): return '%s - %s' % (self.name, self.address) # TODO Cache - # TODO Mock for tests def get_eth_value(self) -> float: if self.fixed_eth_conversion is None: pair = '{}ETH'.format(self.code) @@ -23,3 +24,13 @@ def get_eth_value(self) -> float: return price else: return float(self.fixed_eth_conversion) + + def calculate_gas_price(self, gas_price: int, price_margin: float=1.0) -> int: + """ + Converts ether gas price to token's gas price + :param gas_price: Regular ether gas price + :param price_margin: Threshold to estimate a little higher, so tx will + not be rejected in a few minutes + :return: + """ + return math.ceil(gas_price / self.get_eth_value() * price_margin) From 9eb3f25442ce2afd1c74b6c827ff658a90a2a9dd Mon Sep 17 00:00:00 2001 From: Uxio Fuentefria Date: Tue, 6 Nov 2018 10:55:36 +0100 Subject: [PATCH 7/9] Update gnosis-py version --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 358c8654..7ea149db 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,7 +17,7 @@ ethereum==2.3.2 factory-boy==2.11.1 faker==0.9.2 gevent==1.3.7 -gnosis-py==0.4.5 +gnosis-py==0.5.0 gunicorn==19.9.0 hexbytes==0.1.0 jsonschema==2.6.0 From 2468433c904b8b576a8bf288f136fda6215b59b9 Mon Sep 17 00:00:00 2001 From: Uxio Fuentefria Date: Tue, 6 Nov 2018 12:44:20 +0100 Subject: [PATCH 8/9] Fix signatures --- safe_relay_service/relay/serializers.py | 7 +++--- safe_relay_service/relay/tests/test_views.py | 25 ++++++++++++++++---- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/safe_relay_service/relay/serializers.py b/safe_relay_service/relay/serializers.py index 3b368d67..117ed57c 100644 --- a/safe_relay_service/relay/serializers.py +++ b/safe_relay_service/relay/serializers.py @@ -2,14 +2,13 @@ from gnosis.safe.ethereum_service import EthereumServiceProvider from gnosis.safe.safe_service import SafeServiceProvider -from gnosis.safe.serializers import SafeMultisigTxSerializer +from gnosis.safe.serializers import SafeMultisigTxSerializer, SafeSignatureSerializer from rest_framework import serializers from rest_framework.exceptions import ValidationError from django_eth.constants import (NULL_ADDRESS, SIGNATURE_S_MAX_VALUE, SIGNATURE_S_MIN_VALUE) -from django_eth.serializers import (EthereumAddressField, HexadecimalField, - Sha3HashField, SignatureSerializer, +from django_eth.serializers import (EthereumAddressField, Sha3HashField, TransactionResponseSerializer) from safe_relay_service.relay.models import SafeCreation, SafeFunding @@ -34,7 +33,7 @@ def validate(self, data): class SafeRelayMultisigTxSerializer(SafeMultisigTxSerializer): - signatures = serializers.ListField(child=SignatureSerializer()) + signatures = serializers.ListField(child=SafeSignatureSerializer()) def validate(self, data): super().validate(data) diff --git a/safe_relay_service/relay/tests/test_views.py b/safe_relay_service/relay/tests/test_views.py index bc26e69d..09a5aa34 100644 --- a/safe_relay_service/relay/tests/test_views.py +++ b/safe_relay_service/relay/tests/test_views.py @@ -255,7 +255,22 @@ def test_safe_multisig_tx_gas_token(self): gas_price = estimation_json['gasPrice'] gas_token = estimation_json['gasToken'] - signatures_json = [{'v': 1, 'r': int(owner, 16), 's': 0}] + multisig_tx_hash = safe_service.get_hash_for_safe_tx( + my_safe_address, + to, + value, + tx_data, + operation, + safe_tx_gas, + data_gas, + gas_price, + gas_token, + refund_receiver, + nonce + ) + + signatures = [w3.eth.account.signHash(multisig_tx_hash, private_key) for private_key in [owner_key]] + signatures_json = [{'v': s['v'], 'r': s['r'], 's': s['s']} for s in signatures] data = { "to": to, @@ -318,16 +333,18 @@ def test_safe_multisig_tx_estimate(self): format='json') self.assertEqual(request.status_code, status.HTTP_404_NOT_FOUND) + initial_funding = self.w3.toWei(0.0001, 'ether') to, _ = get_eth_address_with_key() data = { 'to': to, - 'value': 10, + 'value': initial_funding // 2, 'data': '0x', 'operation': 1 } safe_creation = generate_safe() - my_safe_address = deploy_safe(self.w3, safe_creation, self.w3.eth.accounts[0]) + my_safe_address = deploy_safe(self.w3, safe_creation, self.w3.eth.accounts[0], + initial_funding_wei=initial_funding) request = self.client.post(reverse('v1:safe-multisig-tx-estimate', args=(my_safe_address,)), data=data, @@ -343,7 +360,7 @@ def test_safe_multisig_tx_estimate(self): to, _ = get_eth_address_with_key() data = { 'to': to, - 'value': 100, + 'value': initial_funding // 2, 'data': None, 'operation': 0 } From 2c3ff095886f903af553e4b18ffa85056b672f0c Mon Sep 17 00:00:00 2001 From: Uxio Fuentefria Date: Wed, 7 Nov 2018 11:20:30 +0100 Subject: [PATCH 9/9] Implement gas-token --- .../tokens/migrations/0001_initial.py | 12 +++++++----- safe_relay_service/tokens/models.py | 10 +++++++--- safe_relay_service/tokens/tests/factories.py | 13 ++++++------- 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/safe_relay_service/tokens/migrations/0001_initial.py b/safe_relay_service/tokens/migrations/0001_initial.py index ea3bedfd..2139a2c2 100644 --- a/safe_relay_service/tokens/migrations/0001_initial.py +++ b/safe_relay_service/tokens/migrations/0001_initial.py @@ -1,7 +1,6 @@ -# Generated by Django 2.1.2 on 2018-10-26 15:22 +# Generated by Django 2.1.2 on 2018-11-07 10:14 from django.db import migrations, models - import django_eth.models @@ -16,12 +15,15 @@ class Migration(migrations.Migration): migrations.CreateModel( name='Token', fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('address', django_eth.models.EthereumAddressField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=15)), ('code', models.CharField(max_length=5)), - ('address', django_eth.models.EthereumAddressField()), + ('description', models.TextField(blank=True)), + ('decimals', models.PositiveSmallIntegerField()), + ('logo_uri', models.URLField(blank=True)), + ('website_uri', models.URLField(blank=True)), ('gas_token', models.BooleanField(default=False)), - ('fixed_eth_conversion', models.DecimalField(decimal_places=15, default=None, max_digits=20, null=True)), + ('fixed_eth_conversion', models.DecimalField(decimal_places=15, default=None, max_digits=25, null=True)), ], ), ] diff --git a/safe_relay_service/tokens/models.py b/safe_relay_service/tokens/models.py index 1e8ac5ca..af096cb1 100644 --- a/safe_relay_service/tokens/models.py +++ b/safe_relay_service/tokens/models.py @@ -7,11 +7,15 @@ class Token(models.Model): + address = EthereumAddressField(primary_key=True) name = models.CharField(max_length=15) code = models.CharField(max_length=5) - address = EthereumAddressField() + description = models.TextField(blank=True) + decimals = models.PositiveSmallIntegerField() + logo_uri = models.URLField(blank=True) + website_uri = models.URLField(blank=True) gas_token = models.BooleanField(default=False) - fixed_eth_conversion = models.DecimalField(null=True, default=None, max_digits=20, decimal_places=15) + fixed_eth_conversion = models.DecimalField(null=True, default=None, max_digits=25, decimal_places=15) def __str__(self): return '%s - %s' % (self.name, self.address) @@ -19,7 +23,7 @@ def __str__(self): # TODO Cache def get_eth_value(self) -> float: if self.fixed_eth_conversion is None: - pair = '{}ETH'.format(self.code) + pair = '{}ETH'.format(self.symbol) price = float(requests.get('https://api.kraken.com/0/public/Ticker?pair=' + pair).json()['result']['c'][0]) return price else: diff --git a/safe_relay_service/tokens/tests/factories.py b/safe_relay_service/tokens/tests/factories.py index cf268426..6885d686 100644 --- a/safe_relay_service/tokens/tests/factories.py +++ b/safe_relay_service/tokens/tests/factories.py @@ -1,14 +1,9 @@ import factory as factory_boy -from faker import Factory as FakerFactory -from faker import Faker from django_eth.tests.factories import get_eth_address_with_key from .. import models -fakerFactory = FakerFactory.create() -faker = Faker() - class TokenFactory(factory_boy.DjangoModelFactory): @@ -16,7 +11,11 @@ class Meta: model = models.Token address = get_eth_address_with_key()[0] - name = factory_boy.Sequence(lambda n: 'TOKEN NAME %d' %n) - code = factory_boy.Sequence(lambda n: 'TKN%d' % n) + name = factory_boy.Faker('cryptocurrency_name') + code = factory_boy.Faker('cryptocurrency_code') + description = factory_boy.Faker('catch_phrase') + decimals = 18 + logo_uri = '' + website_uri = '' gas_token = True fixed_eth_conversion = 1