Repository: safe-global/safe-config-service

Issue:
`ChainInfo` to include minimum recommended `MasterCopy` version
In order to provide information for the clients whether a safe is "Up to date" we need to know which is the version of the master copy that, as a minimum, is recommended.

This information can change from chain to chain, therefore we would like to have a field that specifies the version in the `ChainInfo` object.

```
ChainInfo {
  "minMasterCopyVersion" : "<semver-str>"
}
```


Discussion:
Maybe `minImplementationVersion` would be more appropriate? Doesn't matter to much, as this isn't exposed to the clients anyway.

We should probably implement this first: https://github.com/gnosis/safe-client-gateway/issues/451 in the CGW, so that we can hide the version an expose just the enum.
@jpalvarezl So this field would represent the minimum recommended version right?

If we have two recommended versions we only set the lowest on this field. This is just to consider the field name (if it could be `recommendedMasterCopyVersion`)

Resolving patch:
diff --git a/src/chains/migrations/0010_chain_min_master_copy_version.py b/src/chains/migrations/0010_chain_min_master_copy_version.py
new file mode 100644
--- /dev/null
+++ b/src/chains/migrations/0010_chain_min_master_copy_version.py
@@ -0,0 +1,34 @@
+# Generated by Django 3.2.5 on 2021-07-12 13:47
+
+import re
+
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("chains", "0009_chain_gas_price_oracle_gwei_factor"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="chain",
+            name="min_master_copy_version",
+            field=models.CharField(
+                default="1.3.0",
+                max_length=255,
+                validators=[
+                    django.core.validators.RegexValidator(
+                        re.compile(
+                            "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$"  # noqa E501
+                        ),
+                        "Invalid version (semver)",
+                        "invalid",
+                    )
+                ],
+            ),
+            preserve_default=False,
+        ),
+    ]
diff --git a/src/chains/models.py b/src/chains/models.py
--- a/src/chains/models.py
+++ b/src/chains/models.py
@@ -9,6 +9,13 @@
 
 color_validator = RegexValidator(HEX_ARGB_REGEX, "Invalid hex color", "invalid")
 
+# https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string
+SEM_VER_REGEX = re.compile(
+    r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$"  # noqa E501
+)
+
+sem_ver_validator = RegexValidator(SEM_VER_REGEX, "Invalid version (semver)", "invalid")
+
 
 class Chain(models.Model):
     id = models.PositiveBigIntegerField(verbose_name="Chain Id", primary_key=True)
@@ -45,6 +52,9 @@ class Chain(models.Model):
         verbose_name="Gwei multiplier factor",
         help_text="Factor required to reach the Gwei unit",
     )
+    min_master_copy_version = models.CharField(
+        max_length=255, validators=[sem_ver_validator]
+    )
 
     def clean(self):
         if self.gas_price_oracle_parameter and self.gas_price_oracle_url is None:
diff --git a/src/chains/serializers.py b/src/chains/serializers.py
--- a/src/chains/serializers.py
+++ b/src/chains/serializers.py
@@ -48,6 +48,7 @@ class Meta:
             "theme",
             "gas_price_oracle",
             "ens_registry_address",
+            "min_master_copy_version",
         ]
 
     @staticmethod


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
@@ -31,3 +31,4 @@ class Meta:
     gas_price_oracle_gwei_factor = factory.Faker(
         "pydecimal", positive=True, min_value=1, max_value=1_000_000_000, right_digits=9
     )
+    min_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
@@ -157,3 +157,38 @@ def test_1e10_conversion_rate_invalid(self):
         with self.assertRaises(DataError):
             chain = ChainFactory.create(gas_price_oracle_gwei_factor=factor)
             chain.full_clean()
+
+
+class ChainMinMasterCopyVersionValidationTestCase(TransactionTestCase):
+    def test_invalid_versions(self):
+        param_list = [
+            "1",
+            "1.2",
+            "1.2.3-0123",
+            "1.1.01",
+            "1.01.1",
+            "01.1.1",
+        ]
+
+        for invalid_version in param_list:
+            with self.subTest(msg=f"Invalid version {invalid_version} should throw"):
+                with self.assertRaises(ValidationError):
+                    chain = ChainFactory.create(min_master_copy_version=invalid_version)
+                    # run validators
+                    chain.full_clean()
+
+    def test_valid_versions(self):
+        param_list = [
+            "0.0.4",
+            "10.20.30",
+            "1.2.3",
+            "1.1.2-prerelease+meta",
+            "1.0.0-alpha.1",
+            "99999999999999999999999.999999999999999999.99999999999999999",
+        ]
+
+        for valid_version in param_list:
+            with self.subTest(msg=f"Valid version {valid_version} should not throw"):
+                chain = ChainFactory.create(min_master_copy_version=valid_version)
+                # run validators
+                chain.full_clean()
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
@@ -47,6 +47,7 @@ def test_json_payload_format(self):
                         ),
                     },
                     "ensRegistryAddress": chain.ens_registry_address,
+                    "minMasterCopyVersion": chain.min_master_copy_version,
                 }
             ],
         }
@@ -138,6 +139,7 @@ def test_json_payload_format(self):
                 "gweiFactor": "{0:.9f}".format(chain.gas_price_oracle_gwei_factor),
             },
             "ensRegistryAddress": chain.ens_registry_address,
+            "minMasterCopyVersion": chain.min_master_copy_version,
         }
 
         response = self.client.get(path=url, data=None, format="json")
@@ -178,6 +180,7 @@ def test_match(self):
                 "gwei_factor": "{0:.9f}".format(chain.gas_price_oracle_gwei_factor),
             },
             "ens_registry_address": chain.ens_registry_address,
+            "min_master_copy_version": chain.min_master_copy_version,
         }
 
         response = self.client.get(path=url, data=None, format="json")
@@ -206,6 +209,7 @@ def test_null_oracle_url_should_return_null_object(self):
             },
             "gas_price_oracle": None,
             "ens_registry_address": chain.ens_registry_address,
+            "min_master_copy_version": chain.min_master_copy_version,
         }
 
         response = self.client.get(path=url, data=None, format="json")