diff --git a/config/settings/base.py b/config/settings/base.py index 4357fc32e..e0fc2a811 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -540,13 +540,21 @@ # Ethereum 4337 Bundler RPC # ------------------------------------------------------------------------------ ETHEREUM_4337_BUNDLER_URL = env("ETHEREUM_4337_BUNDLER_URL", default=None) +ETHEREUM_4337_ENTRYPOINT_V6 = "0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789" +ETHEREUM_4337_ENTRYPOINT_V7 = "0x0000000071727De22E5E9d8BAf0edAc6f37da032" ETHEREUM_4337_SUPPORTED_ENTRY_POINTS = env.list( "ETHEREUM_4337_SUPPORTED_ENTRY_POINTS", - default=["0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789"], + default=[ETHEREUM_4337_ENTRYPOINT_V6, ETHEREUM_4337_ENTRYPOINT_V7], ) +# Module addresses +ETHEREUM_4337_SAFE_MODULE_ADDRESS_V06 = "0xa581c4A4DB7175302464fF3C06380BC3270b4037" +ETHEREUM_4337_SAFE_MODULE_ADDRESS_V07 = "0x75cf11467937ce3F2f357CE24ffc3DBF8fD5c226" ETHEREUM_4337_SUPPORTED_SAFE_MODULES = env.list( "ETHEREUM_4337_SUPPORTED_SAFE_MODULES", - default=["0xa581c4A4DB7175302464fF3C06380BC3270b4037"], + default=[ + ETHEREUM_4337_SAFE_MODULE_ADDRESS_V06, # v0.6 Safe4337Module + ETHEREUM_4337_SAFE_MODULE_ADDRESS_V07, # v0.7 Safe4337Module + ], ) # Tracing indexing configuration (not useful for L2 indexing) diff --git a/safe_transaction_service/account_abstraction/migrations/0006_useroperation_v7_support.py b/safe_transaction_service/account_abstraction/migrations/0006_useroperation_v7_support.py new file mode 100644 index 000000000..03302e103 --- /dev/null +++ b/safe_transaction_service/account_abstraction/migrations/0006_useroperation_v7_support.py @@ -0,0 +1,51 @@ +# Generated by Django 5.2.7 on 2025-10-28 17:35 + +import safe_eth.eth.django.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account_abstraction', '0005_alter_safeoperation_module_address_and_more'), + ('history', '0095_remove_internaltx_history_internaltx_value_idx_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='useroperation', + name='factory', + field=safe_eth.eth.django.models.EthereumAddressBinaryField(blank=True, db_index=True, null=True), + ), + migrations.AddField( + model_name='useroperation', + name='factory_data', + field=models.BinaryField(blank=True, editable=True, null=True), + ), + migrations.AddField( + model_name='useroperation', + name='paymaster_post_op_gas_limit', + field=safe_eth.eth.django.models.Uint256Field(blank=True, null=True), + ), + migrations.AddField( + model_name='useroperation', + name='paymaster_verification_gas_limit', + field=safe_eth.eth.django.models.Uint256Field(blank=True, null=True), + ), + migrations.AddConstraint( + model_name='useroperation', + constraint=models.CheckConstraint(condition=models.Q(('factory__isnull', True), ('init_code__isnull', True), _connector='OR'), name='factory_or_init_code_not_both'), + ), + migrations.AddConstraint( + model_name='useroperation', + constraint=models.CheckConstraint(condition=models.Q(models.Q(('paymaster_data__isnull', True), ('paymaster_post_op_gas_limit__isnull', True), ('paymaster_verification_gas_limit__isnull', True)), ('paymaster__isnull', False), _connector='OR'), name='paymaster_required_with_paymaster_fields'), + ), + migrations.AddConstraint( + model_name='useroperation', + constraint=models.CheckConstraint(condition=models.Q(('init_code__isnull', True), models.Q(('paymaster_post_op_gas_limit__isnull', True), ('paymaster_verification_gas_limit__isnull', True)), _connector='OR'), name='v7_paymaster_gas_limits_not_with_init_code'), + ), + migrations.AddConstraint( + model_name='useroperation', + constraint=models.CheckConstraint(condition=models.Q(('factory_data__isnull', True), ('factory__isnull', False), _connector='OR'), name='factory_required_with_factory_data'), + ), + ] diff --git a/safe_transaction_service/account_abstraction/models.py b/safe_transaction_service/account_abstraction/models.py index 3b76843e1..879afaba3 100644 --- a/safe_transaction_service/account_abstraction/models.py +++ b/safe_transaction_service/account_abstraction/models.py @@ -1,20 +1,23 @@ import logging from functools import cached_property +from django.conf import settings from django.db import models from django.db.models import Index from eth_abi.packed import encode_packed from hexbytes import HexBytes from model_utils.models import TimeStampedModel -from safe_eth.eth.account_abstraction import UserOperation as UserOperationClass +from safe_eth.eth.account_abstraction import UserOperation as UserOperationV6 from safe_eth.eth.account_abstraction import UserOperationMetadata +from safe_eth.eth.account_abstraction import UserOperationV07 as UserOperationV7 from safe_eth.eth.django.models import ( EthereumAddressBinaryField, HexV2Field, Keccak256Field, Uint256Field, ) +from safe_eth.eth.utils import fast_to_checksum_address from safe_eth.safe.account_abstraction import SafeOperation as SafeOperationClass from safe_eth.safe.safe_signature import SafeSignatureType from safe_eth.util.util import to_0x_hex_str @@ -52,10 +55,49 @@ class UserOperation(models.Model): signature = models.BinaryField(null=True, blank=True, editable=True) entry_point = EthereumAddressBinaryField(db_index=True) + # UserOperation v7 specific fields, filled as `None` for <= v6 + factory = EthereumAddressBinaryField( + db_index=True, null=True, blank=True, editable=True + ) + factory_data = models.BinaryField(null=True, blank=True, editable=True) + paymaster_verification_gas_limit = Uint256Field( + null=True, blank=True, editable=True + ) + paymaster_post_op_gas_limit = Uint256Field(null=True, blank=True, editable=True) + class Meta: indexes = [ Index(fields=["sender", "-nonce"]), ] + constraints = [ + models.CheckConstraint( + condition=models.Q(factory__isnull=True) + | models.Q(init_code__isnull=True), + name="factory_or_init_code_not_both", + ), + models.CheckConstraint( + condition=models.Q( + paymaster_verification_gas_limit__isnull=True, + paymaster_post_op_gas_limit__isnull=True, + paymaster_data__isnull=True, + ) + | models.Q(paymaster__isnull=False), + name="paymaster_required_with_paymaster_fields", + ), + models.CheckConstraint( + condition=models.Q(init_code__isnull=True) + | models.Q( + paymaster_verification_gas_limit__isnull=True, + paymaster_post_op_gas_limit__isnull=True, + ), + name="v7_paymaster_gas_limits_not_with_init_code", + ), + models.CheckConstraint( + condition=models.Q(factory_data__isnull=True) + | models.Q(factory__isnull=False), + name="factory_required_with_factory_data", + ), + ] def __str__(self) -> str: return f"{to_0x_hex_str(HexBytes(self.hash))} UserOperation for sender={self.sender} with nonce={self.nonce}" @@ -65,7 +107,9 @@ def paymaster_and_data(self) -> HexBytes | None: if self.paymaster and self.paymaster_data: return HexBytes(HexBytes(self.paymaster) + HexBytes(self.paymaster_data)) - def to_user_operation(self, add_tx_metadata: bool = False) -> UserOperationClass: + def to_user_operation( + self, add_tx_metadata: bool = False + ) -> UserOperationV6 | UserOperationV7: """ Returns a safe-eth-py UserOperation object @@ -86,23 +130,50 @@ def to_user_operation(self, add_tx_metadata: bool = False) -> UserOperationClass if not self.signature: raise ValueError("Signature should not be empty") signature = HexBytes(self.signature) - - return UserOperationClass( - HexBytes(self.hash), - self.sender, - self.nonce, - HexBytes(self.init_code) if self.init_code else b"", - HexBytes(self.call_data) if self.call_data else b"", - self.call_gas_limit, - self.verification_gas_limit, - self.pre_verification_gas, - self.max_fee_per_gas, - self.max_priority_fee_per_gas, - self.paymaster_and_data if self.paymaster_and_data else b"", - signature, - self.entry_point, - user_operation_metadata, - ) + if self.entry_point.lower() == settings.ETHEREUM_4337_ENTRYPOINT_V6.lower(): + return UserOperationV6( + HexBytes(self.hash), + self.sender, + self.nonce, + HexBytes(self.init_code) if self.init_code else b"", + HexBytes(self.call_data) if self.call_data else b"", + self.call_gas_limit, + self.verification_gas_limit, + self.pre_verification_gas, + self.max_fee_per_gas, + self.max_priority_fee_per_gas, + self.paymaster_and_data if self.paymaster_and_data else b"", + signature, + self.entry_point, + user_operation_metadata, + ) + elif self.entry_point.lower() == settings.ETHEREUM_4337_ENTRYPOINT_V7.lower(): + return UserOperationV7( + HexBytes(self.hash), + self.sender, + self.nonce, + HexBytes(self.call_data) if self.call_data else HexBytes(b""), + self.call_gas_limit, + self.verification_gas_limit, + self.pre_verification_gas, + self.max_fee_per_gas, + self.max_priority_fee_per_gas, + signature, + self.entry_point, + fast_to_checksum_address(self.factory) if self.factory else None, + HexBytes(self.factory_data) if self.factory_data else b"", + self.paymaster_verification_gas_limit + if self.paymaster_verification_gas_limit + else None, + self.paymaster_post_op_gas_limit + if self.paymaster_post_op_gas_limit + else None, + fast_to_checksum_address(self.paymaster) if self.paymaster else None, + HexBytes(self.paymaster_data) if self.paymaster_data else None, + user_operation_metadata, + ) + else: + raise ValueError("Unsupported entrypoint") def to_safe_operation(self) -> SafeOperationClass: """ diff --git a/safe_transaction_service/account_abstraction/serializers.py b/safe_transaction_service/account_abstraction/serializers.py index db0bc06d7..1090b2936 100644 --- a/safe_transaction_service/account_abstraction/serializers.py +++ b/safe_transaction_service/account_abstraction/serializers.py @@ -11,7 +11,8 @@ from rest_framework import serializers from rest_framework.exceptions import ValidationError from safe_eth.eth import get_auto_ethereum_client -from safe_eth.eth.account_abstraction import UserOperation as UserOperationClass +from safe_eth.eth.account_abstraction import UserOperation as UserOperationV6 +from safe_eth.eth.account_abstraction import UserOperationV07 as UserOperationV7 from safe_eth.eth.utils import fast_keccak, fast_to_checksum_address from safe_eth.safe.account_abstraction import SafeOperation as SafeOperationClass from safe_eth.safe.safe_signature import SafeSignature, SafeSignatureType @@ -91,14 +92,29 @@ class SafeOperationSerializer( SafeOperationSignatureValidatorMixin, serializers.Serializer ): nonce = serializers.IntegerField(min_value=0) - init_code = eth_serializers.HexadecimalField(allow_null=True) call_data = eth_serializers.HexadecimalField(allow_null=True) call_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_and_data = eth_serializers.HexadecimalField(allow_null=True) + # v6 fields + init_code = eth_serializers.HexadecimalField(allow_null=True, required=False) + paymaster_and_data = eth_serializers.HexadecimalField( + allow_null=True, required=False + ) + # v7 fields + factory = eth_serializers.EthereumAddressField(allow_null=True, required=False) + factory_data = eth_serializers.HexadecimalField(allow_null=True, required=False) + paymaster = eth_serializers.EthereumAddressField(allow_null=True, required=False) + paymaster_data = eth_serializers.HexadecimalField(allow_null=True, required=False) + paymaster_verification_gas_limit = serializers.IntegerField( + min_value=0, allow_null=True, required=False + ) + paymaster_post_op_gas_limit = serializers.IntegerField( + min_value=0, allow_null=True, required=False + ) + signature = eth_serializers.HexadecimalField( min_length=65, max_length=SIGNATURE_LENGTH ) @@ -207,6 +223,53 @@ def validate_valid_until( def validate(self, attrs): attrs = super().validate(attrs) + entry_point = attrs["entry_point"] + module_address = attrs["module_address"] + is_v7 = entry_point.lower() == settings.ETHEREUM_4337_ENTRYPOINT_V7.lower() + + # Validate module address is compatible with entrypoint version + if ( + is_v7 + and module_address.lower() + != settings.ETHEREUM_4337_SAFE_MODULE_ADDRESS_V07.lower() + ) or ( + not is_v7 + and module_address.lower() + != settings.ETHEREUM_4337_SAFE_MODULE_ADDRESS_V06.lower() + ): + raise ValidationError(f"Invalid Module address {module_address}") + + # Validate version-specific fields + if is_v7: + if attrs.get("init_code"): + raise ValidationError( + "`init_code` is not supported for EntryPoint v0.7" + ) + if attrs.get("paymaster_and_data"): + raise ValidationError( + "`paymaster_and_data` is not supported for EntryPoint v0.7, use paymaster fields instead" + ) + # Validate paymaster_data requires paymaster + if attrs.get("paymaster_data") and not attrs.get("paymaster"): + raise ValidationError("`paymaster_data` requires `paymaster` to be set") + # Validate factory_data requires factory + if attrs.get("factory_data") and not attrs.get("factory"): + raise ValidationError("`factory_data` requires `factory` to be set") + else: + if attrs.get("factory") or attrs.get("factory_data"): + raise ValidationError( + "`factory` fields are only supported for EntryPoint v0.7" + ) + if ( + attrs.get("paymaster") + or attrs.get("paymaster_data") + or attrs.get("paymaster_verification_gas_limit") + or attrs.get("paymaster_post_op_gas_limit") + ): + raise ValidationError( + "Paymaster, paymaster_verification_gas_limit, paymaster_post_op_gas_limit and paymaster_data fields are only supported for EntryPoint v0.7" + ) + valid_after, valid_until = [ int(attrs[key].timestamp()) if attrs[key] else 0 for key in ("valid_after", "valid_until") @@ -218,14 +281,14 @@ def validate(self, attrs): safe_operation = SafeOperationClass( safe_address, attrs["nonce"], - fast_keccak(attrs["init_code"] or b""), + fast_keccak(attrs.get("init_code") or b""), fast_keccak(attrs["call_data"] or b""), attrs["call_gas_limit"], attrs["verification_gas_limit"], attrs["pre_verification_gas"], attrs["max_fee_per_gas"], attrs["max_priority_fee_per_gas"], - fast_keccak(attrs["paymaster_and_data"] or b""), + fast_keccak(attrs.get("paymaster_and_data") or b""), valid_after, valid_until, attrs["entry_point"], @@ -260,44 +323,100 @@ def validate(self, attrs): @transaction.atomic def save(self, **kwargs): - user_operation = UserOperationClass( - b"", # Hash will be calculated later - self.context["safe_address"], - self.validated_data["nonce"], - self.validated_data["init_code"] or b"", - self.validated_data["call_data"] or b"", - self.validated_data["call_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"] or b"", - self.validated_data["signature"], - self.validated_data["entry_point"], - ) + entry_point = self.validated_data["entry_point"] + is_v7 = entry_point.lower() == settings.ETHEREUM_4337_ENTRYPOINT_V7.lower() + + if is_v7: + factory = self.validated_data["factory"] + factory_data = self.validated_data["factory_data"] if factory else None + paymaster = self.validated_data["paymaster"] + paymaster_data = ( + self.validated_data.get("paymaster_data") if paymaster else None + ) + user_operation: UserOperationV7 | UserOperationV6 = UserOperationV7( + b"", # Hash will be calculated later + self.context["safe_address"], + self.validated_data["nonce"], + self.validated_data["call_data"] or b"", + self.validated_data["call_gas_limit"], + self.validated_data["verification_gas_limit"], + self.validated_data["pre_verification_gas"], + self.validated_data["max_priority_fee_per_gas"], + self.validated_data["max_fee_per_gas"], + self.validated_data["signature"], + self.validated_data["entry_point"], + factory, + factory_data or b"", + self.validated_data["paymaster_verification_gas_limit"], + self.validated_data["paymaster_post_op_gas_limit"], + paymaster, + paymaster_data or b"", + ) + else: + user_operation = UserOperationV6( + b"", # Hash will be calculated later + self.context["safe_address"], + self.validated_data["nonce"], + self.validated_data["init_code"] or b"", + self.validated_data["call_data"] or b"", + self.validated_data["call_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"] or b"", + self.validated_data["signature"], + self.validated_data["entry_point"], + ) user_operation_hash = user_operation.calculate_user_operation_hash( self.validated_data["chain_id"] ) + defaults = { + "ethereum_tx": None, + "sender": user_operation.sender, + "nonce": user_operation.nonce, + "call_data": user_operation.call_data, + "call_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 is_v7: + # v7 specific fields + # Ensure factory_data and paymaster_data are None when factory/paymaster is None + # to satisfy database constraints + defaults.update( + { + "factory": user_operation.factory, + "factory_data": user_operation.factory_data + if user_operation.factory + else None, + "paymaster_verification_gas_limit": user_operation.paymaster_verification_gas_limit, + "paymaster_post_op_gas_limit": user_operation.paymaster_post_op_gas_limit, + "paymaster_data": user_operation.paymaster_data + if user_operation.paymaster + else None, + } + ) + else: + # v6 specific fields + defaults.update( + { + "init_code": user_operation.init_code, + } + ) + user_operation_model, _ = 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_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, - }, + defaults=defaults, ) safe_operation_model, _ = SafeOperationModel.objects.get_or_create( @@ -358,6 +477,15 @@ def validate(self, attrs): bytes(user_operation_model.init_code), self.ethereum_client ) self._deployment_owners = decoded_init_code.owners + elif user_operation_model.factory and user_operation_model.factory_data: + # v7: init_code = factory + factory_data + init_code_bytes = HexBytes(user_operation_model.factory) + HexBytes( + user_operation_model.factory_data + ) + decoded_init_code = decode_init_code( + bytes(init_code_bytes), self.ethereum_client + ) + self._deployment_owners = decoded_init_code.owners safe_signatures = self._validate_signature( safe_operation.safe, @@ -413,7 +541,6 @@ class UserOperationResponseSerializer(serializers.Serializer): sender = eth_serializers.EthereumAddressField() user_operation_hash = eth_serializers.HexadecimalField(source="hash") nonce = serializers.CharField() - init_code = eth_serializers.HexadecimalField(allow_null=True) call_data = eth_serializers.HexadecimalField(allow_null=True) call_gas_limit = serializers.CharField() verification_gas_limit = serializers.CharField() @@ -424,6 +551,31 @@ class UserOperationResponseSerializer(serializers.Serializer): paymaster_data = eth_serializers.HexadecimalField(allow_null=True) signature = eth_serializers.HexadecimalField() entry_point = eth_serializers.EthereumAddressField() + # v6 field + init_code = eth_serializers.HexadecimalField(allow_null=True) + # v7 fields + factory = eth_serializers.EthereumAddressField(allow_null=True) + factory_data = eth_serializers.HexadecimalField(allow_null=True) + paymaster_verification_gas_limit = serializers.CharField(allow_null=True) + paymaster_post_op_gas_limit = serializers.CharField(allow_null=True) + + def to_representation(self, instance): + # Remove version-specific fields based on entrypoint + data = super().to_representation(instance) + + is_v7 = ( + instance.entry_point.lower() == settings.ETHEREUM_4337_ENTRYPOINT_V7.lower() + ) + + if is_v7: + data.pop("init_code", None) + else: + data.pop("factory", None) + data.pop("factory_data", None) + data.pop("paymaster_verification_gas_limit", None) + data.pop("paymaster_post_op_gas_limit", None) + + return data class SafeOperationResponseSerializer(serializers.Serializer): diff --git a/safe_transaction_service/account_abstraction/services/aa_processor_service.py b/safe_transaction_service/account_abstraction/services/aa_processor_service.py index c85c58bf4..3060978c4 100644 --- a/safe_transaction_service/account_abstraction/services/aa_processor_service.py +++ b/safe_transaction_service/account_abstraction/services/aa_processor_service.py @@ -310,10 +310,8 @@ def index_user_operation( raise BundlerClientException( f"user-operation={user_operation_hash_hex} returned `null`" ) - if isinstance(user_operation, UserOperationV07): - raise UserOperationNotSupportedException( - f"user-operation={user_operation_hash_hex} for EntryPoint v0.7.0 is not supported" - ) + if user_operation.entry_point not in self.supported_entry_points: + raise UserOperationNotSupportedException("Entrypoint is not supported") try: user_operation_model = UserOperationModel.objects.get( @@ -335,22 +333,37 @@ def index_user_operation( user_operation_hash_hex, ethereum_tx.tx_hash, ) + user_operation_kwargs = { + "ethereum_tx": ethereum_tx, + "hash": user_operation_hash_hex, + "sender": user_operation.sender, + "nonce": user_operation.nonce, + "call_data": user_operation.call_data, + "call_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 isinstance(user_operation, UserOperationV07): + user_operation_kwargs.update( + { + "factory": user_operation.factory, + "factory_data": user_operation.factory_data, + "paymaster_verification_gas_limit": user_operation.paymaster_verification_gas_limit, + "paymaster_post_op_gas_limit": user_operation.paymaster_post_op_gas_limit, + } + ) + else: + user_operation_kwargs["init_code"] = user_operation.init_code + user_operation_model = UserOperationModel.objects.create( - ethereum_tx=ethereum_tx, - hash=user_operation_hash_hex, - sender=user_operation.sender, - nonce=user_operation.nonce, - init_code=user_operation.init_code, - call_data=user_operation.call_data, - call_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, + **user_operation_kwargs ) _, user_operation_receipt = self.index_user_operation_receipt( diff --git a/safe_transaction_service/account_abstraction/tests/services/test_aa_processor_service.py b/safe_transaction_service/account_abstraction/tests/services/test_aa_processor_service.py index bcf1ece0a..3251d66fd 100644 --- a/safe_transaction_service/account_abstraction/tests/services/test_aa_processor_service.py +++ b/safe_transaction_service/account_abstraction/tests/services/test_aa_processor_service.py @@ -152,22 +152,32 @@ def test_process_aa_transaction_entrypoint_V07( get_user_operation_receipt_mock: MagicMock, ): """ - Entrypoint v0.7.0 endpoints should be ignored + Entrypoint v0.7.0 endpoints should be ignored when not in supported list """ - ethereum_tx = history_factories.EthereumTxFactory( - logs=[clean_receipt_log(log) for log in aa_tx_receipt_mock["logs"]] - ) - with self.assertRaisesMessage( - UserOperationNotSupportedException, "for EntryPoint v0.7.0 is not supported" + # Override settings to only support v0.6 + with self.settings( + ETHEREUM_4337_SUPPORTED_ENTRY_POINTS=[ + "0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789" + ] ): - self.aa_processor_service.index_user_operation( - Account.create().address, # Not relevant - user_operation_v07_hash, - ethereum_tx, + # Reinitialize service with new settings + get_aa_processor_service.cache_clear() + aa_processor_service = get_aa_processor_service() + + ethereum_tx = history_factories.EthereumTxFactory( + logs=[clean_receipt_log(log) for log in aa_tx_receipt_mock["logs"]] ) + with self.assertRaisesMessage( + UserOperationNotSupportedException, "Entrypoint is not supported" + ): + aa_processor_service.index_user_operation( + Account.create().address, # Not relevant + user_operation_v07_hash, + ethereum_tx, + ) - self.aa_processor_service.process_aa_transaction(aa_safe_address, ethereum_tx) - self.assertEqual(UserOperationModel.objects.count(), 0) - self.assertEqual(SafeOperationModel.objects.count(), 0) - self.assertEqual(UserOperationReceiptModel.objects.count(), 0) - self.assertEqual(SafeOperationConfirmationModel.objects.count(), 0) + aa_processor_service.process_aa_transaction(aa_safe_address, ethereum_tx) + self.assertEqual(UserOperationModel.objects.count(), 0) + self.assertEqual(SafeOperationModel.objects.count(), 0) + self.assertEqual(UserOperationReceiptModel.objects.count(), 0) + self.assertEqual(SafeOperationConfirmationModel.objects.count(), 0) diff --git a/safe_transaction_service/account_abstraction/tests/test_serializers.py b/safe_transaction_service/account_abstraction/tests/test_serializers.py new file mode 100644 index 000000000..4191c458c --- /dev/null +++ b/safe_transaction_service/account_abstraction/tests/test_serializers.py @@ -0,0 +1,1265 @@ +import datetime +from unittest import mock +from unittest.mock import MagicMock + +from django.conf import settings +from django.test import TestCase +from django.utils import timezone + +from eth_account import Account +from hexbytes import HexBytes +from safe_eth.eth import EthereumClient +from safe_eth.eth.tests.mocks.mock_bundler import ( + safe_4337_address, + safe_4337_chain_id_mock, + safe_4337_module_address_mock, + user_operation_v07_chain_id, +) +from safe_eth.eth.tests.mocks.mock_bundler import ( + user_operation_v07_mock_1 as user_operation_v07_mock, +) +from safe_eth.safe.safe_signature import SafeSignature, SafeSignatureType +from safe_eth.util.util import to_0x_hex_str + +from safe_transaction_service.history.tests.factories import EthereumTxFactory + +from .. import models +from ..serializers import SafeOperationSerializer +from . import factories + + +@mock.patch("safe_eth.eth.get_auto_ethereum_client") +class TestSafeOperationSerializer(TestCase): + """Tests for SafeOperationSerializer""" + + def test_serializer_invalid_module_address( + self, get_auto_ethereum_client_mock: MagicMock + ): + """Test that invalid module address is rejected""" + account = Account.create() + safe_address = Account.create().address + invalid_module = Account.create().address + + data = { + "nonce": 0, + "call_data": "0x1234", + "call_gas_limit": 100000, + "verification_gas_limit": 100000, + "pre_verification_gas": 50000, + "max_fee_per_gas": 1000000000, + "max_priority_fee_per_gas": 1000000000, + "signature": to_0x_hex_str( + account.unsafe_sign_hash(HexBytes("0x" + "00" * 32))["signature"] + ), + "entry_point": settings.ETHEREUM_4337_ENTRYPOINT_V6, + "valid_after": None, + "valid_until": None, + "module_address": invalid_module, + } + + serializer = SafeOperationSerializer( + data=data, context={"safe_address": safe_address} + ) + self.assertFalse(serializer.is_valid()) + self.assertIn("module_address", serializer.errors) + + @mock.patch.object(EthereumClient, "is_contract", return_value=True) + def test_serializer_module_entrypoint_mismatch( + self, + is_contract_mock: MagicMock, + get_auto_ethereum_client_mock: MagicMock, + ): + """Test that module address must match entry point version""" + account = Account.create() + safe_address = Account.create().address + + # Use v6 entry point with v7 module address + data = { + "nonce": 0, + "call_data": "0x1234", + "call_gas_limit": 100000, + "verification_gas_limit": 100000, + "pre_verification_gas": 50000, + "max_fee_per_gas": 1000000000, + "max_priority_fee_per_gas": 1000000000, + "signature": to_0x_hex_str( + account.unsafe_sign_hash(HexBytes("0x" + "00" * 32))["signature"] + ), + "entry_point": settings.ETHEREUM_4337_ENTRYPOINT_V6, + "valid_after": None, + "valid_until": None, + "module_address": settings.ETHEREUM_4337_SAFE_MODULE_ADDRESS_V07, # Wrong version + } + + serializer = SafeOperationSerializer( + data=data, context={"safe_address": safe_address} + ) + self.assertFalse(serializer.is_valid()) + self.assertIn("non_field_errors", serializer.errors) + self.assertIn("Invalid Module address", str(serializer.errors)) + + @mock.patch.object(EthereumClient, "is_contract", return_value=True) + @mock.patch.object( + EthereumClient, + "get_chain_id", + autospec=True, + return_value=safe_4337_chain_id_mock, + ) + def test_serializer_v6_rejects_factory_field( + self, + get_chain_id_mock: MagicMock, + is_contract_mock: MagicMock, + get_auto_ethereum_client_mock: MagicMock, + ): + """Test that factory field (v7) is rejected for v6 entry point""" + account = Account.create() + safe_address = safe_4337_address + + data = { + "nonce": 0, + "init_code": None, + "call_data": "0x1234", + "call_gas_limit": 100000, + "verification_gas_limit": 100000, + "pre_verification_gas": 50000, + "max_fee_per_gas": 1000000000, + "max_priority_fee_per_gas": 1000000000, + "paymaster_and_data": None, + "signature": to_0x_hex_str( + account.unsafe_sign_hash(HexBytes("0x" + "00" * 32))["signature"] + ), + "entry_point": settings.ETHEREUM_4337_ENTRYPOINT_V6, + "valid_after": None, + "valid_until": None, + "module_address": safe_4337_module_address_mock, + "factory": Account.create().address, # v7 field - should be rejected + } + + serializer = SafeOperationSerializer( + data=data, context={"safe_address": safe_address} + ) + self.assertFalse(serializer.is_valid()) + self.assertIn("non_field_errors", serializer.errors) + self.assertIn("factory", str(serializer.errors).lower()) + + @mock.patch.object(EthereumClient, "is_contract", return_value=True) + @mock.patch.object( + EthereumClient, + "get_chain_id", + autospec=True, + return_value=safe_4337_chain_id_mock, + ) + def test_serializer_v6_rejects_paymaster_field( + self, + get_chain_id_mock: MagicMock, + is_contract_mock: MagicMock, + get_auto_ethereum_client_mock: MagicMock, + ): + """Test that paymaster field (v7) is rejected for v6 entry point""" + account = Account.create() + safe_address = safe_4337_address + + data = { + "nonce": 0, + "init_code": None, + "call_data": "0x1234", + "call_gas_limit": 100000, + "verification_gas_limit": 100000, + "pre_verification_gas": 50000, + "max_fee_per_gas": 1000000000, + "max_priority_fee_per_gas": 1000000000, + "paymaster_and_data": None, + "signature": to_0x_hex_str( + account.unsafe_sign_hash(HexBytes("0x" + "00" * 32))["signature"] + ), + "entry_point": settings.ETHEREUM_4337_ENTRYPOINT_V6, + "valid_after": None, + "valid_until": None, + "module_address": safe_4337_module_address_mock, + "paymaster": Account.create().address, # v7 field - should be rejected + } + + serializer = SafeOperationSerializer( + data=data, context={"safe_address": safe_address} + ) + self.assertFalse(serializer.is_valid()) + self.assertIn("non_field_errors", serializer.errors) + self.assertIn("Paymaster", str(serializer.errors)) + + @mock.patch.object(EthereumClient, "is_contract", return_value=True) + @mock.patch.object( + EthereumClient, + "get_chain_id", + autospec=True, + return_value=user_operation_v07_chain_id, + ) + def test_serializer_v7_rejects_init_code_field( + self, + get_chain_id_mock: MagicMock, + is_contract_mock: MagicMock, + get_auto_ethereum_client_mock: MagicMock, + ): + """Test that init_code field (v6) is rejected for v7 entry point""" + account = Account.create() + safe_address = user_operation_v07_mock["result"]["userOperation"]["sender"] + + v7_entry_point = settings.ETHEREUM_4337_ENTRYPOINT_V7 + v7_module_address = settings.ETHEREUM_4337_SAFE_MODULE_ADDRESS_V07 + + data = { + "nonce": 0, + "init_code": "0x1234", # v6 field - should be rejected for v7 + "call_data": "0x1234", + "call_gas_limit": 100000, + "verification_gas_limit": 100000, + "pre_verification_gas": 50000, + "max_fee_per_gas": 1000000000, + "max_priority_fee_per_gas": 1000000000, + "signature": to_0x_hex_str( + account.unsafe_sign_hash(HexBytes("0x" + "00" * 32))["signature"] + ), + "entry_point": v7_entry_point, + "valid_after": None, + "valid_until": None, + "module_address": v7_module_address, + } + + serializer = SafeOperationSerializer( + data=data, context={"safe_address": safe_address} + ) + self.assertFalse(serializer.is_valid()) + # init_code validation fails at field level (contract deployed) or validate level (v7) + errors_str = str(serializer.errors).lower() + self.assertTrue( + "init_code" in errors_str, + f"Expected init_code error, got: {serializer.errors}", + ) + + @mock.patch.object(EthereumClient, "is_contract", return_value=True) + @mock.patch.object( + EthereumClient, + "get_chain_id", + autospec=True, + return_value=user_operation_v07_chain_id, + ) + def test_serializer_v7_rejects_paymaster_and_data_field( + self, + get_chain_id_mock: MagicMock, + is_contract_mock: MagicMock, + get_auto_ethereum_client_mock: MagicMock, + ): + """Test that paymaster_and_data field (v6) is rejected for v7 entry point""" + account = Account.create() + safe_address = user_operation_v07_mock["result"]["userOperation"]["sender"] + + v7_entry_point = settings.ETHEREUM_4337_ENTRYPOINT_V7 + v7_module_address = settings.ETHEREUM_4337_SAFE_MODULE_ADDRESS_V07 + + data = { + "nonce": 0, + "paymaster_and_data": "0x" + "00" * 20, # v6 field - should be rejected + "call_data": "0x1234", + "call_gas_limit": 100000, + "verification_gas_limit": 100000, + "pre_verification_gas": 50000, + "max_fee_per_gas": 1000000000, + "max_priority_fee_per_gas": 1000000000, + "signature": to_0x_hex_str( + account.unsafe_sign_hash(HexBytes("0x" + "00" * 32))["signature"] + ), + "entry_point": v7_entry_point, + "valid_after": None, + "valid_until": None, + "module_address": v7_module_address, + } + + serializer = SafeOperationSerializer( + data=data, context={"safe_address": safe_address} + ) + self.assertFalse(serializer.is_valid()) + self.assertIn("non_field_errors", serializer.errors) + self.assertIn("paymaster_and_data", str(serializer.errors).lower()) + + @mock.patch.object( + SafeOperationSerializer, + "_get_owners", + autospec=True, + ) + @mock.patch.object(EthereumClient, "is_contract", return_value=True) + @mock.patch.object( + EthereumClient, + "get_chain_id", + autospec=True, + return_value=safe_4337_chain_id_mock, + ) + def test_serializer_nonce_too_low( + self, + get_chain_id_mock: MagicMock, + is_contract_mock: MagicMock, + get_owners_mock: MagicMock, + get_auto_ethereum_client_mock: MagicMock, + ): + """Test that nonce too low is rejected""" + account = Account.create() + safe_address = safe_4337_address + get_owners_mock.return_value = [account.address] + + # Create existing operation with higher nonce that has been executed + ethereum_tx = EthereumTxFactory() + factories.UserOperationFactory( + nonce=5, + sender=safe_address, + ethereum_tx=ethereum_tx, + ) + + data = { + "nonce": 3, # Lower than existing executed nonce + "call_data": "0x1234", + "call_gas_limit": 100000, + "verification_gas_limit": 100000, + "pre_verification_gas": 50000, + "max_fee_per_gas": 1000000000, + "max_priority_fee_per_gas": 1000000000, + "paymaster_and_data": None, + "signature": to_0x_hex_str( + account.unsafe_sign_hash(HexBytes("0x" + "00" * 32))["signature"] + ), + "entry_point": settings.ETHEREUM_4337_ENTRYPOINT_V6, + "valid_after": None, + "valid_until": None, + "module_address": safe_4337_module_address_mock, + } + + serializer = SafeOperationSerializer( + data=data, context={"safe_address": safe_address} + ) + self.assertFalse(serializer.is_valid()) + self.assertIn("nonce", serializer.errors) + self.assertIn("too low", str(serializer.errors)) + + @mock.patch.object(SafeSignature, "parse_signature") + @mock.patch.object( + SafeOperationSerializer, + "_get_owners", + autospec=True, + ) + @mock.patch.object(EthereumClient, "is_contract", return_value=True) + @mock.patch.object( + EthereumClient, + "get_chain_id", + autospec=True, + return_value=safe_4337_chain_id_mock, + ) + def test_serializer_v6_valid_data( + self, + get_chain_id_mock: MagicMock, + is_contract_mock: MagicMock, + get_owners_mock: MagicMock, + parse_signature_mock: MagicMock, + get_auto_ethereum_client_mock: MagicMock, + ): + """Test SafeOperationSerializer with valid v6 data""" + account = Account.create() + safe_address = safe_4337_address + + get_owners_mock.return_value = [account.address] + + # Create mock signature + mock_safe_signature = MagicMock() + mock_safe_signature.owner = account.address + mock_safe_signature.is_valid.return_value = True + mock_safe_signature.signature = b"\x00" * 65 + parse_signature_mock.return_value = [mock_safe_signature] + + data = { + "nonce": 0, + "init_code": None, + "call_data": "0x1234", + "call_gas_limit": 100000, + "verification_gas_limit": 100000, + "pre_verification_gas": 50000, + "max_fee_per_gas": 1000000000, + "max_priority_fee_per_gas": 1000000000, + "paymaster_and_data": None, + "signature": to_0x_hex_str( + account.unsafe_sign_hash(HexBytes("0x" + "00" * 32))["signature"] + ), + "entry_point": settings.ETHEREUM_4337_ENTRYPOINT_V6, + "valid_after": None, + "valid_until": None, + "module_address": safe_4337_module_address_mock, + } + + serializer = SafeOperationSerializer( + data=data, context={"safe_address": safe_address} + ) + self.assertTrue(serializer.is_valid(), serializer.errors) + + @mock.patch.object(SafeSignature, "parse_signature") + @mock.patch.object( + SafeOperationSerializer, + "_get_owners", + autospec=True, + ) + @mock.patch.object(EthereumClient, "is_contract", return_value=True) + @mock.patch.object( + EthereumClient, + "get_chain_id", + autospec=True, + return_value=safe_4337_chain_id_mock, + ) + def test_serializer_save_v6( + self, + get_chain_id_mock: MagicMock, + is_contract_mock: MagicMock, + get_owners_mock: MagicMock, + parse_signature_mock: MagicMock, + get_auto_ethereum_client_mock: MagicMock, + ): + """Test saving a v6 SafeOperation creates correct models""" + account = Account.create() + safe_address = safe_4337_address + + get_owners_mock.return_value = [account.address] + + # Create mock signature with all required attributes + signature_bytes = account.unsafe_sign_hash(HexBytes("0x" + "00" * 32))[ + "signature" + ] + mock_safe_signature = MagicMock() + mock_safe_signature.owner = account.address + mock_safe_signature.is_valid.return_value = True + mock_safe_signature.signature = signature_bytes + mock_safe_signature.export_signature.return_value = signature_bytes + mock_safe_signature.signature_type = SafeSignatureType.EOA + parse_signature_mock.return_value = [mock_safe_signature] + + data = { + "nonce": 0, + "init_code": None, + "call_data": "0x1234", + "call_gas_limit": 100000, + "verification_gas_limit": 100000, + "pre_verification_gas": 50000, + "max_fee_per_gas": 1000000000, + "max_priority_fee_per_gas": 1000000000, + "paymaster_and_data": None, + "signature": to_0x_hex_str(signature_bytes), + "entry_point": settings.ETHEREUM_4337_ENTRYPOINT_V6, + "valid_after": None, + "valid_until": None, + "module_address": safe_4337_module_address_mock, + } + + serializer = SafeOperationSerializer( + data=data, context={"safe_address": safe_address} + ) + self.assertTrue(serializer.is_valid(), serializer.errors) + + # Save the operation + user_operation_model = serializer.save() + + # Verify models were created + self.assertEqual(models.UserOperation.objects.count(), 1) + self.assertEqual(models.SafeOperation.objects.count(), 1) + + # Verify v6 specific fields + self.assertIsNone(user_operation_model.factory) + self.assertIsNone(user_operation_model.factory_data) + self.assertIsNone(user_operation_model.paymaster_verification_gas_limit) + self.assertIsNone(user_operation_model.paymaster_post_op_gas_limit) + + @mock.patch.object(SafeSignature, "parse_signature") + @mock.patch.object( + SafeOperationSerializer, + "_get_owners", + autospec=True, + ) + @mock.patch.object(EthereumClient, "is_contract", return_value=True) + @mock.patch.object( + EthereumClient, + "get_chain_id", + autospec=True, + return_value=safe_4337_chain_id_mock, + ) + def test_serializer_duplicate_safe_operation( + self, + get_chain_id_mock: MagicMock, + is_contract_mock: MagicMock, + get_owners_mock: MagicMock, + parse_signature_mock: MagicMock, + get_auto_ethereum_client_mock: MagicMock, + ): + """Test that duplicate SafeOperation is rejected""" + account = Account.create() + safe_address = safe_4337_address + + get_owners_mock.return_value = [account.address] + + # Create mock signature with all required attributes + signature_bytes = account.unsafe_sign_hash(HexBytes("0x" + "00" * 32))[ + "signature" + ] + mock_safe_signature = MagicMock() + mock_safe_signature.owner = account.address + mock_safe_signature.is_valid.return_value = True + mock_safe_signature.signature = signature_bytes + mock_safe_signature.export_signature.return_value = signature_bytes + mock_safe_signature.signature_type = SafeSignatureType.EOA + parse_signature_mock.return_value = [mock_safe_signature] + + data = { + "nonce": 0, + "init_code": None, + "call_data": "0x1234", + "call_gas_limit": 100000, + "verification_gas_limit": 100000, + "pre_verification_gas": 50000, + "max_fee_per_gas": 1000000000, + "max_priority_fee_per_gas": 1000000000, + "paymaster_and_data": None, + "signature": to_0x_hex_str(signature_bytes), + "entry_point": settings.ETHEREUM_4337_ENTRYPOINT_V6, + "valid_after": None, + "valid_until": None, + "module_address": safe_4337_module_address_mock, + } + + # First save should succeed + serializer = SafeOperationSerializer( + data=data, context={"safe_address": safe_address} + ) + self.assertTrue(serializer.is_valid(), serializer.errors) + serializer.save() + + # Second save with same data should fail + serializer2 = SafeOperationSerializer( + data=data, context={"safe_address": safe_address} + ) + self.assertFalse(serializer2.is_valid()) + self.assertIn("non_field_errors", serializer2.errors) + self.assertIn("already exists", str(serializer2.errors)) + + @mock.patch.object(SafeSignature, "parse_signature") + @mock.patch.object( + SafeOperationSerializer, + "_get_owners", + autospec=True, + ) + @mock.patch.object(EthereumClient, "is_contract", return_value=True) + @mock.patch.object( + EthereumClient, + "get_chain_id", + autospec=True, + return_value=safe_4337_chain_id_mock, + ) + def test_serializer_invalid_signer( + self, + get_chain_id_mock: MagicMock, + is_contract_mock: MagicMock, + get_owners_mock: MagicMock, + parse_signature_mock: MagicMock, + get_auto_ethereum_client_mock: MagicMock, + ): + """Test that signature from non-owner is rejected""" + account = Account.create() + non_owner = Account.create() + safe_address = safe_4337_address + + # Only account is owner, but signature is from non_owner + get_owners_mock.return_value = [account.address] + + # Create mock signature from non-owner + mock_safe_signature = MagicMock() + mock_safe_signature.owner = non_owner.address # Not an owner! + mock_safe_signature.is_valid.return_value = True + mock_safe_signature.signature = b"\x00" * 65 + parse_signature_mock.return_value = [mock_safe_signature] + + data = { + "nonce": 0, + "init_code": None, + "call_data": "0x1234", + "call_gas_limit": 100000, + "verification_gas_limit": 100000, + "pre_verification_gas": 50000, + "max_fee_per_gas": 1000000000, + "max_priority_fee_per_gas": 1000000000, + "paymaster_and_data": None, + "signature": to_0x_hex_str( + non_owner.unsafe_sign_hash(HexBytes("0x" + "00" * 32))["signature"] + ), + "entry_point": settings.ETHEREUM_4337_ENTRYPOINT_V6, + "valid_after": None, + "valid_until": None, + "module_address": safe_4337_module_address_mock, + } + + serializer = SafeOperationSerializer( + data=data, context={"safe_address": safe_address} + ) + self.assertFalse(serializer.is_valid()) + self.assertIn("non_field_errors", serializer.errors) + self.assertIn("is not an owner", str(serializer.errors)) + + # ==================== Paymaster Tests ==================== + + @mock.patch.object(EthereumClient, "is_contract", return_value=True) + @mock.patch.object( + EthereumClient, + "get_chain_id", + autospec=True, + return_value=safe_4337_chain_id_mock, + ) + def test_serializer_v6_paymaster_and_data_too_short( + self, + get_chain_id_mock: MagicMock, + is_contract_mock: MagicMock, + get_auto_ethereum_client_mock: MagicMock, + ): + """Test that paymaster_and_data shorter than 20 bytes is rejected""" + account = Account.create() + safe_address = safe_4337_address + + data = { + "nonce": 0, + "init_code": None, + "call_data": "0x1234", + "call_gas_limit": 100000, + "verification_gas_limit": 100000, + "pre_verification_gas": 50000, + "max_fee_per_gas": 1000000000, + "max_priority_fee_per_gas": 1000000000, + "paymaster_and_data": "0x" + "00" * 19, # Only 19 bytes - too short + "signature": to_0x_hex_str( + account.unsafe_sign_hash(HexBytes("0x" + "00" * 32))["signature"] + ), + "entry_point": settings.ETHEREUM_4337_ENTRYPOINT_V6, + "valid_after": None, + "valid_until": None, + "module_address": safe_4337_module_address_mock, + } + + serializer = SafeOperationSerializer( + data=data, context={"safe_address": safe_address} + ) + self.assertFalse(serializer.is_valid()) + self.assertIn("paymaster_and_data", serializer.errors) + self.assertIn("at least 20 bytes", str(serializer.errors)) + + @mock.patch.object(EthereumClient, "is_contract") + @mock.patch.object( + EthereumClient, + "get_chain_id", + autospec=True, + return_value=safe_4337_chain_id_mock, + ) + def test_serializer_v6_paymaster_not_contract( + self, + get_chain_id_mock: MagicMock, + is_contract_mock: MagicMock, + get_auto_ethereum_client_mock: MagicMock, + ): + """Test that paymaster address must be a deployed contract""" + account = Account.create() + safe_address = safe_4337_address + paymaster_address = Account.create().address + + # Safe is deployed, but paymaster is not + def is_contract_side_effect(address): + if address.lower() == safe_address.lower(): + return True + return False # Paymaster is not a contract + + is_contract_mock.side_effect = is_contract_side_effect + + # paymaster_and_data = paymaster address (20 bytes) + optional data + paymaster_and_data = paymaster_address.lower().replace("0x", "") + "deadbeef" + + data = { + "nonce": 0, + "init_code": None, + "call_data": "0x1234", + "call_gas_limit": 100000, + "verification_gas_limit": 100000, + "pre_verification_gas": 50000, + "max_fee_per_gas": 1000000000, + "max_priority_fee_per_gas": 1000000000, + "paymaster_and_data": "0x" + paymaster_and_data, + "signature": to_0x_hex_str( + account.unsafe_sign_hash(HexBytes("0x" + "00" * 32))["signature"] + ), + "entry_point": settings.ETHEREUM_4337_ENTRYPOINT_V6, + "valid_after": None, + "valid_until": None, + "module_address": safe_4337_module_address_mock, + } + + serializer = SafeOperationSerializer( + data=data, context={"safe_address": safe_address} + ) + self.assertFalse(serializer.is_valid()) + self.assertIn("paymaster_and_data", serializer.errors) + self.assertIn("not found in blockchain", str(serializer.errors)) + + @mock.patch.object(SafeSignature, "parse_signature") + @mock.patch.object( + SafeOperationSerializer, + "_get_owners", + autospec=True, + ) + @mock.patch.object(EthereumClient, "is_contract", return_value=True) + @mock.patch.object( + EthereumClient, + "get_chain_id", + autospec=True, + return_value=safe_4337_chain_id_mock, + ) + def test_serializer_v6_valid_paymaster_and_data( + self, + get_chain_id_mock: MagicMock, + is_contract_mock: MagicMock, + get_owners_mock: MagicMock, + parse_signature_mock: MagicMock, + get_auto_ethereum_client_mock: MagicMock, + ): + """Test that valid paymaster_and_data is accepted and saved""" + account = Account.create() + safe_address = safe_4337_address + paymaster_address = Account.create().address + + get_owners_mock.return_value = [account.address] + + # Create mock signature + signature_bytes = account.unsafe_sign_hash(HexBytes("0x" + "00" * 32))[ + "signature" + ] + mock_safe_signature = MagicMock() + mock_safe_signature.owner = account.address + mock_safe_signature.is_valid.return_value = True + mock_safe_signature.signature = signature_bytes + mock_safe_signature.export_signature.return_value = signature_bytes + mock_safe_signature.signature_type = SafeSignatureType.EOA + parse_signature_mock.return_value = [mock_safe_signature] + + # paymaster_and_data = paymaster address (20 bytes) + data + paymaster_data = "deadbeef1234" + paymaster_and_data = ( + paymaster_address.lower().replace("0x", "") + paymaster_data + ) + + data = { + "nonce": 0, + "init_code": None, + "call_data": "0x1234", + "call_gas_limit": 100000, + "verification_gas_limit": 100000, + "pre_verification_gas": 50000, + "max_fee_per_gas": 1000000000, + "max_priority_fee_per_gas": 1000000000, + "paymaster_and_data": "0x" + paymaster_and_data, + "signature": to_0x_hex_str(signature_bytes), + "entry_point": settings.ETHEREUM_4337_ENTRYPOINT_V6, + "valid_after": None, + "valid_until": None, + "module_address": safe_4337_module_address_mock, + } + + serializer = SafeOperationSerializer( + data=data, context={"safe_address": safe_address} + ) + self.assertTrue(serializer.is_valid(), serializer.errors) + + # Save and verify paymaster fields + user_operation = serializer.save() + self.assertEqual(user_operation.paymaster.lower(), paymaster_address.lower()) + self.assertEqual(user_operation.paymaster_data, HexBytes("0x" + paymaster_data)) + + @mock.patch.object(SafeSignature, "parse_signature") + @mock.patch.object( + SafeOperationSerializer, + "_get_owners", + autospec=True, + ) + @mock.patch.object(EthereumClient, "is_contract", return_value=True) + @mock.patch.object( + EthereumClient, + "get_chain_id", + autospec=True, + return_value=user_operation_v07_chain_id, + ) + def test_serializer_v7_paymaster_fields_saved( + self, + get_chain_id_mock: MagicMock, + is_contract_mock: MagicMock, + get_owners_mock: MagicMock, + parse_signature_mock: MagicMock, + get_auto_ethereum_client_mock: MagicMock, + ): + """Test that v7 paymaster fields are saved correctly""" + account = Account.create() + safe_address = Account.create().address + paymaster_address = Account.create().address + + get_owners_mock.return_value = [account.address] + + # Create mock signature + signature_bytes = account.unsafe_sign_hash(HexBytes("0x" + "00" * 32))[ + "signature" + ] + mock_safe_signature = MagicMock() + mock_safe_signature.owner = account.address + mock_safe_signature.is_valid.return_value = True + mock_safe_signature.signature = signature_bytes + mock_safe_signature.export_signature.return_value = signature_bytes + mock_safe_signature.signature_type = SafeSignatureType.EOA + parse_signature_mock.return_value = [mock_safe_signature] + + v7_entry_point = settings.ETHEREUM_4337_ENTRYPOINT_V7 + v7_module_address = settings.ETHEREUM_4337_SAFE_MODULE_ADDRESS_V07 + + data = { + "nonce": 0, + "call_data": "0x1234", + "call_gas_limit": 100000, + "verification_gas_limit": 100000, + "pre_verification_gas": 50000, + "max_fee_per_gas": 1000000000, + "max_priority_fee_per_gas": 1000000000, + "factory": None, + "factory_data": None, + "paymaster": paymaster_address, + "paymaster_data": "0xdeadbeef1234", + "paymaster_verification_gas_limit": 75000, + "paymaster_post_op_gas_limit": 25000, + "signature": to_0x_hex_str(signature_bytes), + "entry_point": v7_entry_point, + "valid_after": None, + "valid_until": None, + "module_address": v7_module_address, + } + + serializer = SafeOperationSerializer( + data=data, context={"safe_address": safe_address} + ) + self.assertTrue(serializer.is_valid(), serializer.errors) + + # Save and verify paymaster fields + user_operation = serializer.save() + self.assertEqual(user_operation.paymaster.lower(), paymaster_address.lower()) + self.assertEqual(user_operation.paymaster_data, HexBytes("0xdeadbeef1234")) + self.assertEqual(user_operation.paymaster_verification_gas_limit, 75000) + self.assertEqual(user_operation.paymaster_post_op_gas_limit, 25000) + + @mock.patch.object(EthereumClient, "is_contract", return_value=True) + @mock.patch.object( + EthereumClient, + "get_chain_id", + autospec=True, + return_value=user_operation_v07_chain_id, + ) + def test_serializer_v7_paymaster_data_without_paymaster( + self, + get_chain_id_mock: MagicMock, + is_contract_mock: MagicMock, + get_auto_ethereum_client_mock: MagicMock, + ): + """Test that paymaster_data without paymaster is rejected for v7""" + account = Account.create() + safe_address = Account.create().address + + v7_entry_point = settings.ETHEREUM_4337_ENTRYPOINT_V7 + v7_module_address = settings.ETHEREUM_4337_SAFE_MODULE_ADDRESS_V07 + + data = { + "nonce": 0, + "call_data": "0x1234", + "call_gas_limit": 100000, + "verification_gas_limit": 100000, + "pre_verification_gas": 50000, + "max_fee_per_gas": 1000000000, + "max_priority_fee_per_gas": 1000000000, + "factory": None, + "factory_data": None, + "paymaster": None, # No paymaster + "paymaster_data": "0xdeadbeef", # But paymaster_data is provided + "paymaster_verification_gas_limit": None, + "paymaster_post_op_gas_limit": None, + "signature": to_0x_hex_str( + account.unsafe_sign_hash(HexBytes("0x" + "00" * 32))["signature"] + ), + "entry_point": v7_entry_point, + "valid_after": None, + "valid_until": None, + "module_address": v7_module_address, + } + + serializer = SafeOperationSerializer( + data=data, context={"safe_address": safe_address} + ) + self.assertFalse(serializer.is_valid()) + self.assertIn("non_field_errors", serializer.errors) + self.assertIn("paymaster_data", str(serializer.errors).lower()) + + @mock.patch.object(EthereumClient, "is_contract", return_value=True) + @mock.patch.object( + EthereumClient, + "get_chain_id", + autospec=True, + return_value=safe_4337_chain_id_mock, + ) + def test_serializer_v6_paymaster_verification_gas_limit_rejected( + self, + get_chain_id_mock: MagicMock, + is_contract_mock: MagicMock, + get_auto_ethereum_client_mock: MagicMock, + ): + """Test that v7 paymaster gas fields are rejected for v6""" + account = Account.create() + safe_address = safe_4337_address + + data = { + "nonce": 0, + "init_code": None, + "call_data": "0x1234", + "call_gas_limit": 100000, + "verification_gas_limit": 100000, + "pre_verification_gas": 50000, + "max_fee_per_gas": 1000000000, + "max_priority_fee_per_gas": 1000000000, + "paymaster_and_data": None, + "paymaster_verification_gas_limit": 50000, # v7 field + "signature": to_0x_hex_str( + account.unsafe_sign_hash(HexBytes("0x" + "00" * 32))["signature"] + ), + "entry_point": settings.ETHEREUM_4337_ENTRYPOINT_V6, + "valid_after": None, + "valid_until": None, + "module_address": safe_4337_module_address_mock, + } + + serializer = SafeOperationSerializer( + data=data, context={"safe_address": safe_address} + ) + self.assertFalse(serializer.is_valid()) + self.assertIn("non_field_errors", serializer.errors) + self.assertIn("paymaster", str(serializer.errors).lower()) + + # ==================== Valid After / Valid Until Tests ==================== + + @mock.patch.object(EthereumClient, "is_contract", return_value=True) + @mock.patch.object( + EthereumClient, + "get_chain_id", + autospec=True, + return_value=safe_4337_chain_id_mock, + ) + def test_serializer_valid_until_in_past_rejected( + self, + get_chain_id_mock: MagicMock, + is_contract_mock: MagicMock, + get_auto_ethereum_client_mock: MagicMock, + ): + """Test that valid_until in the past is rejected""" + account = Account.create() + safe_address = safe_4337_address + + # Set valid_until to 1 hour in the past + past_time = timezone.now() - datetime.timedelta(hours=1) + + data = { + "nonce": 0, + "init_code": None, + "call_data": "0x1234", + "call_gas_limit": 100000, + "verification_gas_limit": 100000, + "pre_verification_gas": 50000, + "max_fee_per_gas": 1000000000, + "max_priority_fee_per_gas": 1000000000, + "paymaster_and_data": None, + "signature": to_0x_hex_str( + account.unsafe_sign_hash(HexBytes("0x" + "00" * 32))["signature"] + ), + "entry_point": settings.ETHEREUM_4337_ENTRYPOINT_V6, + "valid_after": None, + "valid_until": past_time.isoformat(), + "module_address": safe_4337_module_address_mock, + } + + serializer = SafeOperationSerializer( + data=data, context={"safe_address": safe_address} + ) + self.assertFalse(serializer.is_valid()) + self.assertIn("valid_until", serializer.errors) + self.assertIn( + "cannot be previous to the current timestamp", str(serializer.errors) + ) + + @mock.patch.object(EthereumClient, "is_contract", return_value=True) + @mock.patch.object( + EthereumClient, + "get_chain_id", + autospec=True, + return_value=safe_4337_chain_id_mock, + ) + def test_serializer_valid_after_greater_than_valid_until_rejected( + self, + get_chain_id_mock: MagicMock, + is_contract_mock: MagicMock, + get_auto_ethereum_client_mock: MagicMock, + ): + """Test that valid_after greater than valid_until is rejected""" + account = Account.create() + safe_address = safe_4337_address + + # Set valid_after to be after valid_until + valid_until = timezone.now() + datetime.timedelta(hours=1) + valid_after = timezone.now() + datetime.timedelta(hours=2) + + data = { + "nonce": 0, + "init_code": None, + "call_data": "0x1234", + "call_gas_limit": 100000, + "verification_gas_limit": 100000, + "pre_verification_gas": 50000, + "max_fee_per_gas": 1000000000, + "max_priority_fee_per_gas": 1000000000, + "paymaster_and_data": None, + "signature": to_0x_hex_str( + account.unsafe_sign_hash(HexBytes("0x" + "00" * 32))["signature"] + ), + "entry_point": settings.ETHEREUM_4337_ENTRYPOINT_V6, + "valid_after": valid_after.isoformat(), + "valid_until": valid_until.isoformat(), + "module_address": safe_4337_module_address_mock, + } + + serializer = SafeOperationSerializer( + data=data, context={"safe_address": safe_address} + ) + self.assertFalse(serializer.is_valid()) + self.assertIn("non_field_errors", serializer.errors) + self.assertIn("valid_after", str(serializer.errors).lower()) + self.assertIn("cannot be higher than", str(serializer.errors).lower()) + + @mock.patch.object(SafeSignature, "parse_signature") + @mock.patch.object( + SafeOperationSerializer, + "_get_owners", + autospec=True, + ) + @mock.patch.object(EthereumClient, "is_contract", return_value=True) + @mock.patch.object( + EthereumClient, + "get_chain_id", + autospec=True, + return_value=safe_4337_chain_id_mock, + ) + def test_serializer_valid_after_and_valid_until_saved( + self, + get_chain_id_mock: MagicMock, + is_contract_mock: MagicMock, + get_owners_mock: MagicMock, + parse_signature_mock: MagicMock, + get_auto_ethereum_client_mock: MagicMock, + ): + """Test that valid_after and valid_until are saved correctly""" + account = Account.create() + safe_address = safe_4337_address + + get_owners_mock.return_value = [account.address] + + # Create mock signature + signature_bytes = account.unsafe_sign_hash(HexBytes("0x" + "00" * 32))[ + "signature" + ] + mock_safe_signature = MagicMock() + mock_safe_signature.owner = account.address + mock_safe_signature.is_valid.return_value = True + mock_safe_signature.signature = signature_bytes + mock_safe_signature.export_signature.return_value = signature_bytes + mock_safe_signature.signature_type = SafeSignatureType.EOA + parse_signature_mock.return_value = [mock_safe_signature] + + # Set valid times in the future + valid_after = timezone.now() + datetime.timedelta(hours=1) + valid_until = timezone.now() + datetime.timedelta(hours=2) + + data = { + "nonce": 0, + "init_code": None, + "call_data": "0x1234", + "call_gas_limit": 100000, + "verification_gas_limit": 100000, + "pre_verification_gas": 50000, + "max_fee_per_gas": 1000000000, + "max_priority_fee_per_gas": 1000000000, + "paymaster_and_data": None, + "signature": to_0x_hex_str(signature_bytes), + "entry_point": settings.ETHEREUM_4337_ENTRYPOINT_V6, + "valid_after": valid_after.isoformat(), + "valid_until": valid_until.isoformat(), + "module_address": safe_4337_module_address_mock, + } + + serializer = SafeOperationSerializer( + data=data, context={"safe_address": safe_address} + ) + self.assertTrue(serializer.is_valid(), serializer.errors) + + # Save and verify the values + user_operation = serializer.save() + safe_operation = user_operation.safe_operation + + # Compare timestamps (allowing for minor differences due to serialization) + self.assertIsNotNone(safe_operation.valid_after) + self.assertIsNotNone(safe_operation.valid_until) + self.assertAlmostEqual( + safe_operation.valid_after.timestamp(), + valid_after.timestamp(), + delta=1, # Allow 1 second difference + ) + self.assertAlmostEqual( + safe_operation.valid_until.timestamp(), + valid_until.timestamp(), + delta=1, + ) + + @mock.patch.object(SafeSignature, "parse_signature") + @mock.patch.object( + SafeOperationSerializer, + "_get_owners", + autospec=True, + ) + @mock.patch.object(EthereumClient, "is_contract", return_value=True) + @mock.patch.object( + EthereumClient, + "get_chain_id", + autospec=True, + return_value=safe_4337_chain_id_mock, + ) + def test_serializer_valid_after_only( + self, + get_chain_id_mock: MagicMock, + is_contract_mock: MagicMock, + get_owners_mock: MagicMock, + parse_signature_mock: MagicMock, + get_auto_ethereum_client_mock: MagicMock, + ): + """Test that valid_after alone is accepted""" + account = Account.create() + safe_address = safe_4337_address + + get_owners_mock.return_value = [account.address] + + # Create mock signature + signature_bytes = account.unsafe_sign_hash(HexBytes("0x" + "00" * 32))[ + "signature" + ] + mock_safe_signature = MagicMock() + mock_safe_signature.owner = account.address + mock_safe_signature.is_valid.return_value = True + mock_safe_signature.signature = signature_bytes + mock_safe_signature.export_signature.return_value = signature_bytes + mock_safe_signature.signature_type = SafeSignatureType.EOA + parse_signature_mock.return_value = [mock_safe_signature] + + valid_after = timezone.now() + datetime.timedelta(hours=1) + + data = { + "nonce": 0, + "init_code": None, + "call_data": "0x1234", + "call_gas_limit": 100000, + "verification_gas_limit": 100000, + "pre_verification_gas": 50000, + "max_fee_per_gas": 1000000000, + "max_priority_fee_per_gas": 1000000000, + "paymaster_and_data": None, + "signature": to_0x_hex_str(signature_bytes), + "entry_point": settings.ETHEREUM_4337_ENTRYPOINT_V6, + "valid_after": valid_after.isoformat(), + "valid_until": None, + "module_address": safe_4337_module_address_mock, + } + + serializer = SafeOperationSerializer( + data=data, context={"safe_address": safe_address} + ) + self.assertTrue(serializer.is_valid(), serializer.errors) + + user_operation = serializer.save() + safe_operation = user_operation.safe_operation + + self.assertIsNotNone(safe_operation.valid_after) + self.assertIsNone(safe_operation.valid_until) + + @mock.patch.object(SafeSignature, "parse_signature") + @mock.patch.object( + SafeOperationSerializer, + "_get_owners", + autospec=True, + ) + @mock.patch.object(EthereumClient, "is_contract", return_value=True) + @mock.patch.object( + EthereumClient, + "get_chain_id", + autospec=True, + return_value=safe_4337_chain_id_mock, + ) + def test_serializer_valid_until_only( + self, + get_chain_id_mock: MagicMock, + is_contract_mock: MagicMock, + get_owners_mock: MagicMock, + parse_signature_mock: MagicMock, + get_auto_ethereum_client_mock: MagicMock, + ): + """Test that valid_until alone is accepted""" + account = Account.create() + safe_address = safe_4337_address + + get_owners_mock.return_value = [account.address] + + # Create mock signature + signature_bytes = account.unsafe_sign_hash(HexBytes("0x" + "00" * 32))[ + "signature" + ] + mock_safe_signature = MagicMock() + mock_safe_signature.owner = account.address + mock_safe_signature.is_valid.return_value = True + mock_safe_signature.signature = signature_bytes + mock_safe_signature.export_signature.return_value = signature_bytes + mock_safe_signature.signature_type = SafeSignatureType.EOA + parse_signature_mock.return_value = [mock_safe_signature] + + valid_until = timezone.now() + datetime.timedelta(hours=1) + + data = { + "nonce": 0, + "init_code": None, + "call_data": "0x1234", + "call_gas_limit": 100000, + "verification_gas_limit": 100000, + "pre_verification_gas": 50000, + "max_fee_per_gas": 1000000000, + "max_priority_fee_per_gas": 1000000000, + "paymaster_and_data": None, + "signature": to_0x_hex_str(signature_bytes), + "entry_point": settings.ETHEREUM_4337_ENTRYPOINT_V6, + "valid_after": None, + "valid_until": valid_until.isoformat(), + "module_address": safe_4337_module_address_mock, + } + + serializer = SafeOperationSerializer( + data=data, context={"safe_address": safe_address} + ) + self.assertTrue(serializer.is_valid(), serializer.errors) + + user_operation = serializer.save() + safe_operation = user_operation.safe_operation + + self.assertIsNone(safe_operation.valid_after) + self.assertIsNotNone(safe_operation.valid_until)