Repository: safe-global/safe-config-service

Issue:
Add Gas Price Oracle for each chain
Client should be able to use a gas price oracle that is associated with a specific chain. Given that, the oracle information and parameters should be retrieved with each chain.

```javascript
{
  ...
  "gasPriceOracle": { // optional
       "url": <url-string>,
       "gasParameter": <string> // eg.: safeLow | average | fast
   }
}
```

Open Questions:
1. Do we require that added chains have a Gas Price Oracle?

## Chains
 * **BSC**: api.bscscan.com
 * **Arbitrum**: uses the eth_gasPrice RPC method


Discussion:
I would say that the `gasParameter` can be `null` even if we have a `url` but not the other way around. Thoughts? @rmeissner @katspaugh 

Resolving patch:
diff --git a/src/chains/migrations/0004_auto_20210622_1353.py b/src/chains/migrations/0004_auto_20210622_1353.py
new file mode 100644
--- /dev/null
+++ b/src/chains/migrations/0004_auto_20210622_1353.py
@@ -0,0 +1,23 @@
+# Generated by Django 3.2.4 on 2021-06-22 13:53
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("chains", "0003_auto_20210617_1444"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="chain",
+            name="gas_price_oracle_parameter",
+            field=models.CharField(blank=True, max_length=255, null=True),
+        ),
+        migrations.AddField(
+            model_name="chain",
+            name="gas_price_oracle_url",
+            field=models.URLField(blank=True, null=True),
+        ),
+    ]
diff --git a/src/chains/models.py b/src/chains/models.py
--- a/src/chains/models.py
+++ b/src/chains/models.py
@@ -1,5 +1,6 @@
 import re
 
+from django.core.exceptions import ValidationError
 from django.core.validators import RegexValidator
 from django.db import models
 
@@ -23,6 +24,16 @@ class Chain(models.Model):
     theme_background_color = models.CharField(
         validators=[color_validator], max_length=9, default="#000"
     )
+    gas_price_oracle_url = models.URLField(blank=True, null=True)
+    gas_price_oracle_parameter = models.CharField(blank=True, null=True, max_length=255)
+
+    def clean(self):
+        if self.gas_price_oracle_parameter and self.gas_price_oracle_url is None:
+            raise ValidationError(
+                {
+                    "gas_price_oracle_parameter": "An oracle parameter was set with no Oracle url"
+                }
+            )
 
     def __str__(self):
         return f"{self.name} | chain_id={self.id}"
diff --git a/src/chains/serializers.py b/src/chains/serializers.py
--- a/src/chains/serializers.py
+++ b/src/chains/serializers.py
@@ -4,6 +4,11 @@
 from .models import Chain
 
 
+class GasPriceOracleSerializer(serializers.Serializer):
+    url = serializers.URLField(source="gas_price_oracle_url")
+    gas_parameter = serializers.CharField(source="gas_price_oracle_parameter")
+
+
 class ThemeSerializer(serializers.Serializer):
     text_color = serializers.CharField(source="theme_text_color")
     background_color = serializers.CharField(source="theme_background_color")
@@ -23,6 +28,7 @@ class ChainSerializer(serializers.ModelSerializer):
         source="transaction_service_url", default=None
     )
     theme = serializers.SerializerMethodField()
+    gas_price_oracle = serializers.SerializerMethodField()
 
     class Meta:
         model = Chain
@@ -34,6 +40,7 @@ class Meta:
             "native_currency",
             "transaction_service",
             "theme",
+            "gas_price_oracle",
         ]
 
     @staticmethod
@@ -45,3 +52,8 @@ def get_native_currency(obj):
     @swagger_serializer_method(serializer_or_field=ThemeSerializer)
     def get_theme(obj):
         return ThemeSerializer(obj).data
+
+    @staticmethod
+    @swagger_serializer_method(serializer_or_field=GasPriceOracleSerializer)
+    def get_gas_price_oracle(obj):
+        return GasPriceOracleSerializer(obj).data


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
@@ -18,3 +18,7 @@ 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")
+    )
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
@@ -15,7 +15,47 @@ def test_str_method_outputs_name_chain_id(self):
         )
 
 
+class ChainGasPriceOracleTestCase(TestCase):
+    faker = Faker()
+
+    def test_oracle_gas_parameter_with_null_url(self):
+        chain = ChainFactory.create(
+            gas_price_oracle_url=None, gas_price_oracle_parameter="fake parameter"
+        )
+
+        with self.assertRaises(ValidationError):
+            chain.full_clean()
+
+    def test_null_oracle_gas_parameter_with_url(self):
+        chain = ChainFactory.create(
+            gas_price_oracle_url=self.faker.url(), gas_price_oracle_parameter=None
+        )
+
+        # No validation exception should be thrown
+        chain.full_clean()
+
+    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
+        )
+
+        # No validation exception should be thrown
+        chain.full_clean()
+
+
 class ChainColorValidationTestCase(TransactionTestCase):
+    faker = Faker()
+
     def test_invalid_text_colors(self):
         param_list = ["aaa", "bbb", "#fffffffff", "zzz", "010", "", "a word", "#hhh"]
         for invalid_color in param_list:
@@ -32,7 +72,7 @@ def test_invalid_text_colors(self):
 
     def test_valid_text_colors(self):
         param_list = ["#000", "#fff", "#00000000", "#ffffffff"] + [
-            Faker().hex_color() for _ in range(20)
+            self.faker.hex_color() for _ in range(20)
         ]
         for valid_color in param_list:
             with self.subTest(msg=f"Valid color {valid_color} should not throw"):
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
@@ -38,6 +38,10 @@ 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,
+                    },
                 }
             ],
         }
@@ -122,6 +126,10 @@ 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,
+            },
         }
 
         response = self.client.get(path=url, data=None, format="json")
@@ -155,6 +163,10 @@ 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,
+            },
         }
 
         response = self.client.get(path=url, data=None, format="json")