From 2293e4c8cdce275b053b88e8b95e737ddaa49a09 Mon Sep 17 00:00:00 2001 From: Uxio Fuentefria <6909403+Uxio0@users.noreply.github.com> Date: Wed, 6 Mar 2024 17:54:33 +0100 Subject: [PATCH] Add endpoints to work with SafeOperations --- config/urls.py | 7 + .../account_abstraction/constants.py | 4 + .../migrations/0001_initial.py | 126 +++- .../account_abstraction/models.py | 59 +- .../account_abstraction/pagination.py | 6 + .../account_abstraction/serializers.py | 189 +++++- .../account_abstraction/tests/factories.py | 56 +- .../account_abstraction/tests/test_views.py | 621 ++++++++++++++++++ .../account_abstraction/urls.py | 17 +- .../account_abstraction/views.py | 87 +++ .../safe_messages/serializers.py | 1 - .../safe_messages/tests/factories.py | 4 +- 12 files changed, 1124 insertions(+), 53 deletions(-) create mode 100644 safe_transaction_service/account_abstraction/pagination.py create mode 100644 safe_transaction_service/account_abstraction/tests/test_views.py diff --git a/config/urls.py b/config/urls.py index f11ae779f..9d80b3a1d 100644 --- a/config/urls.py +++ b/config/urls.py @@ -43,6 +43,13 @@ urlpatterns_v1 = [ path("", include("safe_transaction_service.history.urls", namespace="history")), + path( + "", + include( + "safe_transaction_service.account_abstraction.urls", + namespace="account_abstraction", + ), + ), path( "contracts/", include("safe_transaction_service.contracts.urls", namespace="contracts"), diff --git a/safe_transaction_service/account_abstraction/constants.py b/safe_transaction_service/account_abstraction/constants.py index 623661bc0..0ee1bf52b 100644 --- a/safe_transaction_service/account_abstraction/constants.py +++ b/safe_transaction_service/account_abstraction/constants.py @@ -28,3 +28,7 @@ USER_OPERATION_SUPPORTED_ENTRY_POINTS = { ChecksumAddress(HexStr(HexAddress("0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789"))) } + +SAFE_OPERATION_MODULE_ADDRESSES = { + ChecksumAddress(HexStr(HexAddress("0xa581c4A4DB7175302464fF3C06380BC3270b4037"))) +} diff --git a/safe_transaction_service/account_abstraction/migrations/0001_initial.py b/safe_transaction_service/account_abstraction/migrations/0001_initial.py index 138696d9a..23e208e77 100644 --- a/safe_transaction_service/account_abstraction/migrations/0001_initial.py +++ b/safe_transaction_service/account_abstraction/migrations/0001_initial.py @@ -1,8 +1,11 @@ -# Generated by Django 4.2.10 on 2024-02-29 17:39 +# Generated by Django 5.0.3 on 2024-03-06 16:18 import django.db.models.deletion +import django.utils.timezone from django.db import migrations, models +import model_utils.fields + import gnosis.eth.django.models @@ -10,15 +13,111 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ("history", "0078_remove_safestatus_history_saf_address_1c362b_idx_and_more"), + ("history", "0079_alter_erc20transfer_unique_together_and_more"), ] operations = [ + migrations.CreateModel( + name="SafeOperation", + fields=[ + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, + editable=False, + verbose_name="created", + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, + editable=False, + verbose_name="modified", + ), + ), + ( + "hash", + gnosis.eth.django.models.Keccak256Field( + primary_key=True, serialize=False + ), + ), + ("valid_after", models.DateTimeField()), + ("valid_until", models.DateTimeField()), + ( + "module_address", + gnosis.eth.django.models.EthereumAddressV2Field(db_index=True), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="SafeOperationConfirmation", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, + editable=False, + verbose_name="created", + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, + editable=False, + verbose_name="modified", + ), + ), + ("owner", gnosis.eth.django.models.EthereumAddressV2Field()), + ( + "signature", + gnosis.eth.django.models.HexField( + default=None, max_length=5000, null=True + ), + ), + ( + "signature_type", + models.PositiveSmallIntegerField( + choices=[ + (0, "CONTRACT_SIGNATURE"), + (1, "APPROVED_HASH"), + (2, "EOA"), + (3, "ETH_SIGN"), + ], + db_index=True, + ), + ), + ( + "safe_operation", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="confirmations", + to="account_abstraction.safeoperation", + ), + ), + ], + options={ + "ordering": ["created"], + }, + ), migrations.CreateModel( name="UserOperation", fields=[ ( - "user_operation_hash", + "hash", gnosis.eth.django.models.Keccak256Field( primary_key=True, serialize=False ), @@ -45,7 +144,7 @@ class Migration(migrations.Migration): "paymaster_data", models.BinaryField(blank=True, editable=True, null=True), ), - ("signature", models.BinaryField()), + ("signature", models.BinaryField(blank=True, editable=True, null=True)), ( "entry_point", gnosis.eth.django.models.EthereumAddressV2Field(db_index=True), @@ -53,12 +152,23 @@ class Migration(migrations.Migration): ( "ethereum_tx", models.ForeignKey( + blank=True, + null=True, on_delete=django.db.models.deletion.CASCADE, to="history.ethereumtx", ), ), ], ), + migrations.AddField( + model_name="safeoperation", + name="user_operation", + field=models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="safe_operation", + to="account_abstraction.useroperation", + ), + ), migrations.CreateModel( name="UserOperationReceipt", fields=[ @@ -80,11 +190,19 @@ class Migration(migrations.Migration): "user_operation", models.OneToOneField( on_delete=django.db.models.deletion.CASCADE, + related_name="receipt", to="account_abstraction.useroperation", ), ), ], ), + migrations.AddConstraint( + model_name="safeoperationconfirmation", + constraint=models.UniqueConstraint( + fields=("safe_operation", "owner"), + name="unique_safe_operation_owner_confirmation", + ), + ), migrations.AddIndex( model_name="useroperation", index=models.Index( diff --git a/safe_transaction_service/account_abstraction/models.py b/safe_transaction_service/account_abstraction/models.py index c51d3be4d..ca9616dae 100644 --- a/safe_transaction_service/account_abstraction/models.py +++ b/safe_transaction_service/account_abstraction/models.py @@ -6,21 +6,26 @@ from django.db.models import Index from hexbytes import HexBytes +from model_utils.models import TimeStampedModel from gnosis.eth.account_abstraction import UserOperation as UserOperationClass from gnosis.eth.account_abstraction import UserOperationMetadata from gnosis.eth.django.models import ( EthereumAddressV2Field, + HexField, Keccak256Field, Uint256Field, ) from gnosis.safe.account_abstraction import SafeOperation from gnosis.safe.account_abstraction import SafeOperation as SafeOperationClass +from gnosis.safe.safe_signature import SafeSignatureType from safe_transaction_service.history import models as history_models logger = logging.getLogger(__name__) +SIGNATURE_LENGTH = 5_000 + class UserOperation(models.Model): """ @@ -49,13 +54,6 @@ class UserOperation(models.Model): signature = models.BinaryField(null=True, blank=True, editable=True) entry_point = EthereumAddressV2Field(db_index=True) - # Receipt - actual_gas_cost = Uint256Field() - actual_gas_used = Uint256Field() - success = models.BooleanField() - reason = models.CharField(max_length=256) - deposited = Uint256Field() - class Meta: unique_together = (("sender", "nonce"),) indexes = [ @@ -117,7 +115,9 @@ def to_safe_operation(self) -> SafeOperation: class UserOperationReceipt(models.Model): - user_operation = models.OneToOneField(UserOperation, on_delete=models.CASCADE) + user_operation = models.OneToOneField( + UserOperation, on_delete=models.CASCADE, related_name="receipt" + ) actual_gas_cost = Uint256Field() actual_gas_used = Uint256Field() success = models.BooleanField() @@ -125,10 +125,47 @@ class UserOperationReceipt(models.Model): deposited = Uint256Field() -class SafeOperation(models.Model): +class SafeOperation(TimeStampedModel): hash = Keccak256Field(primary_key=True) # safeOperationHash - user_operation = models.ForeignKey( - UserOperation, on_delete=models.CASCADE, null=True, blank=True + user_operation = models.OneToOneField( + UserOperation, on_delete=models.CASCADE, related_name="safe_operation" ) valid_after = models.DateTimeField() # Epoch uint48 valid_until = models.DateTimeField() # Epoch uint48 + module_address = EthereumAddressV2Field(db_index=True) + + def build_signature(self) -> bytes: + return b"".join( + [ + HexBytes(signature) + for _, signature in sorted( + self.confirmations.values_list("owner", "signature"), + key=lambda tup: tup[0].lower(), + ) + ] + ) + + +class SafeOperationConfirmation(TimeStampedModel): + safe_operation = models.ForeignKey( + SafeOperation, + on_delete=models.CASCADE, + related_name="confirmations", + ) + owner = EthereumAddressV2Field() + signature = HexField(null=True, default=None, max_length=SIGNATURE_LENGTH) + signature_type = models.PositiveSmallIntegerField( + choices=[(tag.value, tag.name) for tag in SafeSignatureType], db_index=True + ) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["safe_operation", "owner"], + name="unique_safe_operation_owner_confirmation", + ) + ] + ordering = ["created"] + + def __str__(self): + return f"Safe Operation Confirmation for owner={self.owner}" diff --git a/safe_transaction_service/account_abstraction/pagination.py b/safe_transaction_service/account_abstraction/pagination.py new file mode 100644 index 000000000..e5d5557ab --- /dev/null +++ b/safe_transaction_service/account_abstraction/pagination.py @@ -0,0 +1,6 @@ +from rest_framework.pagination import LimitOffsetPagination + + +class DefaultPagination(LimitOffsetPagination): + max_limit = 200 + default_limit = 100 diff --git a/safe_transaction_service/account_abstraction/serializers.py b/safe_transaction_service/account_abstraction/serializers.py index 7c3f6db6f..057e77267 100644 --- a/safe_transaction_service/account_abstraction/serializers.py +++ b/safe_transaction_service/account_abstraction/serializers.py @@ -1,20 +1,27 @@ -from typing import List +from typing import Any, Dict, List, Optional -from eth_typing import ChecksumAddress +from django.db import transaction + +from eth_typing import ChecksumAddress, HexStr from hexbytes import HexBytes from rest_framework import serializers from rest_framework.exceptions import ValidationError import gnosis.eth.django.serializers as eth_serializers from gnosis.eth import EthereumClientProvider +from gnosis.eth.account_abstraction import UserOperation as UserOperationClass from gnosis.eth.utils import fast_keccak from gnosis.safe.account_abstraction import SafeOperation as SafeOperationClass -from gnosis.safe.safe_signature import SafeSignature +from gnosis.safe.safe_signature import SafeSignature, SafeSignatureType from safe_transaction_service.utils.ethereum import get_chain_id from ..utils.serializers import get_safe_owners +from .constants import SAFE_OPERATION_MODULE_ADDRESSES from .models import SafeOperation +from .models import SafeOperation as SafeOperationModel +from .models import SafeOperationConfirmation +from .models import UserOperation as UserOperationModel SIGNATURE_LENGTH = 5_000 @@ -23,7 +30,6 @@ # Request Serializers # ================================================ # class SafeOperationSerializer(serializers.Serializer): - safe = eth_serializers.EthereumAddressField() nonce = serializers.IntegerField(min_value=0) init_code = eth_serializers.HexadecimalField(allow_null=True) call_data = eth_serializers.HexadecimalField(allow_null=True) @@ -38,11 +44,10 @@ class SafeOperationSerializer(serializers.Serializer): min_length=65, max_length=SIGNATURE_LENGTH ) entry_point = eth_serializers.EthereumAddressField() - + # Safe Operation fields valid_after = serializers.IntegerField(min_value=0) # Epoch uint48 valid_until = serializers.IntegerField(min_value=0) # Epoch uint48 - - safe_module_address = eth_serializers.EthereumAddressField() + module_address = eth_serializers.EthereumAddressField() def _validate_signature( self, @@ -73,12 +78,17 @@ def _validate_signature( signature_owners.append(owner) safe_signatures.append(safe_signature) - return signature + return safe_signatures def validate(self, attrs): - safe_module_address = attrs["safe_module_address"] - # TODO Check safe_module_address is whitelisted - # if module_address IN + attrs = super().validate(attrs) + + module_address = attrs["module_address"] + # TODO Check module_address is whitelisted + if module_address not in SAFE_OPERATION_MODULE_ADDRESSES: + raise ValidationError( + f"Module-address={module_address} not supported, valid values are {SAFE_OPERATION_MODULE_ADDRESSES}" + ) valid_after = attrs["valid_after"] valid_until = attrs["valid_until"] @@ -86,18 +96,31 @@ def validate(self, attrs): if valid_after > valid_until: raise ValidationError("`valid_after` cannot be higher than `valid_until`") - safe_address = attrs["safe"] + safe_address = self.context["safe_address"] + nonce = attrs["nonce"] + if ( + UserOperationModel.objects.filter(sender=safe_address, nonce=nonce) + .exclude(ethereum_tx=None) + .exists() + ): + raise ValidationError( + f"UserOperation with nonce={nonce} for safe={safe_address} was already executed" + ) + paymaster = attrs["paymaster"] paymaster_data = attrs["paymaster_data"] paymaster_and_data = ( (HexBytes(paymaster) + HexBytes(paymaster_data)) if paymaster and paymaster_data - else None + else b"" ) + attrs["paymaster_and_data"] = paymaster_and_data + attrs["init_code"] = attrs["init_code"] or b"" + attrs["call_data"] = attrs["call_data"] or b"" safe_operation = SafeOperationClass( attrs["safe"], - attrs["nonce"], + nonce, fast_keccak(attrs["init_code"]), fast_keccak(attrs["call_data"]), attrs["call_data_gas_limit"], @@ -113,11 +136,12 @@ def validate(self, attrs): ) chain_id = get_chain_id() + attrs["chain_id"] = chain_id safe_operation_hash = safe_operation.get_safe_operation_hash( - chain_id, safe_module_address + chain_id, module_address ) - if SafeOperation.objects.filter(hash=safe_operation_hash).exists(): + if SafeOperationModel.objects.filter(hash=safe_operation_hash).exists(): raise ValidationError( f"SafeOperation with hash={safe_operation_hash} already exists" ) @@ -125,36 +149,141 @@ def validate(self, attrs): safe_signatures = self._validate_signature( safe_address, safe_operation_hash, - safe_operation.get_safe_operation_hash_preimage( - chain_id, safe_module_address - ), + safe_operation.get_safe_operation_hash_preimage(chain_id, module_address), attrs["signature"], ) if not safe_signatures: - raise ValidationError("No valid signatures provided") + raise ValidationError("At least one signature must be provided") attrs["safe_operation_hash"] = safe_operation_hash attrs["safe_signatures"] = safe_signatures return attrs + @transaction.atomic def save(self, **kwargs): - safe_operation_hash = self.validated_data["safe_operation_hash"] + user_operation = UserOperationClass( + b"", + self.context["safe_address"], + self.validated_data["nonce"], + self.validated_data["init_code"], + self.validated_data["call_data"], + self.validated_data["call_data_gas_limit"], + self.validated_data["verification_gas_limit"], + self.validated_data["pre_verification_gas"], + self.validated_data["max_fee_per_gas"], + self.validated_data["max_priority_fee_per_gas"], + self.validated_data["paymaster_and_data"], + self.validated_data["signature"], + self.validated_data["entry_point"], + ) + + user_operation_hash = user_operation.calculate_user_operation_hash( + self.validated_data["chain_id"] + ) + + user_operation_model, created = UserOperationModel.objects.get_or_create( + hash=user_operation_hash, + defaults={ + "ethereum_tx": None, + "sender": user_operation.sender, + "nonce": user_operation.nonce, + "init_code": user_operation.init_code, + "call_data": user_operation.call_data, + "call_data_gas_limit": user_operation.call_gas_limit, + "verification_gas_limit": user_operation.verification_gas_limit, + "pre_verification_gas": user_operation.pre_verification_gas, + "max_fee_per_gas": user_operation.max_fee_per_gas, + "max_priority_fee_per_gas": user_operation.max_priority_fee_per_gas, + "paymaster": user_operation.paymaster, + "paymaster_data": user_operation.paymaster_data, + "signature": user_operation.signature, + "entry_point": user_operation.entry_point, + }, + ) + + if created: + safe_operation_model = SafeOperationModel.objects.create( + hash=self.validated_data["safe_operation_hash"], + user_operation=user_operation_model, + valid_after=self.validated_data["valid_after"], + valid_until=self.validated_data["valid_until"], + module_address=self.validated_data["module_address"], + ) - safe_operation_confirmations = [] safe_signatures = self.validated_data["safe_signatures"] for safe_signature in safe_signatures: - """ - multisig_confirmation, _ = SafeOperationConfirmation.objects.get_or_create( - multisig_transaction_hash=safe_tx_hash, + SafeOperationConfirmation.objects.get_or_create( + safe_operation=safe_operation_model, owner=safe_signature.owner, defaults={ - "multisig_transaction_id": safe_tx_hash, "signature": safe_signature.export_signature(), "signature_type": safe_signature.signature_type.value, }, ) - safe_operation_confirmations.append(multisig_confirmation) - """ - pass - return safe_operation_confirmations + return user_operation_model + + +# ================================================ # +# Request Serializers +# ================================================ # +class SafeOperationConfirmationResponseSerializer(serializers.Serializer): + created = serializers.DateTimeField() + modified = serializers.DateTimeField() + owner = eth_serializers.EthereumAddressField() + signature = eth_serializers.HexadecimalField() + signature_type = serializers.SerializerMethodField() + + def get_signature_type(self, obj: SafeOperationConfirmation) -> str: + return SafeSignatureType(obj.signature_type).name + + +class SafeOperationResponseSerializer(serializers.Serializer): + created = serializers.DateTimeField(source="safe_operation.created") + modified = serializers.DateTimeField(source="safe_operation.modified") + + sender = eth_serializers.EthereumAddressField() + user_operation_hash = eth_serializers.HexadecimalField(source="hash") + safe_operation_hash = eth_serializers.HexadecimalField(source="safe_operation.hash") + nonce = serializers.IntegerField(min_value=0) + init_code = eth_serializers.HexadecimalField(allow_null=True) + call_data = eth_serializers.HexadecimalField(allow_null=True) + call_data_gas_limit = serializers.IntegerField(min_value=0) + verification_gas_limit = serializers.IntegerField(min_value=0) + pre_verification_gas = serializers.IntegerField(min_value=0) + max_fee_per_gas = serializers.IntegerField(min_value=0) + max_priority_fee_per_gas = serializers.IntegerField(min_value=0) + paymaster = eth_serializers.EthereumAddressField(allow_null=True) + paymaster_data = eth_serializers.HexadecimalField(allow_null=True) + signature = eth_serializers.HexadecimalField() + entry_point = eth_serializers.EthereumAddressField() + # Safe Operation fields + valid_after = serializers.DateTimeField(source="safe_operation.valid_after") + valid_until = serializers.DateTimeField(source="safe_operation.valid_until") + module_address = eth_serializers.EthereumAddressField( + source="safe_operation.module_address" + ) + + confirmations = serializers.SerializerMethodField() + prepared_signature = serializers.SerializerMethodField() + + def get_confirmations(self, obj: SafeOperation) -> Dict[str, Any]: + """ + Filters confirmations queryset + + :param obj: SafeOperation instance + :return: Serialized queryset + """ + return SafeOperationConfirmationResponseSerializer( + obj.safe_operation.confirmations, many=True + ).data + + def get_prepared_signature(self, obj: SafeOperation) -> Optional[HexStr]: + """ + Prepared signature sorted + + :param obj: SafeOperation instance + :return: Serialized queryset + """ + signature = HexBytes(obj.safe_operation.build_signature()) + return HexBytes(signature).hex() if signature else None diff --git a/safe_transaction_service/account_abstraction/tests/factories.py b/safe_transaction_service/account_abstraction/tests/factories.py index 2bb9203f5..6eb21a08a 100644 --- a/safe_transaction_service/account_abstraction/tests/factories.py +++ b/safe_transaction_service/account_abstraction/tests/factories.py @@ -1,26 +1,72 @@ +from django.utils import timezone + import factory from eth_account import Account from factory.django import DjangoModelFactory from gnosis.eth.constants import NULL_ADDRESS from gnosis.eth.utils import fast_keccak +from gnosis.safe.safe_signature import SafeSignatureType + +from safe_transaction_service.history.tests import factories as history_factories from .. import models from ..constants import USER_OPERATION_SUPPORTED_ENTRY_POINTS -class UserOperation(DjangoModelFactory): +class UserOperationFactory(DjangoModelFactory): class Meta: model = models.UserOperation - user_operation_hash = factory.Sequence( - lambda n: fast_keccak(f"user-operation-{n}".encode()).hex() + hash = factory.Sequence( + lambda n: "0x" + fast_keccak(f"user-operation-{n}".encode()).hex() ) + ethereum_tx = factory.SubFactory(history_factories.EthereumTxFactory) sender = factory.LazyFunction(lambda: Account.create().address) + nonce = factory.Sequence(lambda n: n) + init_code = b"" + call_data = b"" + call_data_gas_limit = factory.fuzzy.FuzzyInteger(50_000, 200_000) + verification_gas_limit = factory.fuzzy.FuzzyInteger(30_000, 50_000) + pre_verification_gas = factory.fuzzy.FuzzyInteger(20_000, 30_000) + max_fee_per_gas = factory.fuzzy.FuzzyInteger(20, 50) + max_priority_fee_per_gas = factory.fuzzy.FuzzyInteger(0, 10) paymaster = NULL_ADDRESS - entry_point = USER_OPERATION_SUPPORTED_ENTRY_POINTS[0] + paymaster_data = b"" + signature = b"" + entry_point = list(USER_OPERATION_SUPPORTED_ENTRY_POINTS)[0] -class UserOperationReceipt(DjangoModelFactory): +class UserOperationReceiptFactory(DjangoModelFactory): class Meta: model = models.UserOperationReceipt + + user_operation = factory.SubFactory(UserOperationFactory) + + +class SafeOperationFactory(DjangoModelFactory): + class Meta: + model = models.SafeOperation + + hash = factory.Sequence( + lambda n: "0x" + fast_keccak(f"safe-operation-{n}".encode()).hex() + ) + user_operation = factory.SubFactory(UserOperationFactory) + valid_after = factory.LazyFunction(timezone.now) + valid_until = factory.LazyFunction(timezone.now) + module_address = factory.LazyFunction(lambda: Account.create().address) + + +class SafeOperationConfirmationFactory(DjangoModelFactory): + class Meta: + model = models.SafeOperationConfirmation + + class Params: + signing_owner = Account.create() + + safe_operation = factory.SubFactory(SafeOperationFactory) + owner = factory.LazyAttribute(lambda o: o.signing_owner.address) + signature = factory.LazyAttribute( + lambda o: o.signing_owner.signHash(o.safe_operation.hash)["signature"].hex() + ) + signature_type = SafeSignatureType.EOA.value diff --git a/safe_transaction_service/account_abstraction/tests/test_views.py b/safe_transaction_service/account_abstraction/tests/test_views.py new file mode 100644 index 000000000..0e20bd2e1 --- /dev/null +++ b/safe_transaction_service/account_abstraction/tests/test_views.py @@ -0,0 +1,621 @@ +import datetime +import logging +from unittest import mock +from unittest.mock import MagicMock + +from django.urls import reverse + +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 gnosis.eth.constants import NULL_ADDRESS +from gnosis.eth.eip712 import eip712_encode_hash +from gnosis.safe.safe_signature import SafeSignatureEOA +from gnosis.safe.signatures import signature_to_bytes +from gnosis.safe.tests.safe_test_case import SafeTestCaseMixin + +from safe_transaction_service.safe_messages.models import ( + SafeMessage, + SafeMessageConfirmation, +) + +from ...safe_messages.tests.factories import ( + SafeMessageConfirmationFactory, + SafeMessageFactory, +) +from ...safe_messages.tests.mocks import get_eip712_payload_mock +from . import factories + +logger = logging.getLogger(__name__) + + +# FIXME Refactor datetime_to_str +def datetime_to_str(value: datetime.datetime) -> str: + value = value.isoformat() + if value.endswith("+00:00"): + value = value[:-6] + "Z" + return value + + +class TestMessageViews(SafeTestCaseMixin, APITestCase): + def test_safe_operation_view(self): + random_safe_operation_hash = ( + "0x8aca9664752dbae36135fd0956c956fc4a370feeac67485b49bcd4b99608ae41" + ) + response = self.client.get( + reverse( + "v1:account_abstraction:safe-operation", + args=(random_safe_operation_hash,), + ) + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(response.json(), {"detail": "Not found."}) + safe_operation = factories.SafeOperationFactory( + user_operation__sender=self.deploy_test_safe().address + ) + response = self.client.get( + reverse( + "v1:account_abstraction:safe-operation", args=(safe_operation.hash,) + ) + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + expected = { + "created": datetime_to_str(safe_operation.created), + "modified": datetime_to_str(safe_operation.modified), + "sender": safe_operation.user_operation.sender, + "nonce": safe_operation.user_operation.nonce, + "userOperationHash": safe_operation.user_operation.hash, + "safeOperationHash": safe_operation.hash, + "initCode": "0x", # FIXME Should be None + "callData": "0x", # FIXME Should be None + "callDataGasLimit": safe_operation.user_operation.call_data_gas_limit, + "verificationGasLimit": safe_operation.user_operation.verification_gas_limit, + "preVerificationGas": safe_operation.user_operation.pre_verification_gas, + "maxFeePerGas": safe_operation.user_operation.max_fee_per_gas, + "maxPriorityFeePerGas": safe_operation.user_operation.max_priority_fee_per_gas, + "paymaster": NULL_ADDRESS, + "paymasterData": "0x", + "signature": "0x", + "entryPoint": safe_operation.user_operation.entry_point, + "validAfter": datetime_to_str(safe_operation.valid_after), + "validUntil": datetime_to_str(safe_operation.valid_until), + "moduleAddress": safe_operation.module_address, + "confirmations": [], + "preparedSignature": None, + } + self.assertDictEqual( + response.json(), + expected, + ) + + # Add a confirmation + safe_operation_confirmation = factories.SafeOperationConfirmationFactory( + safe_operation=safe_operation + ) + response = self.client.get( + reverse( + "v1:account_abstraction:safe-operation", args=(safe_operation.hash,) + ) + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + expected["confirmations"] = [ + { + "created": datetime_to_str(safe_operation_confirmation.created), + "modified": datetime_to_str(safe_operation_confirmation.modified), + "owner": safe_operation_confirmation.owner, + "signature": safe_operation_confirmation.signature, + "signatureType": "EOA", + } + ] + self.assertDictEqual(response.json(), expected) + + def test_safe_operations_view(self): + safe_operation = factories.SafeOperationFactory( + user_operation__sender=self.deploy_test_safe().address + ) + safe_operation_confirmation = factories.SafeOperationConfirmationFactory( + safe_operation=safe_operation + ) + + response = self.client.get( + reverse("v1:safe_operations:message", args=(safe_operation.hash,)) + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertDictEqual( + response.json(), + { + "created": datetime_to_str(safe_operation.created), + "modified": datetime_to_str(safe_operation.modified), + "safe": safe_operation.safe, + "messageHash": safe_operation.hash, + "message": safe_operation.message, + "proposedBy": safe_operation.proposed_by, + "safeAppId": safe_operation.safe_app_id, + "preparedSignature": safe_operation_confirmation.signature, + "confirmations": [ + { + "created": datetime_to_str(safe_operation_confirmation.created), + "modified": datetime_to_str( + safe_operation_confirmation.modified + ), + "owner": safe_operation_confirmation.owner, + "signature": safe_operation_confirmation.signature, + "signatureType": "EOA", + } + ], + }, + ) + + @mock.patch( + "safe_transaction_service.account_abstraction.serializers.get_safe_owners", + ) + def test_safe_operation_create_view(self, get_owners_mock: MagicMock): + account = Account.create() + safe = self.deploy_test_safe() + 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]), + ] + safe_message_hashes = [ + safe.get_message_hash(message_hash) for message_hash in message_hashes + ] + signatures = [ + account.signHash(safe_message_hash)["signature"].hex() + for safe_message_hash in safe_message_hashes + ] + + 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 + ): + 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, + ): + data = { + "message": message, + "description": description, + "signature": signature, + "safeAppId": -1, + } + response = self.client.post( + reverse("v1:safe_messages:safe-messages", args=(safe_address,)), + format="json", + data=data, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data, + { + "safe_app_id": [ + ErrorDetail( + string="Ensure this value is greater than or equal to 0.", + code="min_value", + ) + ] + }, + ) + + data.pop("safeAppId") + response = self.client.post( + reverse("v1:safe_messages:safe-messages", args=(safe_address,)), + format="json", + data=data, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data, + { + "non_field_errors": [ + ErrorDetail( + string=f"{account.address} is not an owner of the Safe", + code="invalid", + ) + ] + }, + ) + + # Test not valid signature + with mock.patch.object( + SafeSignatureEOA, "is_valid", return_value=False + ): + response = self.client.post( + reverse("v1:safe_messages:safe-messages", args=(safe_address,)), + format="json", + data=data, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data, + { + "non_field_errors": [ + ErrorDetail( + string=f'Signature={data["signature"]} for owner={account.address} is not valid', + code="invalid", + ) + ] + }, + ) + + get_owners_mock.return_value = [account.address] + response = self.client.post( + reverse("v1:safe_messages:safe-messages", args=(safe_address,)), + format="json", + data=data, + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(SafeMessage.objects.count(), 1) + self.assertEqual(SafeMessageConfirmation.objects.count(), 1) + + response = self.client.post( + reverse("v1:safe_messages:safe-messages", args=(safe_address,)), + format="json", + data=data, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data, + { + "non_field_errors": [ + ErrorDetail( + string=f"Message with hash {safe_message_hash.hex()} for safe {safe_address} already exists in DB", + code="invalid", + ) + ] + }, + ) + + def test_safe_messages_create_using_1271_signature_view(self): + account = Account.create() + safe_owner = self.deploy_test_safe(owners=[account.address]) + safe = self.deploy_test_safe(owners=[safe_owner.address]) + + 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) + safe_owner_signature = account.signHash(safe_owner_message_hash)["signature"] + + # Build EIP1271 signature v=0 r=safe v=dynamic_part dynamic_part=size+owner_signature + signature_1271 = ( + signature_to_bytes( + 0, int.from_bytes(HexBytes(safe_owner.address), byteorder="big"), 65 + ) + + eth_abi.encode(["bytes"], [safe_owner_signature])[32:] + ) + + data = { + "message": message, + "description": description, + "signature": HexBytes(signature_1271).hex(), + } + response = self.client.post( + reverse("v1:safe_messages:safe-messages", args=(safe_address,)), + format="json", + data=data, + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(SafeMessage.objects.count(), 1) + self.assertEqual(SafeMessageConfirmation.objects.count(), 1) + + @mock.patch( + "safe_transaction_service.safe_messages.serializers.get_safe_owners", + return_value=[], + ) + def test_safe_message_add_signature_view(self, get_owners_mock: MagicMock): + # Test not existing message + safe_message_hash = ( + "0x8aca9664752dbae36135fd0956c956fc4a370feeac67485b49bcd4b99608ae41" + ) + data = {"signature": "0x12"} + response = self.client.post( + reverse("v1:safe_messages:signatures", args=(safe_message_hash,)), + format="json", + data=data, + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + # Test invalid signature + safe_message = SafeMessageFactory(safe=self.deploy_test_safe().address) + safe_message_confirmation = SafeMessageConfirmationFactory( + safe_message=safe_message + ) + response = self.client.post( + reverse("v1:safe_messages:signatures", args=(safe_message.message_hash,)), + format="json", + data=data, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertDictEqual( + response.data, + { + "signature": [ + ErrorDetail( + string="Ensure this field has at least 65 hexadecimal chars (not counting 0x).", + code="min_length", + ) + ] + }, + ) + + # Test same signature + data["signature"] = safe_message_confirmation.signature + response = self.client.post( + reverse("v1:safe_messages:signatures", args=(safe_message.message_hash,)), + format="json", + data=data, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data, + { + "non_field_errors": [ + ErrorDetail( + string=f"Signature for owner {safe_message_confirmation.owner} already exists", + code="invalid", + ) + ] + }, + ) + + # Test not existing owner + owner_account = Account.create() + data["signature"] = owner_account.signHash(safe_message.message_hash)[ + "signature" + ].hex() + response = self.client.post( + reverse("v1:safe_messages:signatures", args=(safe_message.message_hash,)), + format="json", + data=data, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data, + { + "non_field_errors": [ + ErrorDetail( + string=f"{owner_account.address} is not an owner of the Safe", + code="invalid", + ) + ] + }, + ) + + # Test valid owner + get_owners_mock.return_value = [owner_account.address] + response = self.client.post( + reverse("v1:safe_messages:signatures", args=(safe_message.message_hash,)), + format="json", + data=data, + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(SafeMessage.objects.count(), 1) + self.assertEqual(SafeMessageConfirmation.objects.count(), 2) + safe_message_confirmation = SafeMessageConfirmation.objects.get( + safe_message__safe=safe_message.safe, owner=owner_account.address + ) + + # Check SafeMessage modified was updated with new signature + safe_message_modified = safe_message.modified + safe_message.refresh_from_db() + self.assertGreater(safe_message.modified, safe_message_modified) + self.assertEqual(safe_message_confirmation.modified, safe_message.modified) + + def test_safe_messages_list_view(self): + safe_address = Account.create().address + response = self.client.get( + reverse("v1:safe_messages:safe-messages", args=(safe_address,)) + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.json(), {"count": 0, "next": None, "previous": None, "results": []} + ) + + # Create a Safe message for a random Safe, it should not appear + safe_message = SafeMessageFactory(safe=self.deploy_test_safe().address) + response = self.client.get( + reverse("v1:safe_messages:safe-messages", args=(safe_address,)) + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.json(), {"count": 0, "next": None, "previous": None, "results": []} + ) + + response = self.client.get( + reverse("v1:safe_messages:safe-messages", args=(safe_message.safe,)) + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.json(), + { + "count": 1, + "next": None, + "previous": None, + "results": [ + { + "created": datetime_to_str(safe_message.created), + "modified": datetime_to_str(safe_message.modified), + "safe": safe_message.safe, + "messageHash": safe_message.message_hash, + "message": safe_message.message, + "proposedBy": safe_message.proposed_by, + "safeAppId": safe_message.safe_app_id, + "preparedSignature": None, + "confirmations": [], + } + ], + }, + ) + + # Add a confirmation + safe_message_confirmation = SafeMessageConfirmationFactory( + safe_message=safe_message + ) + response = self.client.get( + reverse("v1:safe_messages:safe-messages", args=(safe_message.safe,)) + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.json(), + { + "count": 1, + "next": None, + "previous": None, + "results": [ + { + "created": datetime_to_str(safe_message.created), + "modified": datetime_to_str(safe_message.modified), + "safe": safe_message.safe, + "messageHash": safe_message.message_hash, + "message": safe_message.message, + "proposedBy": safe_message.proposed_by, + "safeAppId": safe_message.safe_app_id, + "preparedSignature": safe_message_confirmation.signature, + "confirmations": [ + { + "created": datetime_to_str( + safe_message_confirmation.created + ), + "modified": datetime_to_str( + safe_message_confirmation.modified + ), + "owner": safe_message_confirmation.owner, + "signature": safe_message_confirmation.signature, + "signatureType": "EOA", + } + ], + } + ], + }, + ) + + def test_safe_messages_list_not_camel_case_view(self): + safe_message = SafeMessageFactory(safe=self.deploy_test_safe().address) + safe_message_confirmation = SafeMessageConfirmationFactory( + safe_message=safe_message + ) + safe_message.message = {"test_not_camel": 2} + safe_message.save(update_fields=["message"]) + + # Response message should not be camelcased + response = self.client.get( + reverse("v1:safe_messages:safe-messages", args=(safe_message.safe,)) + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.json(), + { + "count": 1, + "next": None, + "previous": None, + "results": [ + { + "created": datetime_to_str(safe_message.created), + "modified": datetime_to_str(safe_message.modified), + "safe": safe_message.safe, + "messageHash": safe_message.message_hash, + "message": safe_message.message, + "proposedBy": safe_message.proposed_by, + "safeAppId": safe_message.safe_app_id, + "preparedSignature": safe_message_confirmation.signature, + "confirmations": [ + { + "created": datetime_to_str( + safe_message_confirmation.created + ), + "modified": datetime_to_str( + safe_message_confirmation.modified + ), + "owner": safe_message_confirmation.owner, + "signature": safe_message_confirmation.signature, + "signatureType": "EOA", + } + ], + } + ], + }, + ) + + def test_safe_message_view_v1_1_1(self): + random_safe_message_hash = ( + "0x8aca9664752dbae36135fd0956c956fc4a370feeac67485b49bcd4b99608ae41" + ) + response = self.client.get( + reverse( + "v1:account_abstraction:safe-operation", + args=(random_safe_message_hash,), + ) + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(response.json(), {"detail": "Not found."}) + safe_message = SafeMessageFactory(safe=self.deploy_test_safe_v1_1_1().address) + response = self.client.get( + reverse( + "v1:account_abstraction:safe-operation", + args=(safe_message.message_hash,), + ) + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.json(), + { + "created": datetime_to_str(safe_message.created), + "modified": datetime_to_str(safe_message.modified), + "safe": safe_message.safe, + "messageHash": safe_message.message_hash, + "message": safe_message.message, + "proposedBy": safe_message.proposed_by, + "safeAppId": safe_message.safe_app_id, + "preparedSignature": None, + "confirmations": [], + }, + ) + + # Add a confirmation + safe_message_confirmation = SafeMessageConfirmationFactory( + safe_message=safe_message + ) + response = self.client.get( + reverse( + "v1:account_abstraction:safe-operation", + args=(safe_message.message_hash,), + ) + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.json(), + { + "created": datetime_to_str(safe_message.created), + "modified": datetime_to_str(safe_message.modified), + "safe": safe_message.safe, + "messageHash": safe_message.message_hash, + "message": safe_message.message, + "proposedBy": safe_message.proposed_by, + "safeAppId": safe_message.safe_app_id, + "preparedSignature": safe_message_confirmation.signature, + "confirmations": [ + { + "created": datetime_to_str(safe_message_confirmation.created), + "modified": datetime_to_str(safe_message_confirmation.modified), + "owner": safe_message_confirmation.owner, + "signature": safe_message_confirmation.signature, + "signatureType": "EOA", + } + ], + }, + ) diff --git a/safe_transaction_service/account_abstraction/urls.py b/safe_transaction_service/account_abstraction/urls.py index 41b7f24d7..f5e0b5dae 100644 --- a/safe_transaction_service/account_abstraction/urls.py +++ b/safe_transaction_service/account_abstraction/urls.py @@ -1,3 +1,18 @@ +from django.urls import path + +from . import views + app_name = "account_abstraction" -urlpatterns = [] +urlpatterns = [ + path( + "safe-operations//", + views.SafeOperationView.as_view(), + name="safe-operation", + ), + path( + "safes//safe-operations/", + views.SafeOperationsView.as_view(), + name="safe-operations", + ), +] diff --git a/safe_transaction_service/account_abstraction/views.py b/safe_transaction_service/account_abstraction/views.py index e69de29bb..21f54aa42 100644 --- a/safe_transaction_service/account_abstraction/views.py +++ b/safe_transaction_service/account_abstraction/views.py @@ -0,0 +1,87 @@ +import django_filters +from drf_yasg.utils import swagger_auto_schema +from rest_framework import status +from rest_framework.filters import OrderingFilter +from rest_framework.generics import ListCreateAPIView, RetrieveAPIView +from rest_framework.response import Response + +from gnosis.eth.utils import fast_is_checksum_address + +from . import pagination, serializers +from .models import UserOperation + + +class SafeOperationView(RetrieveAPIView): + lookup_field = "safe_operation__hash" + lookup_url_kwarg = "safe_operation_hash" + queryset = UserOperation.objects.prefetch_related("safe_operation__confirmations") + serializer_class = serializers.SafeOperationResponseSerializer + + +class SafeOperationsView(ListCreateAPIView): + filter_backends = [ + django_filters.rest_framework.DjangoFilterBackend, + OrderingFilter, + ] + ordering = ["-created"] + ordering_fields = ["created", "modified"] + pagination_class = pagination.DefaultPagination + + def get_serializer_context(self): + context = super().get_serializer_context() + if getattr(self, "swagger_fake_view", False): + return context + + context["safe_address"] = self.kwargs["address"] + return context + + def get_serializer_class(self): + if self.request.method == "GET": + return serializers.SafeOperationResponseSerializer + elif self.request.method == "POST": + return serializers.SafeOperationSerializer + + def get(self, request, address, *args, **kwargs): + if not fast_is_checksum_address(address): + return Response( + status=status.HTTP_422_UNPROCESSABLE_ENTITY, + data={ + "code": 1, + "message": "Checksum address validation failed", + "arguments": [address], + }, + ) + return super().get(request, address, *args, **kwargs) + + @swagger_auto_schema( + request_body=serializers.SafeOperationSerializer, + responses={201: "Created"}, + ) + def post(self, request, address, *args, **kwargs): + """ + Create a new signed message for a Safe. Message can be: + - A ``string``, so ``EIP191`` will be used to get the hash. + - An ``EIP712`` ``object``. + + Hash will be calculated from the provided ``message``. Sending a raw ``hash`` will not be accepted, + service needs to derive it itself. + """ + if not fast_is_checksum_address(address): + return Response( + status=status.HTTP_422_UNPROCESSABLE_ENTITY, + data={ + "code": 1, + "message": "Checksum address validation failed", + "arguments": [address], + }, + ) + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + return Response(status=status.HTTP_201_CREATED) + + def get_queryset(self): + safe = self.kwargs["address"] + return UserOperation.objects.filter(sender=safe).prefetch_related( + "safe_operation__confirmations" + ) diff --git a/safe_transaction_service/safe_messages/serializers.py b/safe_transaction_service/safe_messages/serializers.py index d6a391d2c..1de62e76b 100644 --- a/safe_transaction_service/safe_messages/serializers.py +++ b/safe_transaction_service/safe_messages/serializers.py @@ -27,7 +27,6 @@ def get_valid_owner_from_signatures( """ :param safe_signatures: :param safe_address: - :param message_hash: Original hash of the message (not the one tied to the Safe) :param safe_message: Safe message database object (if already created) :return: :raises ValidationError: diff --git a/safe_transaction_service/safe_messages/tests/factories.py b/safe_transaction_service/safe_messages/tests/factories.py index fc8de81d3..6f7f9078f 100644 --- a/safe_transaction_service/safe_messages/tests/factories.py +++ b/safe_transaction_service/safe_messages/tests/factories.py @@ -2,6 +2,8 @@ from eth_account import Account from factory.django import DjangoModelFactory +from gnosis.safe.safe_signature import SafeSignatureType + from ..models import SafeMessage, SafeMessageConfirmation from ..utils import get_hash_for_message, get_safe_message_hash_for_message @@ -36,4 +38,4 @@ class Params: "signature" ].hex() ) - signature_type = 2 + signature_type = SafeSignatureType.EOA.value