Repository: safe-global/safe-config-service

Issue:
Fixed gas price settings for EIP-1559
**Is your feature request related to a problem? Please describe.**
On chain's that support EIP-1559 it is currently not possible to use fixed gas prices for both `maxFeePerGas` and `maxPriorityFeePerGas` as we currently only allow one `weiValue` for fixed gas prices.

**Provide references to the feature you are implementing that requires this change**
For https://github.com/safe-global/safe-wallet-web/issues/2222
we talked with the optimism team and they suggested to use fixed `maxFeePerGas` of 0.1 gwei and `maxPriorityFeePerGas` of 0.0001 gwei. 
This is currently not possible through the chain config of the config service.

**Describe the solution you'd like**
I would like to add a new type of gas config `fixed1559`
It requires two new fields `maxFeePerGas` and `maxPriorityFeePerGas` in the `GasPrice` model which are both configured in WEI - analog to the `fixed_wei_value` for the current `fixed` gas config.

**Describe alternatives you've considered**
Alternatively we would have to add custom code to our wallet interfaces and set these gas parameters - overwriting the gas-configs of the chains. 
This would require a new deployment if we wanted to change these settings. 
The team also mentioned that we will need similar settings on other networks such as Base. 
Having this implemented on config service level would enable us to
- Easily change the chain configs if the suggested prices change
- Reuse the solution for multiple networks
- Reuse the solution for the mobile wallet interface to have a consistent experience.




Discussion:

Resolving patch:
diff --git a/src/chains/migrations/0038_gasprice_max_fee_per_gas_and_more.py b/src/chains/migrations/0038_gasprice_max_fee_per_gas_and_more.py
new file mode 100644
--- /dev/null
+++ b/src/chains/migrations/0038_gasprice_max_fee_per_gas_and_more.py
@@ -0,0 +1,27 @@
+# Generated by Django 4.2.3 on 2023-08-14 15:24
+
+import gnosis.eth.django.models
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("chains", "0037_chain_hidden"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="gasprice",
+            name="max_fee_per_gas",
+            field=gnosis.eth.django.models.Uint256Field(  # type: ignore[no-untyped-call]
+                blank=True, null=True, verbose_name="Max fee per gas (wei)"
+            ),
+        ),
+        migrations.AddField(
+            model_name="gasprice",
+            name="max_priority_fee_per_gas",
+            field=gnosis.eth.django.models.Uint256Field(  # type: ignore[no-untyped-call]
+                blank=True, null=True, verbose_name="Max priority fee per gas (wei)"
+            ),
+        ),
+    ]
diff --git a/src/chains/models.py b/src/chains/models.py
--- a/src/chains/models.py
+++ b/src/chains/models.py
@@ -147,16 +147,37 @@ class GasPrice(models.Model):
     rank = models.SmallIntegerField(
         default=100
     )  # A lower number will indicate higher ranking
+    max_fee_per_gas = Uint256Field(
+        verbose_name="Max fee per gas (wei)", blank=True, null=True
+    )  # type: ignore[no-untyped-call]
+    max_priority_fee_per_gas = Uint256Field(
+        verbose_name="Max priority fee per gas (wei)", blank=True, null=True
+    )  # type: ignore[no-untyped-call]
 
     def __str__(self) -> str:
-        return f"Chain = {self.chain.id} | uri={self.oracle_uri} | fixed_wei_value={self.fixed_wei_value}"
+        return f"Chain = {self.chain.id} | uri={self.oracle_uri} | fixed_wei_value={self.fixed_wei_value} | max_fee_per_gas={self.max_fee_per_gas} | max_priority_fee_per-gas={self.max_priority_fee_per_gas}"  # noqa E501
 
     def clean(self) -> None:
-        if (self.fixed_wei_value is not None) == (self.oracle_uri is not None):
+        fixed_wei_defined = self.fixed_wei_value is not None
+        fixed1559_defined = (
+            self.max_fee_per_gas is not None
+            and self.max_priority_fee_per_gas is not None
+        )
+        oracle_defined = self.oracle_uri is not None
+        exactly_one_variant = [
+            fixed_wei_defined,
+            fixed1559_defined,
+            oracle_defined,
+        ].count(True) == 1
+        multiple_variants_error = "An oracle uri, fixed gas price or maxFeePerGas and maxPriorityFeePerGas \
+            should be provided (but not multiple)"
+        if not exactly_one_variant:
             raise ValidationError(
                 {
-                    "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)",
+                    "oracle_uri": multiple_variants_error,
+                    "fixed_wei_value": multiple_variants_error,
+                    "max_fee_per_gas": multiple_variants_error,
+                    "max_priority_fee_per_gas": multiple_variants_error,
                 }
             )
         if self.oracle_uri is not None and self.oracle_parameter is None:
diff --git a/src/chains/serializers.py b/src/chains/serializers.py
--- a/src/chains/serializers.py
+++ b/src/chains/serializers.py
@@ -21,12 +21,35 @@ class GasPriceFixedSerializer(serializers.Serializer[GasPrice]):
     wei_value = serializers.CharField(source="fixed_wei_value")
 
 
+class GasPriceFixed1559Serializer(serializers.Serializer[GasPrice]):
+    type = serializers.ReadOnlyField(default="fixed1559")
+    max_fee_per_gas = serializers.CharField()
+    max_priority_fee_per_gas = serializers.CharField()
+
+
 class GasPriceSerializer(serializers.Serializer[GasPrice]):
     def to_representation(self, instance: GasPrice) -> ReturnDict:
-        if instance.oracle_uri and instance.fixed_wei_value is None:
+        if (
+            instance.oracle_uri
+            and instance.fixed_wei_value is None
+            and instance.max_fee_per_gas is None
+            and instance.max_priority_fee_per_gas is None
+        ):
             return GasPriceOracleSerializer(instance).data
-        elif instance.fixed_wei_value and instance.oracle_uri is None:
+        elif (
+            instance.fixed_wei_value
+            and instance.oracle_uri is None
+            and instance.max_fee_per_gas is None
+            and instance.max_priority_fee_per_gas is None
+        ):
             return GasPriceFixedSerializer(instance).data
+        elif (
+            instance.max_fee_per_gas
+            and instance.max_priority_fee_per_gas
+            and instance.oracle_uri is None
+            and instance.fixed_wei_value is None
+        ):
+            return GasPriceFixed1559Serializer(instance).data
         else:
             raise APIException(
                 f"The gas price oracle or a fixed gas price was not provided for chain {instance.chain}"


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
@@ -58,6 +58,8 @@ class Meta:
         "pydecimal", positive=True, min_value=1, max_value=1_000_000_000, right_digits=9
     )
     fixed_wei_value = factory.Faker("pyint")
+    max_fee_per_gas = None
+    max_priority_fee_per_gas = None
     rank = factory.Faker("pyint")
 
 
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
@@ -24,7 +24,7 @@ def test_str_method_output(self) -> None:
 
         self.assertEqual(
             str(gas_price),
-            f"Chain = {gas_price.chain.id} | uri={gas_price.oracle_uri} | fixed_wei_value={gas_price.fixed_wei_value}",
+            f"Chain = {gas_price.chain.id} | uri={gas_price.oracle_uri} | fixed_wei_value={gas_price.fixed_wei_value} | max_fee_per_gas={gas_price.max_fee_per_gas} | max_priority_fee_per-gas={gas_price.max_priority_fee_per_gas}",  # noqa E501
         )
 
 
@@ -57,6 +57,43 @@ def test_big_number() -> None:
         gas_price.full_clean()
 
 
+class ChainGasPriceFixed1559TestCase(TestCase):
+    faker = Faker()
+
+    @staticmethod
+    def test_only_fixed1559_defined() -> None:
+        gas_price = GasPriceFactory.create(
+            oracle_uri=None,
+            fixed_wei_value=None,
+            max_fee_per_gas="100000",
+            max_priority_fee_per_gas="1000",
+        )
+
+        gas_price.full_clean()
+
+    def test_fixed_and_fixed1559_defined(self) -> None:
+        gas_price = GasPriceFactory.create(
+            fixed_wei_value="100000",
+            max_fee_per_gas="100000",
+            max_priority_fee_per_gas="1000",
+        )
+
+        with self.assertRaises(ValidationError):
+            gas_price.full_clean()
+
+    def test_fixed_and_fixed1559_and_oracle_defined(self) -> None:
+        gas_price = GasPriceFactory.create(
+            oracle_uri=self.faker.url(),
+            oracle_parameter="fake parameter",
+            fixed_wei_value="100000",
+            max_fee_per_gas="100000",
+            max_priority_fee_per_gas="1000",
+        )
+
+        with self.assertRaises(ValidationError):
+            gas_price.full_clean()
+
+
 class ChainGasPriceOracleTestCase(TestCase):
     faker = Faker()
 
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
@@ -447,6 +447,28 @@ def test_fixed_gas_256_bit(self) -> None:
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.json()["gasPrice"], expected_oracle_json_payload)
 
+    def test_fixed_gas_price_1559_json_payload_format(self) -> None:
+        chain = ChainFactory.create(id=1)
+        gas_price = GasPriceFactory.create(
+            chain=chain,
+            max_fee_per_gas=self.faker.pyint(),
+            max_priority_fee_per_gas=self.faker.pyint(),
+            fixed_wei_value=None,
+        )
+        url = reverse("v1:chains:detail", args=[1])
+        expected_oracle_json_payload = [
+            {
+                "type": "fixed1559",
+                "maxFeePerGas": str(gas_price.max_fee_per_gas),
+                "maxPriorityFeePerGas": str(gas_price.max_priority_fee_per_gas),
+            }
+        ]
+
+        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)
+
 
 class WalletTests(APITestCase):
     def test_wallet_with_no_chains_show_as_disabled(self) -> None: