From 8ea99331860ed56dc64d01c5bc06ad5a32633790 Mon Sep 17 00:00:00 2001 From: Frederico Sabino <3332770+fmrsabino@users.noreply.github.com> Date: Mon, 6 Sep 2021 14:05:24 +0200 Subject: [PATCH] Add support for collection of oracles to chains endpoint (#210) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Breaking Change /api/v1/chains: each chain is now allowed to have multiple gas price configurations (resulting in a collection instead of a single object of fixed and oracle gas prices). - The new GasPrice for each chain is now ranked – a lower value means that it'd show up higher on the list (eg.: rank 1 > rank 100) - The new GasPrice collection is allowed to be empty – this is not only to reduce complexity but also because the current relationship between GasPrice <> Chain means that a Chain needs to exist first before assigning a GasPrice to it. --- src/chains/admin.py | 14 +- .../migrations/0023_create_gas_price_model.py | 74 +++++++++ .../0024_remove_gas_price_fields.py | 28 ++++ src/chains/models.py | 46 +++--- src/chains/serializers.py | 36 +++-- src/chains/tests/factories.py | 20 ++- src/chains/tests/test_models.py | 80 ++++++---- src/chains/tests/test_views.py | 151 +++++++++++++----- 8 files changed, 334 insertions(+), 115 deletions(-) create mode 100644 src/chains/migrations/0023_create_gas_price_model.py create mode 100644 src/chains/migrations/0024_remove_gas_price_fields.py diff --git a/src/chains/admin.py b/src/chains/admin.py index 5b366224..427d6a60 100644 --- a/src/chains/admin.py +++ b/src/chains/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from .models import Chain +from .models import Chain, GasPrice @admin.register(Chain) @@ -17,3 +17,15 @@ class ChainAdmin(admin.ModelAdmin): "relevance", "name", ) + + +@admin.register(GasPrice) +class GasPrice(admin.ModelAdmin): + list_display = ( + "chain_id", + "oracle_uri", + "fixed_wei_value", + "rank", + ) + search_fields = ("chain_id", "oracle_uri") + ordering = ("rank",) diff --git a/src/chains/migrations/0023_create_gas_price_model.py b/src/chains/migrations/0023_create_gas_price_model.py new file mode 100644 index 00000000..04ce0a17 --- /dev/null +++ b/src/chains/migrations/0023_create_gas_price_model.py @@ -0,0 +1,74 @@ +# Generated by Django 3.2.6 on 2021-09-01 12:38 + +import django.db.models.deletion +import gnosis.eth.django.models +from django.db import migrations, models + + +def copy_gas_prices(apps, schema_editor): + GasPrice = apps.get_model("chains", "GasPrice") + Chain = apps.get_model("chains", "Chain") + + GasPrice.objects.bulk_create( + GasPrice( + chain=chain, + oracle_uri=chain.gas_price_oracle_uri, + oracle_parameter=chain.gas_price_oracle_parameter, + gwei_factor=chain.gas_price_oracle_gwei_factor, + fixed_wei_value=chain.gas_price_fixed_wei, + ) + for chain in Chain.objects.all() + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("chains", "0022_remove_chain_block_explorer_uri"), + ] + + operations = [ + migrations.CreateModel( + name="GasPrice", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("oracle_uri", models.URLField(blank=True, null=True)), + ( + "oracle_parameter", + models.CharField(blank=True, max_length=255, null=True), + ), + ( + "gwei_factor", + models.DecimalField( + decimal_places=9, + default=1, + help_text="Factor required to reach the Gwei unit", + max_digits=19, + verbose_name="Gwei multiplier factor", + ), + ), + ( + "fixed_wei_value", + gnosis.eth.django.models.Uint256Field( + blank=True, null=True, verbose_name="Fixed gas price (wei)" + ), + ), + ("rank", models.SmallIntegerField(default=100)), + ( + "chain", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="chains.chain" + ), + ), + ], + ), + # noop for backwards because it will be handled by the backwards of CreateModel (ie.: destroying the model) + migrations.RunPython(copy_gas_prices, migrations.RunPython.noop), + ] diff --git a/src/chains/migrations/0024_remove_gas_price_fields.py b/src/chains/migrations/0024_remove_gas_price_fields.py new file mode 100644 index 00000000..6375b038 --- /dev/null +++ b/src/chains/migrations/0024_remove_gas_price_fields.py @@ -0,0 +1,28 @@ +# Generated by Django 3.2.6 on 2021-09-01 12:38 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("chains", "0023_create_gas_price_model"), + ] + + operations = [ + migrations.RemoveField( + model_name="chain", + name="gas_price_fixed_wei", + ), + migrations.RemoveField( + model_name="chain", + name="gas_price_oracle_gwei_factor", + ), + migrations.RemoveField( + model_name="chain", + name="gas_price_oracle_parameter", + ), + migrations.RemoveField( + model_name="chain", + name="gas_price_oracle_uri", + ), + ] diff --git a/src/chains/models.py b/src/chains/models.py index 364b564e..b4c35e49 100644 --- a/src/chains/models.py +++ b/src/chains/models.py @@ -57,39 +57,45 @@ class RpcAuthentication(models.TextChoices): help_text="Please use the following format: #RRGGBB.", ) ens_registry_address = EthereumAddressField(null=True, blank=True) - gas_price_oracle_uri = models.URLField(blank=True, null=True) - gas_price_oracle_parameter = models.CharField(blank=True, null=True, max_length=255) - gas_price_oracle_gwei_factor = models.DecimalField( + + recommended_master_copy_version = models.CharField( + max_length=255, validators=[sem_ver_validator] + ) + + def __str__(self): + return f"{self.name} | chain_id={self.id}" + + +class GasPrice(models.Model): + chain = models.ForeignKey(Chain, on_delete=models.CASCADE) + oracle_uri = models.URLField(blank=True, null=True) + oracle_parameter = models.CharField(blank=True, null=True, max_length=255) + gwei_factor = models.DecimalField( default=1, max_digits=19, decimal_places=9, verbose_name="Gwei multiplier factor", help_text="Factor required to reach the Gwei unit", ) - gas_price_fixed_wei = Uint256Field( + fixed_wei_value = Uint256Field( verbose_name="Fixed gas price (wei)", blank=True, null=True ) - recommended_master_copy_version = models.CharField( - max_length=255, validators=[sem_ver_validator] - ) + rank = models.SmallIntegerField( + default=100 + ) # A lower number will indicate higher ranking + + def __str__(self): + return f"Chain = {self.chain.id} | uri={self.oracle_uri} | fixed_wei_value={self.fixed_wei_value}" def clean(self): - if (self.gas_price_fixed_wei is not None) == ( - self.gas_price_oracle_uri is not None - ): + if (self.fixed_wei_value is not None) == (self.oracle_uri is not None): raise ValidationError( { - "gas_price_oracle_uri": "An oracle uri or fixed gas price should be provided (but not both)", - "gas_price_fixed_wei": "An oracle uri or fixed gas price should be provided (but not both)", + "oracle_uri": "An oracle uri or fixed gas price should be provided (but not both)", + "fixed_wei_value": "An oracle uri or fixed gas price should be provided (but not both)", } ) - if ( - self.gas_price_oracle_uri is not None - and self.gas_price_oracle_parameter is None - ): + if self.oracle_uri is not None and self.oracle_parameter is None: raise ValidationError( - {"gas_price_oracle_parameter": "The oracle parameter should be set"} + {"oracle_parameter": "The oracle parameter should be set"} ) - - def __str__(self): - return f"{self.name} | chain_id={self.id}" diff --git a/src/chains/serializers.py b/src/chains/serializers.py index 15eda9b8..05f20bd7 100644 --- a/src/chains/serializers.py +++ b/src/chains/serializers.py @@ -10,16 +10,26 @@ class GasPriceOracleSerializer(serializers.Serializer): type = serializers.ReadOnlyField(default="oracle") - uri = serializers.URLField(source="gas_price_oracle_uri") - gas_parameter = serializers.CharField(source="gas_price_oracle_parameter") - gwei_factor = serializers.DecimalField( - source="gas_price_oracle_gwei_factor", max_digits=19, decimal_places=9 - ) + uri = serializers.URLField(source="oracle_uri") + gas_parameter = serializers.CharField(source="oracle_parameter") + gwei_factor = serializers.DecimalField(max_digits=19, decimal_places=9) class GasPriceFixedSerializer(serializers.Serializer): type = serializers.ReadOnlyField(default="fixed") - wei_value = serializers.CharField(source="gas_price_fixed_wei") + wei_value = serializers.CharField(source="fixed_wei_value") + + +class GasPriceSerializer(serializers.Serializer): + def to_representation(self, instance): + if instance.oracle_uri and instance.fixed_wei_value is None: + return GasPriceOracleSerializer(instance).data + elif instance.fixed_wei_value and instance.oracle_uri is None: + return GasPriceFixedSerializer(instance).data + else: + raise APIException( + f"The gas price oracle or a fixed gas price was not provided for chain {instance.chain}" + ) class ThemeSerializer(serializers.Serializer): @@ -123,13 +133,7 @@ def get_rpc_uri(obj): def get_block_explorer_uri_template(obj): return BlockExplorerUriTemplateSerializer(obj).data - @staticmethod - def get_gas_price(obj): - if obj.gas_price_oracle_uri and obj.gas_price_fixed_wei is None: - return GasPriceOracleSerializer(obj).data - elif obj.gas_price_fixed_wei and obj.gas_price_oracle_uri is None: - return GasPriceFixedSerializer(obj).data - else: - raise APIException( - f"The gas price oracle or a fixed gas price was not provided for chain {obj.id}" - ) + @swagger_serializer_method(serializer_or_field=GasPriceSerializer) + def get_gas_price(self, instance): + ranked_gas_prices = instance.gasprice_set.all().order_by("rank") + return GasPriceSerializer(ranked_gas_prices, many=True).data diff --git a/src/chains/tests/factories.py b/src/chains/tests/factories.py index fce9db08..55165eaf 100644 --- a/src/chains/tests/factories.py +++ b/src/chains/tests/factories.py @@ -4,7 +4,7 @@ import web3 from factory.django import DjangoModelFactory -from ..models import Chain +from ..models import Chain, GasPrice class ChainFactory(DjangoModelFactory): @@ -31,13 +31,21 @@ class Meta: transaction_service_uri = factory.Faker("url") theme_text_color = factory.Faker("hex_color") theme_background_color = factory.Faker("hex_color") - gas_price_oracle_uri = None - gas_price_oracle_parameter = None ens_registry_address = factory.LazyAttribute( lambda o: web3.Account.create().address ) - gas_price_oracle_gwei_factor = factory.Faker( + recommended_master_copy_version = "1.3.0" + + +class GasPriceFactory(DjangoModelFactory): + class Meta: + model = GasPrice + + chain = factory.SubFactory(ChainFactory) + oracle_uri = None + oracle_parameter = None + gwei_factor = factory.Faker( "pydecimal", positive=True, min_value=1, max_value=1_000_000_000, right_digits=9 ) - gas_price_fixed_wei = factory.Faker("pyint") - recommended_master_copy_version = "1.3.0" + fixed_wei_value = factory.Faker("pyint") + rank = factory.Faker("pyint") diff --git a/src/chains/tests/test_models.py b/src/chains/tests/test_models.py index 76124edf..ebea6cfa 100644 --- a/src/chains/tests/test_models.py +++ b/src/chains/tests/test_models.py @@ -6,7 +6,7 @@ from django.test import TestCase, TransactionTestCase from faker import Faker -from .factories import ChainFactory +from .factories import ChainFactory, GasPriceFactory class ChainTestCase(TestCase): @@ -18,63 +18,77 @@ def test_str_method_outputs_name_chain_id(self): ) +class GasPriceTestCase(TestCase): + def test_str_method_output(self): + gas_price = GasPriceFactory.create() + + self.assertEqual( + str(gas_price), + f"Chain = {gas_price.chain.id} | uri={gas_price.oracle_uri} | fixed_wei_value={gas_price.fixed_wei_value}", + ) + + class ChainGasPriceFixedTestCase(TestCase): @staticmethod def test_null_oracle_with_non_null_fixed_gas_price(): - chain = ChainFactory.create( - gas_price_oracle_uri=None, gas_price_fixed_wei=10000 + gas_price = GasPriceFactory.create( + oracle_uri=None, + fixed_wei_value=10000, ) - chain.full_clean() + gas_price.full_clean() def test_null_oracle_gas_oracle_with_null_fixed_gas_price(self): - chain = ChainFactory.create(gas_price_oracle_uri=None, gas_price_fixed_wei=None) + gas_price = GasPriceFactory.create( + oracle_uri=None, + fixed_wei_value=None, + ) with self.assertRaises(ValidationError): - chain.full_clean() + gas_price.full_clean() @staticmethod def test_big_number(): - chain = ChainFactory.create( - gas_price_oracle_uri=None, - gas_price_fixed_wei="115792089237316195423570985008687907853269984665640564039457584007913129639935", + gas_price = GasPriceFactory.create( + oracle_uri=None, + fixed_wei_value="115792089237316195423570985008687907853269984665640564039457584007913129639935", ) - chain.full_clean() + gas_price.full_clean() class ChainGasPriceOracleTestCase(TestCase): faker = Faker() def test_oracle_gas_parameter_with_null_uri(self): - chain = ChainFactory.create( - gas_price_oracle_uri=None, - gas_price_oracle_parameter="fake parameter", - gas_price_fixed_wei=None, + gas_price = GasPriceFactory.create( + oracle_uri=None, + oracle_parameter="fake parameter", + fixed_wei_value=None, ) with self.assertRaises(ValidationError): - chain.full_clean() + gas_price.full_clean() def test_null_oracle_gas_parameter_with_uri(self): - chain = ChainFactory.create( - gas_price_oracle_uri=self.faker.url(), - gas_price_oracle_parameter=None, - gas_price_fixed_wei=None, + gas_price = GasPriceFactory.create( + oracle_uri=self.faker.url(), + oracle_parameter=None, + fixed_wei_value=None, ) with self.assertRaises(ValidationError): - chain.full_clean() + gas_price.full_clean() def test_oracle_gas_parameter_with_uri(self): - chain = ChainFactory.create( - gas_price_oracle_uri=self.faker.url(), - gas_price_oracle_parameter="fake parameter", - gas_price_fixed_wei=None, + gas_price = GasPriceFactory.create( + oracle_uri=self.faker.url(), + oracle_parameter="fake parameter", + fixed_wei_value=None, ) # No validation exception should be thrown - chain.full_clean() + gas_price.full_clean() class ChainColorValidationTestCase(TransactionTestCase): @@ -154,30 +168,30 @@ class ChainGweiFactorTestCase(TestCase): @staticmethod def test_ether_to_gwei_conversion_rate_valid(): eth_gwei = Decimal("0.000000001") # 0.000000001 ETH == 1 GWei - chain = ChainFactory.create(gas_price_oracle_gwei_factor=eth_gwei) + gas_price = GasPriceFactory.create(gwei_factor=eth_gwei) - chain.full_clean() + gas_price.full_clean() @staticmethod def test_wei_to_gwei_conversion_rate_valid(): eth_gwei = Decimal("1000000000") # 1000000000 Wei == 1 GWei - chain = ChainFactory.create(gas_price_oracle_gwei_factor=eth_gwei) + gas_price = GasPriceFactory.create(gwei_factor=eth_gwei) - chain.full_clean() + gas_price.full_clean() def test_1e_minus10_conversion_rate_invalid(self): factor = Decimal("0.00000000001") - chain = ChainFactory.create(gas_price_oracle_gwei_factor=factor) + gas_price = GasPriceFactory.create(gwei_factor=factor) with self.assertRaises(ValidationError): - chain.full_clean() + gas_price.full_clean() def test_1e10_conversion_rate_invalid(self): factor = Decimal("10000000000") with self.assertRaises(DataError): - chain = ChainFactory.create(gas_price_oracle_gwei_factor=factor) - chain.full_clean() + gas_price = GasPriceFactory.create(gwei_factor=factor) + gas_price.full_clean() class ChainMinMasterCopyVersionValidationTestCase(TransactionTestCase): diff --git a/src/chains/tests/test_views.py b/src/chains/tests/test_views.py index d2afe0f3..be7e4be6 100644 --- a/src/chains/tests/test_views.py +++ b/src/chains/tests/test_views.py @@ -2,7 +2,7 @@ from faker import Faker from rest_framework.test import APITestCase -from .factories import ChainFactory +from .factories import ChainFactory, GasPriceFactory class EmptyChainsListViewTests(APITestCase): @@ -19,13 +19,14 @@ def test_empty_chains(self): class ChainJsonPayloadFormatViewTests(APITestCase): def test_json_payload_format(self): chain = ChainFactory.create() + gas_price = GasPriceFactory.create(chain=chain) json_response = { "count": 1, "next": None, "previous": None, "results": [ { - "chainId": str(chain.id), + "chainId": str(gas_price.chain.id), "chainName": chain.name, "rpcUri": { "authentication": chain.rpc_authentication, @@ -50,10 +51,12 @@ def test_json_payload_format(self): "textColor": chain.theme_text_color, "backgroundColor": chain.theme_background_color, }, - "gasPrice": { - "type": "fixed", - "weiValue": str(chain.gas_price_fixed_wei), - }, + "gasPrice": [ + { + "type": "fixed", + "weiValue": str(gas_price.fixed_wei_value), + } + ], "ensRegistryAddress": chain.ens_registry_address, "recommendedMasterCopyVersion": chain.recommended_master_copy_version, } @@ -124,6 +127,7 @@ def test_offset_greater_than_count(self): class ChainDetailViewTests(APITestCase): def test_json_payload_format(self): chain = ChainFactory.create(id=1) + gas_price = GasPriceFactory.create(chain=chain) url = reverse("v1:chains:detail", args=[1]) json_response = { "chainId": str(chain.id), @@ -151,10 +155,12 @@ def test_json_payload_format(self): "textColor": chain.theme_text_color, "backgroundColor": chain.theme_background_color, }, - "gasPrice": { - "type": "fixed", - "weiValue": str(chain.gas_price_fixed_wei), - }, + "gasPrice": [ + { + "type": "fixed", + "weiValue": str(gas_price.fixed_wei_value), + } + ], "ensRegistryAddress": chain.ens_registry_address, "recommendedMasterCopyVersion": chain.recommended_master_copy_version, } @@ -174,6 +180,7 @@ def test_no_match(self): def test_match(self): chain = ChainFactory.create(id=1) + gas_price = GasPriceFactory.create(chain=chain) url = reverse("v1:chains:detail", args=[1]) json_response = { "chain_id": str(chain.id), @@ -201,10 +208,12 @@ def test_match(self): "text_color": chain.theme_text_color, "background_color": chain.theme_background_color, }, - "gas_price": { - "type": "fixed", - "wei_value": str(chain.gas_price_fixed_wei), - }, + "gas_price": [ + { + "type": "fixed", + "wei_value": str(gas_price.fixed_wei_value), + } + ], "ens_registry_address": chain.ens_registry_address, "recommended_master_copy_version": chain.recommended_master_copy_version, } @@ -254,17 +263,72 @@ def test_null_ens_registry_address(self): class ChainGasPriceTests(APITestCase): faker = Faker() + def test_rank_sort(self): + chain = ChainFactory.create(id=1) + # fixed price rank 100 + gas_price_100 = GasPriceFactory.create( + chain=chain, + rank=100, + ) + # oracle price rank 50 + gas_price_50 = GasPriceFactory.create( + chain=chain, + oracle_uri=self.faker.url(), + oracle_parameter="fast", + fixed_wei_value=None, + rank=50, + ) + # fixed price rank 1 + gas_price_1 = GasPriceFactory.create( + chain=chain, + rank=1, + ) + expected = [ + { + "type": "fixed", + "wei_value": str(gas_price_1.fixed_wei_value), + }, + { + "type": "oracle", + "uri": gas_price_50.oracle_uri, + "gas_parameter": gas_price_50.oracle_parameter, + "gwei_factor": str(gas_price_50.gwei_factor), + }, + { + "type": "fixed", + "wei_value": str(gas_price_100.fixed_wei_value), + }, + ] + url = reverse("v1:chains:detail", args=[1]) + + response = self.client.get(path=url, data=None, format="json") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["gas_price"], expected) + + def test_empty_gas_prices(self): + ChainFactory.create(id=1) + url = reverse("v1:chains:detail", args=[1]) + + response = self.client.get(path=url, data=None, format="json") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["gas_price"], []) + def test_oracle_json_payload_format(self): - chain = ChainFactory.create( - id=1, gas_price_oracle_uri=self.faker.url(), gas_price_fixed_wei=None + chain = ChainFactory.create(id=1) + gas_price = GasPriceFactory.create( + chain=chain, oracle_uri=self.faker.url(), fixed_wei_value=None ) url = reverse("v1:chains:detail", args=[1]) - expected_oracle_json_payload = { - "type": "oracle", - "uri": chain.gas_price_oracle_uri, - "gasParameter": chain.gas_price_oracle_parameter, - "gweiFactor": "{0:.9f}".format(chain.gas_price_oracle_gwei_factor), - } + expected_oracle_json_payload = [ + { + "type": "oracle", + "uri": gas_price.oracle_uri, + "gasParameter": gas_price.oracle_parameter, + "gweiFactor": "{0:.9f}".format(gas_price.gwei_factor), + } + ] response = self.client.get(path=url, data=None, format="json") @@ -272,12 +336,17 @@ def test_oracle_json_payload_format(self): self.assertEqual(response.json()["gasPrice"], expected_oracle_json_payload) def test_fixed_gas_price_json_payload_format(self): - chain = ChainFactory.create(id=1, gas_price_fixed_wei=self.faker.pyint()) + chain = ChainFactory.create(id=1) + gas_price = GasPriceFactory.create( + chain=chain, fixed_wei_value=self.faker.pyint() + ) url = reverse("v1:chains:detail", args=[1]) - expected_oracle_json_payload = { - "type": "fixed", - "weiValue": str(chain.gas_price_fixed_wei), - } + expected_oracle_json_payload = [ + { + "type": "fixed", + "weiValue": str(gas_price.fixed_wei_value), + } + ] response = self.client.get(path=url, data=None, format="json") @@ -285,14 +354,15 @@ def test_fixed_gas_price_json_payload_format(self): self.assertEqual(response.json()["gasPrice"], expected_oracle_json_payload) def test_oracle_with_fixed(self): - chain = ChainFactory.create( - id=1, - gas_price_oracle_uri=self.faker.url(), - gas_price_fixed_wei=self.faker.pyint(), + chain = ChainFactory.create(id=1) + GasPriceFactory.create( + chain=chain, + oracle_uri=self.faker.url(), + fixed_wei_value=self.faker.pyint(), ) url = reverse("v1:chains:detail", args=[1]) expected_error_body = { - "detail": f"The gas price oracle or a fixed gas price was not provided for chain {chain.id}" + "detail": f"The gas price oracle or a fixed gas price was not provided for chain {chain}" } response = self.client.get(path=url, data=None, format="json") @@ -301,15 +371,18 @@ def test_oracle_with_fixed(self): self.assertEqual(response.json(), expected_error_body) def test_fixed_gas_256_bit(self): - ChainFactory.create( - id=1, - gas_price_fixed_wei="115792089237316195423570985008687907853269984665640564039457584007913129639935", + chain = ChainFactory.create(id=1) + GasPriceFactory.create( + chain=chain, + fixed_wei_value="115792089237316195423570985008687907853269984665640564039457584007913129639935", ) url = reverse("v1:chains:detail", args=[1]) - expected_oracle_json_payload = { - "type": "fixed", - "weiValue": "115792089237316195423570985008687907853269984665640564039457584007913129639935", - } + expected_oracle_json_payload = [ + { + "type": "fixed", + "weiValue": "115792089237316195423570985008687907853269984665640564039457584007913129639935", + } + ] response = self.client.get(path=url, data=None, format="json")