Repository: safe-global/safe-config-service

Issue:
Add ENS Registry to each chain
In case the ENS registry is present on the Chain, then its address should be returned:

```javascript
GET /api/v1/chains/

{
  ...
  "ensRegistryAddress": "<checksummed-address>" // optional
}
```

@gnosis/safe-android @gnosis/safe-ios @gnosis/safe-web


Discussion:
@gnosis/safe-android @gnosis/safe-ios @gnosis/safe-web 

Similarly to #125 can you comment here what is required by each of the frameworks used on the client side?
According to @gnosis/safe-android and @gnosis/safe-ios we are using a custom solution on the mobile clients which requires the `ensRegistryAddress` (right now hardcoded).

@gnosis/safe-web is `ensRegistryAddress` sufficient for the feature to work on your side?
In safe-react, we're using `web3.eth.ens.getAddress`.

Currently, we're not overriding the resolver address but it's possible using [`web3.eth.ens.registryAddress`](https://github.com/ChainSafe/web3.js/blob/1.x/docs/web3-eth-ens.rst#registryaddress).

So the proposed solution will work.

Resolving patch:
diff --git a/src/chains/migrations/0008_chain_ens_registry_address.py b/src/chains/migrations/0008_chain_ens_registry_address.py
new file mode 100644
--- /dev/null
+++ b/src/chains/migrations/0008_chain_ens_registry_address.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.2.5 on 2021-07-08 11:48
+
+import gnosis.eth.django.models
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("chains", "0007_chain_currency_logo_url"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="chain",
+            name="ens_registry_address",
+            field=gnosis.eth.django.models.EthereumAddressField(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
@@ -3,6 +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
 
 HEX_ARGB_REGEX = re.compile("^#[0-9a-fA-F]{6}$")
 
@@ -36,6 +37,7 @@ class Chain(models.Model):
     )
     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)
 
     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
@@ -1,4 +1,5 @@
 from drf_yasg.utils import swagger_serializer_method
+from gnosis.eth.django.serializers import EthereumAddressField
 from rest_framework import serializers
 
 from .models import Chain
@@ -30,6 +31,7 @@ class ChainSerializer(serializers.ModelSerializer):
     )
     theme = serializers.SerializerMethodField()
     gas_price_oracle = serializers.SerializerMethodField()
+    ens_registry_address = EthereumAddressField()
 
     class Meta:
         model = Chain
@@ -42,6 +44,7 @@ class Meta:
             "transaction_service",
             "theme",
             "gas_price_oracle",
+            "ens_registry_address",
         ]
 
     @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
@@ -1,4 +1,5 @@
 import factory
+import web3
 from factory.django import DjangoModelFactory
 
 from ..models import Chain
@@ -24,3 +25,6 @@ class Meta:
     gas_price_oracle_parameter = factory.Faker(
         "random_element", elements=("safeLow", "average", "fast")
     )
+    ens_registry_address = factory.LazyAttribute(
+        lambda o: web3.Account.create().address
+    )
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
@@ -1,3 +1,4 @@
+import web3
 from django.core.exceptions import ValidationError
 from django.db import DataError
 from django.test import TestCase, TransactionTestCase
@@ -89,3 +90,38 @@ def test_valid_text_colors(self):
             with self.subTest(msg=f"Valid color {valid_color} should not throw"):
                 chain = ChainFactory.create(theme_text_color=valid_color)
                 chain.full_clean()
+
+
+class ChainEnsRegistryAddressValidationTestCase(TransactionTestCase):
+    def test_invalid_addresses(self):
+        param_list = [
+            "0x",
+            "0x0",
+            "0xgz",
+            "0x0",
+            "0x000000000000000000000000000000000000000",
+            "0x00000000000000000000000000000000000000000",
+        ]
+
+        for invalid_address in param_list:
+            with self.subTest(msg=f"Invalid address {invalid_address} should throw"):
+                with self.assertRaises(
+                    (
+                        # normalize_address from gnosis-py throws a generic Exception if the address is not valid
+                        Exception,
+                    )
+                ):
+                    chain = ChainFactory.create(ens_registry_address=invalid_address)
+                    # run validators
+                    chain.full_clean()
+
+    def test_valid_addresses(self):
+        param_list = [
+            "0x0000000000000000000000000000000000000000",
+            "0xFFfFfFffFFfffFFfFFfFFFFFffFFFffffFfFFFfF",
+        ] + [web3.Account.create().address for _ in range(20)]
+
+        for valid_address in param_list:
+            with self.subTest(msg=f"Valid address {valid_address} should not throw"):
+                chain = ChainFactory.create(ens_registry_address=valid_address)
+                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
@@ -43,6 +43,7 @@ def test_json_payload_format(self):
                         "url": chain.gas_price_oracle_url,
                         "gasParameter": chain.gas_price_oracle_parameter,
                     },
+                    "ensRegistryAddress": chain.ens_registry_address,
                 }
             ],
         }
@@ -132,6 +133,7 @@ def test_json_payload_format(self):
                 "url": chain.gas_price_oracle_url,
                 "gasParameter": chain.gas_price_oracle_parameter,
             },
+            "ensRegistryAddress": chain.ens_registry_address,
         }
 
         response = self.client.get(path=url, data=None, format="json")
@@ -170,6 +172,7 @@ def test_match(self):
                 "url": chain.gas_price_oracle_url,
                 "gas_parameter": chain.gas_price_oracle_parameter,
             },
+            "ens_registry_address": chain.ens_registry_address,
         }
 
         response = self.client.get(path=url, data=None, format="json")
@@ -197,6 +200,7 @@ def test_null_oracle_url_should_return_null_object(self):
                 "background_color": chain.theme_background_color,
             },
             "gas_price_oracle": None,
+            "ens_registry_address": chain.ens_registry_address,
         }
 
         response = self.client.get(path=url, data=None, format="json")
@@ -207,26 +211,35 @@ def test_null_oracle_url_should_return_null_object(self):
 
 class ChainsListViewRelevanceTests(APITestCase):
     def test_relevance_sorting(self):
-        safe_app_1 = ChainFactory.create(name="aaa", relevance=10)
-        safe_app_2 = ChainFactory.create(name="bbb", relevance=1)
+        chain_1 = ChainFactory.create(name="aaa", relevance=10)
+        chain_2 = ChainFactory.create(name="bbb", relevance=1)
         url = reverse("v1:chains:list")
 
         response = self.client.get(path=url, data=None, format="json")
 
         chain_ids = [result["chain_id"] for result in response.data["results"]]
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(chain_ids, [str(safe_app_2.id), str(safe_app_1.id)])
+        self.assertEqual(chain_ids, [str(chain_2.id), str(chain_1.id)])
 
     def test_same_relevance_sorting(self):
-        safe_app_1 = ChainFactory.create(name="ccc", relevance=10)
-        safe_app_2 = ChainFactory.create(name="bbb", relevance=10)
-        safe_app_3 = ChainFactory.create(name="aaa", relevance=10)
+        chain_1 = ChainFactory.create(name="ccc", relevance=10)
+        chain_2 = ChainFactory.create(name="bbb", relevance=10)
+        chain_3 = ChainFactory.create(name="aaa", relevance=10)
         url = reverse("v1:chains:list")
 
         response = self.client.get(path=url, data=None, format="json")
 
         chain_ids = [result["chain_id"] for result in response.data["results"]]
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(
-            chain_ids, [str(safe_app_3.id), str(safe_app_2.id), str(safe_app_1.id)]
-        )
+        self.assertEqual(chain_ids, [str(chain_3.id), str(chain_2.id), str(chain_1.id)])
+
+
+class ChainsEnsRegistryTests(APITestCase):
+    def test_null_ens_registry_address(self):
+        ChainFactory.create(id=1, ens_registry_address=None)
+        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["ens_registry_address"], None)