diff --git a/gnosis/eth/django/models.py b/gnosis/eth/django/models.py index c9687ba92..ea0595803 100644 --- a/gnosis/eth/django/models.py +++ b/gnosis/eth/django/models.py @@ -6,13 +6,13 @@ from django.db import models from django.utils.translation import gettext_lazy as _ -from eth_typing import ChecksumAddress +from eth_typing import ChecksumAddress, HexAddress, HexStr from eth_utils import to_normalized_address from hexbytes import HexBytes from ..utils import fast_bytes_to_checksum_address, fast_to_checksum_address from .forms import EthereumAddressFieldForm, HexFieldForm, Keccak256FieldForm -from .validators import validate_checksumed_address +from .validators import validate_address, validate_checksumed_address try: from django.db import DefaultConnectionProxy @@ -24,54 +24,11 @@ connection = connections["default"] -class EthereumAddressCharField(models.CharField): - """ - Stores Ethereum Addresses as Strings. Takes more space in database than `EthereumAddressBinaryField`, - but does not require the keccak256 calculation to calculate the EIP55 checksummed address. - """ - - default_validators = [validate_checksumed_address] - description = "Stores Ethereum Addresses (EIP55) as strings" - default_error_messages = { - "invalid": _('"%(value)s" value must be an EIP55 checksummed address.'), - } - - def __init__(self, *args, **kwargs): - kwargs["max_length"] = 42 - super().__init__(*args, **kwargs) - - def deconstruct(self): - name, path, args, kwargs = super().deconstruct() - del kwargs["max_length"] - return name, path, args, kwargs - - def from_db_value(self, value, expression, connection): - return self.to_python(value) - - def to_python(self, value): - value = super().to_python(value) - if value: - try: - return fast_to_checksum_address(value) - except ValueError: - raise exceptions.ValidationError( - self.error_messages["invalid"], - code="invalid", - params={"value": value}, - ) - else: - return value - - def get_prep_value(self, value): - value = super().get_prep_value(value) - return self.to_python(value) - - class EthereumAddressBinaryField(models.Field): """ - Stores Ethereum Addresses in binary. Takes less space in Database than `EthereumAddressCharField`, - but does require a keccak256 calculation to calculate the EIP55 checksummed address, that it can take - a high impact on the CPU for a lot of addresses. + Stores Ethereum Addresses in binary. Requires keccak256 hashing to + calculate the EIP55 checksummed address, and it can take a high impact + on the CPU for a lot of addresses. """ default_validators = [validate_checksumed_address] @@ -120,6 +77,41 @@ def formfield(self, **kwargs): return super().formfield(**defaults) +class EthereumAddressFastBinaryField(EthereumAddressBinaryField): + """ + Stores Ethereum Addresses in binary. It returns not EIP55 regular addresses, + which is faster as not EIP55 checksum is involved. + """ + + default_validators = [validate_address] + description = "Stores Ethereum Addresses in binary" + default_error_messages = { + "invalid": _('"%(value)s" value must be a valid address.'), + } + + def from_db_value( + self, value: memoryview, expression, connection + ) -> Optional[HexAddress]: + if value: + return HexAddress(HexStr("0x" + bytes(value).hex())) + + def to_python(self, value) -> Optional[ChecksumAddress]: + if value is not None: + try: + if isinstance(value, bytes): + if len(value) != 20: + raise ValueError( + "Cannot convert %s to a checksum address, 20 bytes were expected" + ) + return HexAddress(HexStr(to_normalized_address(value)[2:])) + except ValueError: + raise exceptions.ValidationError( + self.error_messages["invalid"], + code="invalid", + params={"value": value}, + ) + + class UnsignedDecimal(models.DecimalField): def deconstruct(self): name, path, args, kwargs = super().deconstruct() diff --git a/gnosis/eth/django/tests/models.py b/gnosis/eth/django/tests/models.py index 39834c0d4..b7bde87a7 100644 --- a/gnosis/eth/django/tests/models.py +++ b/gnosis/eth/django/tests/models.py @@ -2,7 +2,7 @@ from ..models import ( EthereumAddressBinaryField, - EthereumAddressCharField, + EthereumAddressFastBinaryField, Keccak256Field, Uint32Field, Uint96Field, @@ -10,12 +10,12 @@ ) -class EthereumAddress(models.Model): - value = EthereumAddressCharField(null=True) +class EthereumAddressBinary(models.Model): + value = EthereumAddressBinaryField(null=True) -class EthereumAddressV2(models.Model): - value = EthereumAddressBinaryField(null=True) +class EthereumAddressFastBinary(models.Model): + value = EthereumAddressFastBinaryField(null=True) class Uint256(models.Model): diff --git a/gnosis/eth/django/tests/test_models.py b/gnosis/eth/django/tests/test_models.py index 7c94691c9..9ae509a96 100644 --- a/gnosis/eth/django/tests/test_models.py +++ b/gnosis/eth/django/tests/test_models.py @@ -9,8 +9,8 @@ from ...constants import NULL_ADDRESS, SENTINEL_ADDRESS from ...utils import fast_is_checksum_address, fast_keccak_text from .models import ( - EthereumAddress, - EthereumAddressV2, + EthereumAddressBinary, + EthereumAddressFastBinary, Keccak256Hash, Uint32, Uint96, @@ -22,38 +22,68 @@ class TestModels(TestCase): def test_ethereum_address_field(self): - for EthereumAddressModel in (EthereumAddress, EthereumAddressV2): - with self.subTest(EthereumAddressModel=EthereumAddressModel): - address = Account.create().address - self.assertTrue(fast_is_checksum_address(address)) - ethereum_address = EthereumAddressModel.objects.create(value=address) - ethereum_address.refresh_from_db() - self.assertTrue(fast_is_checksum_address(ethereum_address.value)) - self.assertEqual(address, ethereum_address.value) - - # Test addresses - for addresss in ( - None, - NULL_ADDRESS, - SENTINEL_ADDRESS, - Account.create().address, - ): - with self.subTest(special_address=addresss): - EthereumAddressModel.objects.create(value=addresss) - self.assertEqual( - EthereumAddressModel.objects.get(value=addresss).value, - addresss, - ) - - with self.assertRaisesMessage( - ValidationError, - '"0x23" value must be an EIP55 checksummed address.', - ): - with transaction.atomic(): - EthereumAddressModel.objects.create(value="0x23") - - ethereum_address = EthereumAddressModel(value=Account.create().address) - self.assertIsNone(ethereum_address.full_clean()) + address = Account.create().address + self.assertTrue(fast_is_checksum_address(address)) + ethereum_address = EthereumAddressBinary.objects.create(value=address) + ethereum_address.refresh_from_db() + self.assertTrue(fast_is_checksum_address(ethereum_address.value)) + self.assertEqual(address, ethereum_address.value) + + # Test addresses + for addresss in ( + None, + NULL_ADDRESS, + SENTINEL_ADDRESS, + Account.create().address, + ): + with self.subTest(special_address=addresss): + EthereumAddressBinary.objects.create(value=addresss) + self.assertEqual( + EthereumAddressBinary.objects.get(value=addresss).value, + addresss, + ) + + with self.assertRaisesMessage( + ValidationError, + '"0x23" value must be an EIP55 checksummed address.', + ): + with transaction.atomic(): + EthereumAddressBinary.objects.create(value="0x23") + + ethereum_address = EthereumAddressBinary(value=Account.create().address) + self.assertIsNone(ethereum_address.full_clean()) + + def test_ethereum_address_fast_field(self): + address = Account.create().address + self.assertTrue(fast_is_checksum_address(address)) + ethereum_address = EthereumAddressFastBinary.objects.create(value=address) + ethereum_address.refresh_from_db() + self.assertFalse(fast_is_checksum_address(ethereum_address.value)) + self.assertEqual(address.lower(), ethereum_address.value) + + # Test addresses + for addresss in ( + None, + NULL_ADDRESS, + SENTINEL_ADDRESS, + Account.create().address.lower(), + ): + with self.subTest(special_address=addresss): + EthereumAddressFastBinary.objects.create(value=addresss) + self.assertEqual( + EthereumAddressFastBinary.objects.get(value=addresss).value, + addresss, + ) + + with self.assertRaisesMessage( + ValidationError, + '"0x23" value must be a valid address.', + ): + with transaction.atomic(): + EthereumAddressFastBinary.objects.create(value="0x23") + + ethereum_address = EthereumAddressFastBinary(value=Account.create().address) + self.assertIsNone(ethereum_address.full_clean()) def test_uint256_field(self): for value in [ @@ -177,8 +207,8 @@ def test_serialize_keccak256_field_to_json(self): def test_serialize_ethereum_address_v2_field_to_json(self): address: str = "0x5aFE3855358E112B5647B952709E6165e1c1eEEe" - EthereumAddressV2.objects.create(value=address) - serialized = serialize("json", EthereumAddressV2.objects.all()) + EthereumAddressBinary.objects.create(value=address) + serialized = serialize("json", EthereumAddressBinary.objects.all()) # address should be in serialized data self.assertIn(address, serialized) diff --git a/gnosis/eth/django/validators.py b/gnosis/eth/django/validators.py index 467d9c4e4..c8b34efc5 100644 --- a/gnosis/eth/django/validators.py +++ b/gnosis/eth/django/validators.py @@ -1,9 +1,23 @@ from django.core.exceptions import ValidationError +from hexbytes import HexBytes + from ..utils import fast_is_checksum_address -def validate_checksumed_address(address): +def validate_address(address: str): + try: + address_bytes = HexBytes(address) + if len(address_bytes) != 20: + raise ValueError + except ValueError: + raise ValidationError( + "%(address)s is not a valid EthereumAddress", + params={"address": address}, + ) + + +def validate_checksumed_address(address: str): if not fast_is_checksum_address(address): raise ValidationError( "%(address)s has an invalid checksum",