Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Generated by Django 5.0.13 on 2025-06-20 12:09
# Contract signatures will not be valid as v1.4.1 contracts change how they are validated
# Contract signatures must be deleted from database to prevent issues

from django.db import migrations

from safe_eth.safe.safe_signature import SafeSignatureType


def delete_safe_message_contract_confirmations(apps, schema_editor):
SafeMessageConfirmation = apps.get_model("safe_messages", "SafeMessageConfirmation")
SafeMessageConfirmation.objects.filter(
signature_type=SafeSignatureType.CONTRACT_SIGNATURE.value
).delete()


class Migration(migrations.Migration):

dependencies = [
("safe_messages", "0005_safemessage_origin"),
]

operations = [
migrations.RunPython(
delete_safe_message_contract_confirmations,
reverse_code=migrations.RunPython.noop,
),
]
36 changes: 25 additions & 11 deletions safe_transaction_service/safe_messages/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,17 @@
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from safe_eth.eth import get_auto_ethereum_client
from safe_eth.eth.eip712 import eip712_encode_hash
from safe_eth.eth.eip712 import eip712_encode
from safe_eth.safe.safe_signature import SafeSignature, SafeSignatureType
from safe_eth.util.util import to_0x_hex_str

from safe_transaction_service.utils.serializers import get_safe_owners

from .models import SIGNATURE_LENGTH, SafeMessage, SafeMessageConfirmation
from .utils import get_hash_for_message, get_safe_message_hash_for_message
from .utils import (
get_message_encoded,
get_safe_message_hash_and_preimage_for_message,
)


# Request serializers
Expand Down Expand Up @@ -93,7 +96,7 @@ def validate_message(self, value: Union[str, dict[str, Any]]):

if isinstance(value, dict):
try:
eip712_encode_hash(value)
eip712_encode(value)
return value
except ValueError as exc:
raise ValidationError(
Expand All @@ -105,12 +108,15 @@ def validate_message(self, value: Union[str, dict[str, Any]]):
def validate(self, attrs):
attrs = super().validate(attrs)
safe_address = self.context["safe_address"]
attrs["safe"] = safe_address
message = attrs["message"]
signature = attrs["signature"]
attrs["safe"] = safe_address
message_hash = get_hash_for_message(message)
safe_message_hash = get_safe_message_hash_for_message(
safe_address, message_hash
# Encode EIP-191 or EIP-712 original message as bytes
message_encoded = get_message_encoded(message)
safe_message_hash, safe_message_preimage = (
get_safe_message_hash_and_preimage_for_message(
safe_address, message_encoded
)
)
attrs["message_hash"] = safe_message_hash

Expand All @@ -119,8 +125,11 @@ def validate(self, attrs):
f"Message with hash {to_0x_hex_str(safe_message_hash)} for safe {safe_address} already exists in DB"
)

# Preimage is encoded for the Safe. But if an EIP-1271 signature is used, owner's Safe will be called
# the preimage will be encoded again for the owner Safe. That's what needs to be signed by the user
# So original data -> EIP-191 or EIP-712 encoded -> Safe encoded data -> Owner encoded data
safe_signatures = SafeSignature.parse_signature(
signature, safe_message_hash, safe_hash_preimage=message_hash
signature, safe_message_hash, safe_hash_preimage=safe_message_preimage
)
owner, signature_type = self.get_valid_owner_from_signatures(
safe_signatures, safe_address, None
Expand Down Expand Up @@ -158,11 +167,16 @@ def validate(self, attrs):
attrs["safe_message"] = safe_message
signature: HexStr = attrs["signature"]
safe_address = safe_message.safe
message_hash = get_hash_for_message(safe_message.message)
safe_message_hash = safe_message.message_hash
message_encoded = get_message_encoded(safe_message.message)
safe_message_hash, safe_message_preimage = (
get_safe_message_hash_and_preimage_for_message(
safe_address, message_encoded
)
)
assert to_0x_hex_str(safe_message_hash) == safe_message.message_hash

safe_signatures = SafeSignature.parse_signature(
signature, safe_message_hash, safe_hash_preimage=message_hash
signature, safe_message_hash, safe_hash_preimage=safe_message_preimage
)
owner, signature_type = self.get_valid_owner_from_signatures(
safe_signatures, safe_address, safe_message
Expand Down
8 changes: 4 additions & 4 deletions safe_transaction_service/safe_messages/tests/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from safe_eth.util.util import to_0x_hex_str

from ..models import SafeMessage, SafeMessageConfirmation
from ..utils import get_hash_for_message, get_safe_message_hash_for_message
from ..utils import get_message_encoded, get_safe_message_hash_and_preimage_for_message


class SafeMessageFactory(DjangoModelFactory):
Expand All @@ -21,9 +21,9 @@ class Meta:
@factory.lazy_attribute
def message_hash(self) -> str:
return to_0x_hex_str(
get_safe_message_hash_for_message(
self.safe, get_hash_for_message(self.message)
)
get_safe_message_hash_and_preimage_for_message(
self.safe, get_message_encoded(self.message)
)[0]
)


Expand Down
58 changes: 58 additions & 0 deletions safe_transaction_service/safe_messages/tests/test_migrations.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,66 @@
from unittest import mock
from unittest.mock import PropertyMock

from django.test import TestCase

from django_test_migrations.migrator import Migrator
from safe_eth.safe import Safe
from safe_eth.safe.safe_signature import SafeSignatureType

from safe_transaction_service.safe_messages.tests.factories import (
SafeMessageConfirmationFactory,
)


class TestMigrations(TestCase):
def setUp(self) -> None:
self.migrator = Migrator(database="default")

@mock.patch.object(
Safe, "domain_separator", new_callable=mock.PropertyMock, return_value=b"23"
)
def test_migration_forward_0006_remove_contract_signatures(
self, domain_separator_mock: PropertyMock
):
old_state = self.migrator.apply_initial_migration(
("safe_messages", "0005_safemessage_origin")
)
SafeMessageConfirmationFactory(
signature_type=SafeSignatureType.CONTRACT_SIGNATURE.value
)
SafeMessageConfirmationFactory(signature_type=SafeSignatureType.EOA.value)
SafeMessageConfirmationFactory(
signature_type=SafeSignatureType.APPROVED_HASH.value
)
SafeMessageConfirmationFactory(
signature_type=SafeSignatureType.CONTRACT_SIGNATURE.value
)

SafeMessageOld = old_state.apps.get_model("safe_messages", "SafeMessage")
SafeMessageConfirmationOld = old_state.apps.get_model(
"safe_messages", "SafeMessageConfirmation"
)
self.assertEqual(SafeMessageOld.objects.count(), 4)
self.assertEqual(SafeMessageConfirmationOld.objects.count(), 4)
self.assertEqual(
SafeMessageConfirmationOld.objects.filter(
signature_type=SafeSignatureType.CONTRACT_SIGNATURE.value
).count(),
2,
)

new_state = self.migrator.apply_tested_migration(
("safe_messages", "0006_remove_contract_signatures"),
)
SafeMessageNew = old_state.apps.get_model("safe_messages", "SafeMessage")
SafeMessageConfirmationNew = new_state.apps.get_model(
"safe_messages", "SafeMessageConfirmation"
)
self.assertEqual(SafeMessageNew.objects.count(), 4)
self.assertEqual(SafeMessageConfirmationNew.objects.count(), 2)
self.assertEqual(
SafeMessageConfirmationNew.objects.filter(
signature_type=SafeSignatureType.CONTRACT_SIGNATURE.value
).count(),
0,
)
14 changes: 7 additions & 7 deletions safe_transaction_service/safe_messages/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from safe_eth.safe.tests.safe_test_case import SafeTestCaseMixin
from safe_eth.util.util import to_0x_hex_str

from ..utils import get_hash_for_message, get_safe_message_hash_for_message
from ..utils import get_message_encoded, get_safe_message_hash_and_preimage_for_message
from .factories import SafeMessageConfirmationFactory, SafeMessageFactory
from .mocks import get_eip712_payload_mock

Expand All @@ -22,15 +22,15 @@ def test_str(self):
for input, expected in [
(
"TestMessage",
"Safe Message 0xb04a24aa07a51d1d8c3913e9493b3b1f88ed6a8a75430a9a8eda3ed3ce1897bc - TestMessage",
"Safe Message 0x95a89792bad17115e101b4d8fbdcd517dd13d085e815287d7fe791078182bf9e - TestMessage",
),
(
"TestMessageVeryLong",
"Safe Message 0xe3db816540ce371e2703b8ec59bdd6fec32e0c6078f2e204a205fd6d81564f28 - TestMessageVery...",
"Safe Message 0xb7783fde9060e75b61298c14cbc2a987f0e0800d1bb08b4a2b24ce3544b9b144 - TestMessageVery...",
),
(
get_eip712_payload_mock(),
"Safe Message 0xbabb22f5c02a24db447b8f0136d6e26bb58cd6d068ebe8ab25c2221cfdf53e18 - {'types': {'EIP...",
"Safe Message 0x632db20317ce8e467d17e941d209fc1173ff1cad83b0c072c008385a566ddd80 - {'types': {'EIP...",
),
]:
with self.subTest(input=input):
Expand Down Expand Up @@ -61,9 +61,9 @@ def test_factory(self):
self.assertEqual(
message_hash,
to_0x_hex_str(
get_safe_message_hash_for_message(
safe_message.safe, get_hash_for_message(message)
)
get_safe_message_hash_and_preimage_for_message(
safe_message.safe, get_message_encoded(message)
)[0]
),
)
recovered_owner = Account._recover_hash(
Expand Down
35 changes: 22 additions & 13 deletions safe_transaction_service/safe_messages/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,11 @@

import eth_abi
from eth_account import Account
from eth_account.messages import defunct_hash_message
from hexbytes import HexBytes
from rest_framework import status
from rest_framework.exceptions import ErrorDetail
from rest_framework.test import APITestCase
from safe_eth.eth.eip712 import eip712_encode_hash
from safe_eth.eth.eip712 import eip712_encode
from safe_eth.safe.safe_signature import SafeSignatureEOA
from safe_eth.safe.signatures import signature_to_bytes
from safe_eth.safe.tests.safe_test_case import SafeTestCaseMixin
Expand All @@ -28,6 +27,7 @@
)
from safe_transaction_service.utils.utils import datetime_to_str

from ..utils import encode_eip191_message, encode_eip712_message
from .mocks import get_eip712_payload_mock

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -144,12 +144,13 @@ def test_safe_messages_create_view(self, get_owners_mock: MagicMock):
safe_address = safe.address
messages = ["Text to sign message", get_eip712_payload_mock()]
description = "Testing EIP191 message signing"
message_hashes = [
defunct_hash_message(text=messages[0]),
eip712_encode_hash(messages[1]),
messages_encoded = [
encode_eip191_message(messages[0]),
encode_eip712_message(messages[1]),
]
safe_message_hashes = [
safe.get_message_hash(message_hash) for message_hash in message_hashes
safe.get_message_hash(message_encoded)
for message_encoded in messages_encoded
]
signatures = [
to_0x_hex_str(account.unsafe_sign_hash(safe_message_hash)["signature"])
Expand All @@ -158,15 +159,14 @@ def test_safe_messages_create_view(self, get_owners_mock: MagicMock):

sub_tests = ["create_eip191", "create_eip712"]

for sub_test, message, message_hash, safe_message_hash, signature in zip(
sub_tests, messages, message_hashes, safe_message_hashes, signatures
for sub_test, message, safe_message_hash, signature in zip(
sub_tests, messages, safe_message_hashes, signatures
):
SafeMessage.objects.all().delete()
get_owners_mock.return_value = []
with self.subTest(
sub_test,
message=message,
message_hash=message_hash,
safe_message_hash=safe_message_hash,
signature=signature,
):
Expand Down Expand Up @@ -289,8 +289,13 @@ def _test_safe_messages_create_using_1271_signature_view(self, safe_deployment_f
safe_address = safe.address
message = get_eip712_payload_mock()
description = "Testing EIP712 message signing"
message_hash = eip712_encode_hash(message)
safe_owner_message_hash = safe_owner.get_message_hash(message_hash)
message_encoded = b"".join(eip712_encode(message))
safe_message_hash, safe_message_preimage = safe.get_message_hash_and_preimage(
message_encoded
)
safe_owner_message_hash, _ = safe_owner.get_message_hash_and_preimage(
safe_message_preimage
)
safe_owner_signature = account.unsafe_sign_hash(safe_owner_message_hash)[
"signature"
]
Expand All @@ -314,8 +319,12 @@ def _test_safe_messages_create_using_1271_signature_view(self, safe_deployment_f
data=data,
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(SafeMessage.objects.count(), 1)
self.assertEqual(SafeMessageConfirmation.objects.count(), 1)
self.assertEqual(
SafeMessage.objects.get().message_hash, to_0x_hex_str(safe_message_hash)
)
self.assertEqual(
SafeMessageConfirmation.objects.get().owner, safe_owner.address
)

def test_safe_messages_create_using_1271_signature_v1_3_0_view(self):
return self._test_safe_messages_create_using_1271_signature_view(
Expand Down
32 changes: 23 additions & 9 deletions safe_transaction_service/safe_messages/utils.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,36 @@
from typing import Any

from eth_account.messages import defunct_hash_message
from eth_account.messages import encode_defunct
from eth_typing import ChecksumAddress, Hash32
from safe_eth.eth import get_auto_ethereum_client
from safe_eth.eth.eip712 import eip712_encode_hash
from safe_eth.eth.eip712 import eip712_encode
from safe_eth.safe import Safe


def get_hash_for_message(message: str | dict[str, Any]) -> Hash32:
def encode_eip191_message(message: str) -> bytes:
signable_message = encode_defunct(text=message)
return (
defunct_hash_message(text=message)
b"\x19"
+ signable_message.version
+ signable_message.header
+ signable_message.body
)


def encode_eip712_message(message: dict[str, Any]) -> bytes:
return b"".join(eip712_encode(message))


def get_message_encoded(message: str | dict[str, Any]) -> bytes:
return (
encode_eip191_message(message)
if isinstance(message, str)
else eip712_encode_hash(message)
else encode_eip712_message(message)
)


def get_safe_message_hash_for_message(
safe_address: ChecksumAddress, message_hash: Hash32
) -> Hash32:
def get_safe_message_hash_and_preimage_for_message(
safe_address: ChecksumAddress, message: bytes
) -> tuple[Hash32, bytes]:
safe = Safe(safe_address, get_auto_ethereum_client())
return safe.get_message_hash(message_hash)
return safe.get_message_hash_and_preimage(message)
4 changes: 4 additions & 0 deletions safe_transaction_service/safe_messages/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ def get_serializer_context(self):
def post(self, request, *args, **kwargs):
"""
Adds the signature of a message given its message hash

Note: Safe must be v1.4.1 for EIP-1271 signatures to work.
"""
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
Expand Down Expand Up @@ -122,6 +124,8 @@ def post(self, request, address, *args, **kwargs):

Hash will be calculated from the provided ``message``. Sending a raw ``hash`` will not be accepted,
service needs to derive it itself.

Note: Safe must be v1.4.1 for EIP-1271 signatures to work.
"""
if not fast_is_checksum_address(address):
return Response(
Expand Down