Repository: safe-global/safe-config-service

Issue:
Support for fixed gas price without oracle
For some chains a fixed gas price can be used (instead of relying on a gas price oracle).

On the safe-react, the `GasPrice` can be either given with `gasPrice | gasPriceOracle?` or `gasPrice? | gasPriceOracle`: 
https://github.com/gnosis/safe-react/blob/ae206843c86aa1663ca6db66d3662c1882d36991/src/config/networks/network.d.ts#L80-L89

Given this, if `gasPrice` is given then `gasPriceOracle` should be `null` and if `gasPriceOracle` is given then `gasPrice` should be null.

### Open Questions

1. Is the `gasPrice` considered to be in `gwei`
2. Is it correct to assume that only one can be provided and not both?

cc/ @gnosis/safe-web 




Discussion:
@fmrsabino with that typing in Javascript if one of the values is provided the other one doesn't have to, but still it enforces that at least one of them is set.

Right now I think we are using it only on xDai, so with the current case, the fixed gas price is set to 1Gwei, and we set it in Gwei.

The app globally is mostly expecting that the oracles return the price in Gwei and then convert as necessary

Currently we don't have a case where we have to provide both, but we left that version open as a fallback, because we didn't know all the information related to networks back then. With the current use cases is correct to assume that only one is provided
@dasanra looking at https://github.com/gnosis/safe-react/blob/ae206843c86aa1663ca6db66d3662c1882d36991/src/config/networks/xdai.ts#L9 you set `gasPrice` in Wei (as 1_000_000_000 Wei are 1 Gwei and 1_000_000_000 Gwei would be 1 ETH and I assume the gas price is not 1 ETH) 
So if you get the following from the services it'd work for you?

```javascript
{
  ...
  "gasPriceOracle": {
    ...
  },
  "gasPrice": null
}
```

or

```javascript
{
  ...
  "gasPriceOracle": null,
  "gasPrice": <stringified-number>
}
```
Wouldn't it make sense to use a polymorphic response here? This would clearly show that it is either or and cannot be both. At least on client gateway side this is what is being done in such cases.

e.g. 
```json
{
  "type": "oracle",
  "url": <url_string>,
  "param": <param_name>,
  "multiplier": <multiplier_number>
}
```
and
```json
{
  "type": "fixed",
  "gasPrice": <number_string>
}
``` 
Yes I agree. That'd would be a better alternative 👍 Just to put it in context of a `gasPrice` object:

```javascript
{
  "gasPrice" {
    "type": "oracle",
    "url": <url_string>,
    "gasParameter": <param_name>,
    "gweiFactor": <multiplier_number>
  }
}
```

or

```javascript
{
  "gasPrice" {
    "type": "fixed",
    "value": <stringified-number>
  }
}
```
> @dasanra looking at https://github.com/gnosis/safe-react/blob/ae206843c86aa1663ca6db66d3662c1882d36991/src/config/networks/xdai.ts#L9 you set `gasPrice` in Wei (as 1_000_000_000 Wei are 1 Gwei and 1_000_000_000 Gwei would be 1 ETH and I assume the gas price is not 1 ETH)

If it is indeed in `Wei` then I think that would be preferable for representation purposes (one `UInt256` instead of a decimal field)
> @dasanra looking at https://github.com/gnosis/safe-react/blob/ae206843c86aa1663ca6db66d3662c1882d36991/src/config/networks/xdai.ts#L9 you set `gasPrice` in Wei (as 1_000_000_000 Wei are 1 Gwei and 1_000_000_000 Gwei would be 1 ETH and I assume the gas price is not 1 ETH)

:facepalm: wrong thing talking without checking. On my memory the number on xDai config file seemed very short, but we were using scientific notation. Sorry about that.

Yes, fixed gas price is using Wei

Resolving patch:
diff --git a/src/chains/migrations/0012_chain_gas_price_fixed_wei.py b/src/chains/migrations/0012_chain_gas_price_fixed_wei.py
new file mode 100644
--- /dev/null
+++ b/src/chains/migrations/0012_chain_gas_price_fixed_wei.py
@@ -0,0 +1,24 @@
+# Generated by Django 3.2.5 on 2021-07-14 09:18
+
+import gnosis.eth.django.models
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        (
+            "chains",
+            "0011_rename_min_master_copy_version_chain_recommended_master_copy_version",
+        ),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="chain",
+            name="gas_price_fixed_wei",
+            field=gnosis.eth.django.models.Uint256Field(
+                blank=True, null=True, verbose_name="Fixed gas price (wei)"
+            ),
+        ),
+    ]
diff --git a/src/chains/models.py b/src/chains/models.py
--- a/src/chains/models.py
+++ b/src/chains/models.py
@@ -3,7 +3,7 @@
 from django.core.exceptions import ValidationError
 from django.core.validators import RegexValidator
 from django.db import models
-from gnosis.eth.django.models import EthereumAddressField
+from gnosis.eth.django.models import EthereumAddressField, Uint256Field
 
 HEX_ARGB_REGEX = re.compile("^#[0-9a-fA-F]{6}$")
 
@@ -42,9 +42,9 @@ class Chain(models.Model):
         default="#000000",
         help_text="Please use the following format: <em>#RRGGBB</em>.",
     )
+    ens_registry_address = EthereumAddressField(null=True, blank=True)
     gas_price_oracle_url = models.URLField(blank=True, null=True)
     gas_price_oracle_parameter = models.CharField(blank=True, null=True, max_length=255)
-    ens_registry_address = EthereumAddressField(null=True, blank=True)
     gas_price_oracle_gwei_factor = models.DecimalField(
         default=1,
         max_digits=19,
@@ -52,11 +52,23 @@ class Chain(models.Model):
         verbose_name="Gwei multiplier factor",
         help_text="Factor required to reach the Gwei unit",
     )
+    gas_price_fixed_wei = Uint256Field(
+        verbose_name="Fixed gas price (wei)", blank=True, null=True
+    )
     recommended_master_copy_version = models.CharField(
         max_length=255, validators=[sem_ver_validator]
     )
 
     def clean(self):
+        if (self.gas_price_fixed_wei is not None) == (
+            self.gas_price_oracle_url is not None
+        ):
+            raise ValidationError(
+                {
+                    "gas_price_oracle_url": "An oracle url or fixed gas price should be provided (but not both)",
+                    "gas_price_fixed": "An oracle url or fixed gas price should be provided (but not both)",
+                }
+            )
         if self.gas_price_oracle_parameter and self.gas_price_oracle_url is None:
             raise ValidationError(
                 {
diff --git a/src/chains/serializers.py b/src/chains/serializers.py
--- a/src/chains/serializers.py
+++ b/src/chains/serializers.py
@@ -1,11 +1,13 @@
 from drf_yasg.utils import swagger_serializer_method
 from gnosis.eth.django.serializers import EthereumAddressField
 from rest_framework import serializers
+from rest_framework.exceptions import APIException
 
 from .models import Chain
 
 
 class GasPriceOracleSerializer(serializers.Serializer):
+    type = serializers.ReadOnlyField(default="oracle")
     url = serializers.URLField(source="gas_price_oracle_url")
     gas_parameter = serializers.CharField(source="gas_price_oracle_parameter")
     gwei_factor = serializers.DecimalField(
@@ -13,6 +15,11 @@ class GasPriceOracleSerializer(serializers.Serializer):
     )
 
 
+class GasPriceFixedSerializer(serializers.Serializer):
+    type = serializers.ReadOnlyField(default="fixed")
+    wei_value = serializers.CharField(source="gas_price_fixed_wei")
+
+
 class ThemeSerializer(serializers.Serializer):
     text_color = serializers.CharField(source="theme_text_color")
     background_color = serializers.CharField(source="theme_background_color")
@@ -33,7 +40,7 @@ class ChainSerializer(serializers.ModelSerializer):
         source="transaction_service_url", default=None
     )
     theme = serializers.SerializerMethodField()
-    gas_price_oracle = serializers.SerializerMethodField()
+    gas_price = serializers.SerializerMethodField()
     ens_registry_address = EthereumAddressField()
 
     class Meta:
@@ -46,7 +53,7 @@ class Meta:
             "native_currency",
             "transaction_service",
             "theme",
-            "gas_price_oracle",
+            "gas_price",
             "ens_registry_address",
             "recommended_master_copy_version",
         ]
@@ -62,8 +69,12 @@ def get_theme(obj):
         return ThemeSerializer(obj).data
 
     @staticmethod
-    @swagger_serializer_method(serializer_or_field=GasPriceOracleSerializer)
-    def get_gas_price_oracle(obj):
-        if obj.gas_price_oracle_url is None:
-            return None
-        return GasPriceOracleSerializer(obj).data
+    def get_gas_price(obj):
+        if obj.gas_price_oracle_url and obj.gas_price_fixed_wei is None:
+            return GasPriceOracleSerializer(obj).data
+        elif obj.gas_price_fixed_wei and obj.gas_price_oracle_url 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}"
+            )


Test patch:
diff --git a/src/chains/tests/factories.py b/src/chains/tests/factories.py
--- a/src/chains/tests/factories.py
+++ b/src/chains/tests/factories.py
@@ -21,14 +21,13 @@ class Meta:
     transaction_service_url = factory.Faker("url")
     theme_text_color = factory.Faker("hex_color")
     theme_background_color = factory.Faker("hex_color")
-    gas_price_oracle_url = factory.Faker("url")
-    gas_price_oracle_parameter = factory.Faker(
-        "random_element", elements=("safeLow", "average", "fast")
-    )
+    gas_price_oracle_url = None
+    gas_price_oracle_parameter = None
     ens_registry_address = factory.LazyAttribute(
         lambda o: web3.Account.create().address
     )
     gas_price_oracle_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"
diff --git a/src/chains/tests/test_models.py b/src/chains/tests/test_models.py
--- a/src/chains/tests/test_models.py
+++ b/src/chains/tests/test_models.py
@@ -18,6 +18,31 @@ def test_str_method_outputs_name_chain_id(self):
         )
 
 
+class ChainGasPriceFixedTestCase(TestCase):
+    @staticmethod
+    def test_null_oracle_with_non_null_fixed_gas_price():
+        chain = ChainFactory.create(
+            gas_price_oracle_url=None, gas_price_fixed_wei=10000
+        )
+
+        chain.full_clean()
+
+    def test_null_oracle_gas_oracle_with_null_fixed_gas_price(self):
+        chain = ChainFactory.create(gas_price_oracle_url=None, gas_price_fixed_wei=None)
+
+        with self.assertRaises(ValidationError):
+            chain.full_clean()
+
+    @staticmethod
+    def test_big_number():
+        chain = ChainFactory.create(
+            gas_price_oracle_url=None,
+            gas_price_fixed_wei="115792089237316195423570985008687907853269984665640564039457584007913129639935",
+        )
+
+        chain.full_clean()
+
+
 class ChainGasPriceOracleTestCase(TestCase):
     faker = Faker()
 
@@ -31,7 +56,9 @@ def test_oracle_gas_parameter_with_null_url(self):
 
     def test_null_oracle_gas_parameter_with_url(self):
         chain = ChainFactory.create(
-            gas_price_oracle_url=self.faker.url(), gas_price_oracle_parameter=None
+            gas_price_oracle_url=self.faker.url(),
+            gas_price_oracle_parameter=None,
+            gas_price_fixed_wei=None,
         )
 
         # No validation exception should be thrown
@@ -41,15 +68,7 @@ def test_oracle_gas_parameter_with_url(self):
         chain = ChainFactory.create(
             gas_price_oracle_url=self.faker.url(),
             gas_price_oracle_parameter="fake parameter",
-        )
-
-        # No validation exception should be thrown
-        chain.full_clean()
-
-    @staticmethod
-    def test_null_oracle_gas_parameter_with_null_url():
-        chain = ChainFactory.create(
-            gas_price_oracle_url=None, gas_price_oracle_parameter=None
+            gas_price_fixed_wei=None,
         )
 
         # No validation exception should be thrown
diff --git a/src/chains/tests/test_views.py b/src/chains/tests/test_views.py
--- a/src/chains/tests/test_views.py
+++ b/src/chains/tests/test_views.py
@@ -1,4 +1,5 @@
 from django.urls import reverse
+from faker import Faker
 from rest_framework.test import APITestCase
 
 from .factories import ChainFactory
@@ -39,12 +40,9 @@ def test_json_payload_format(self):
                         "textColor": chain.theme_text_color,
                         "backgroundColor": chain.theme_background_color,
                     },
-                    "gasPriceOracle": {
-                        "url": chain.gas_price_oracle_url,
-                        "gasParameter": chain.gas_price_oracle_parameter,
-                        "gweiFactor": "{0:.9f}".format(
-                            chain.gas_price_oracle_gwei_factor
-                        ),
+                    "gasPrice": {
+                        "type": "fixed",
+                        "weiValue": str(chain.gas_price_fixed_wei),
                     },
                     "ensRegistryAddress": chain.ens_registry_address,
                     "recommendedMasterCopyVersion": chain.recommended_master_copy_version,
@@ -133,10 +131,9 @@ def test_json_payload_format(self):
                 "textColor": chain.theme_text_color,
                 "backgroundColor": chain.theme_background_color,
             },
-            "gasPriceOracle": {
-                "url": chain.gas_price_oracle_url,
-                "gasParameter": chain.gas_price_oracle_parameter,
-                "gweiFactor": "{0:.9f}".format(chain.gas_price_oracle_gwei_factor),
+            "gasPrice": {
+                "type": "fixed",
+                "weiValue": str(chain.gas_price_fixed_wei),
             },
             "ensRegistryAddress": chain.ens_registry_address,
             "recommendedMasterCopyVersion": chain.recommended_master_copy_version,
@@ -174,10 +171,9 @@ def test_match(self):
                 "text_color": chain.theme_text_color,
                 "background_color": chain.theme_background_color,
             },
-            "gas_price_oracle": {
-                "url": chain.gas_price_oracle_url,
-                "gas_parameter": chain.gas_price_oracle_parameter,
-                "gwei_factor": "{0:.9f}".format(chain.gas_price_oracle_gwei_factor),
+            "gas_price": {
+                "type": "fixed",
+                "wei_value": str(chain.gas_price_fixed_wei),
             },
             "ens_registry_address": chain.ens_registry_address,
             "recommended_master_copy_version": chain.recommended_master_copy_version,
@@ -188,35 +184,6 @@ def test_match(self):
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.data, json_response)
 
-    def test_null_oracle_url_should_return_null_object(self):
-        chain = ChainFactory.create(id=1, gas_price_oracle_url=None)
-        url = reverse("v1:chains:detail", args=[1])
-        json_response = {
-            "chain_id": str(chain.id),
-            "chain_name": chain.name,
-            "rpc_url": chain.rpc_url,
-            "block_explorer_url": chain.block_explorer_url,
-            "native_currency": {
-                "name": chain.currency_name,
-                "symbol": chain.currency_symbol,
-                "decimals": chain.currency_decimals,
-                "logo_url": chain.currency_logo_url,
-            },
-            "transaction_service": chain.transaction_service_url,
-            "theme": {
-                "text_color": chain.theme_text_color,
-                "background_color": chain.theme_background_color,
-            },
-            "gas_price_oracle": None,
-            "ens_registry_address": chain.ens_registry_address,
-            "recommended_master_copy_version": chain.recommended_master_copy_version,
-        }
-
-        response = self.client.get(path=url, data=None, format="json")
-
-        self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.data, json_response)
-
 
 class ChainsListViewRelevanceTests(APITestCase):
     def test_relevance_sorting(self):
@@ -252,3 +219,69 @@ def test_null_ens_registry_address(self):
 
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.data["ens_registry_address"], None)
+
+
+class ChainGasPriceTests(APITestCase):
+    faker = Faker()
+
+    def test_oracle_json_payload_format(self):
+        chain = ChainFactory.create(
+            id=1, gas_price_oracle_url=self.faker.url(), gas_price_fixed_wei=None
+        )
+        url = reverse("v1:chains:detail", args=[1])
+        expected_oracle_json_payload = {
+            "type": "oracle",
+            "url": chain.gas_price_oracle_url,
+            "gasParameter": chain.gas_price_oracle_parameter,
+            "gweiFactor": "{0:.9f}".format(chain.gas_price_oracle_gwei_factor),
+        }
+
+        response = self.client.get(path=url, data=None, format="json")
+
+        self.assertEqual(response.status_code, 200)
+        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())
+        url = reverse("v1:chains:detail", args=[1])
+        expected_oracle_json_payload = {
+            "type": "fixed",
+            "weiValue": str(chain.gas_price_fixed_wei),
+        }
+
+        response = self.client.get(path=url, data=None, format="json")
+
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json()["gasPrice"], expected_oracle_json_payload)
+
+    def test_oracle_with_fixed(self):
+        chain = ChainFactory.create(
+            id=1,
+            gas_price_oracle_url=self.faker.url(),
+            gas_price_fixed_wei=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}"
+        }
+
+        response = self.client.get(path=url, data=None, format="json")
+
+        self.assertEqual(response.status_code, 500)
+        self.assertEqual(response.json(), expected_error_body)
+
+    def test_fixed_gas_256_bit(self):
+        ChainFactory.create(
+            id=1,
+            gas_price_fixed_wei="115792089237316195423570985008687907853269984665640564039457584007913129639935",
+        )
+        url = reverse("v1:chains:detail", args=[1])
+        expected_oracle_json_payload = {
+            "type": "fixed",
+            "weiValue": "115792089237316195423570985008687907853269984665640564039457584007913129639935",
+        }
+
+        response = self.client.get(path=url, data=None, format="json")
+
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json()["gasPrice"], expected_oracle_json_payload)