Skip to content
Draft
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
4cf76e5
Add EntryPoint v0.7 and v0.6 constants
sherifahmed990 Oct 30, 2025
a6e2c4e
Add v0.7 entrypoint and module addresses to default settings
sherifahmed990 Oct 30, 2025
b0d76f9
Add UserOperationV7 class for entrypoint v0.7 support
sherifahmed990 Oct 30, 2025
2a7b386
Add SafeOperation wrapper class to oveeride from_user_operation to ac…
sherifahmed990 Oct 30, 2025
e161f56
Add database migration for entrypoint v0.7 support
sherifahmed990 Oct 30, 2025
8ff1f96
Update class UserOperation to support both v0.6 and v0.7 useroperations
sherifahmed990 Oct 30, 2025
f0bfdc2
Update serializer classes to support v0.7 useroperation
sherifahmed990 Oct 30, 2025
99c9dc5
Add support for useroperation v7 to index_user_operation
sherifahmed990 Oct 30, 2025
d52bbab
Use the new SafeOperation wrapper class for tests
sherifahmed990 Oct 30, 2025
c3f5cf4
Override settings to only support v0.6 for test_process_aa_transactio…
sherifahmed990 Oct 30, 2025
be1e1b2
Merge branch 'safe-global:main' into entrypointv7
sherifahmed990 Oct 30, 2025
c6ca161
Merge branch 'safe-global:main' into entrypointv7
sherifahmed990 Dec 6, 2025
1a03408
Merge branch 'safe-global:main' into entrypointv7
sherifahmed990 Dec 11, 2025
808007c
Bump safe-eth-py from 7.14.0 to 7.17.0
sherifahmed990 Dec 6, 2025
cfcfca1
import ETHEREUM_4337_ENTRYPOINT_V6 &v7 from base.py
sherifahmed990 Dec 12, 2025
eaae31a
import SafeOperation and UserOperationV07 from safe-eth-py 7.17.0
sherifahmed990 Dec 12, 2025
f097ccd
update comment
sherifahmed990 Dec 12, 2025
2b851a0
fix: import user_operation_v07_hash_1 and user_operation_v07_mock_1 f…
sherifahmed990 Dec 12, 2025
d8aee49
fix: ETHEREUM_4337_ENTRYPOINT_V6 and ETHEREUM_4337_ENTRYPOINT_V7
sherifahmed990 Dec 12, 2025
c2e4b16
Merge branch 'main' into entrypointv7
sherifahmed990 Jan 5, 2026
fcc9da4
Merge branch 'safe-global:main' into entrypointv7
sherifahmed990 Jan 14, 2026
0934612
use parentheses instead of brackets for validated_data.get()
sherifahmed990 Jan 6, 2026
96e7b5c
Add paymaster and paymaster_data fields for v7 fields
sherifahmed990 Jan 14, 2026
4965128
fix validation and handling for some fields
sherifahmed990 Jan 16, 2026
fa4aa7a
Add tests for SafeOperationSerializer including valid_after and valid…
sherifahmed990 Jan 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
from eth_typing import ChecksumAddress, HexAddress, HexStr

from safe_transaction_service import __version__
from safe_transaction_service.account_abstraction.constants import (
ENTRYPOINT_V6,
ENTRYPOINT_V7,
)
from safe_transaction_service.loggers.custom_logger import SafeJsonFormatter

from ..gunicorn import (
Expand Down Expand Up @@ -539,11 +543,17 @@
ETHEREUM_4337_BUNDLER_URL = env("ETHEREUM_4337_BUNDLER_URL", default=None)
ETHEREUM_4337_SUPPORTED_ENTRY_POINTS = env.list(
"ETHEREUM_4337_SUPPORTED_ENTRY_POINTS",
default=["0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789"],
default=[ENTRYPOINT_V6, ENTRYPOINT_V7],
)
# Module addresses
SAFE_4337_MODULE_ADDRESS_V06 = "0xa581c4A4DB7175302464fF3C06380BC3270b4037"
SAFE_4337_MODULE_ADDRESS_V07 = "0x75cf11467937ce3F2f357CE24ffc3DBF8fD5c226"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Being picky:

Suggested change
SAFE_4337_MODULE_ADDRESS_V06 = "0xa581c4A4DB7175302464fF3C06380BC3270b4037"
SAFE_4337_MODULE_ADDRESS_V07 = "0x75cf11467937ce3F2f357CE24ffc3DBF8fD5c226"
ETHEREUM_4337_SAFE_MODULE_ADDRESS_V06 = "0xa581c4A4DB7175302464fF3C06380BC3270b4037"
ETHEREUM_4337_SAFE_MODULE_ADDRESS_V07 = "0x75cf11467937ce3F2f357CE24ffc3DBF8fD5c226"

So all the settings related to 4337 start the same.

Also hardcoding shouldn't be done, it needs to be defined using environment configuration variables as the other configuration (using env.str)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok

ETHEREUM_4337_SUPPORTED_SAFE_MODULES = env.list(
"ETHEREUM_4337_SUPPORTED_SAFE_MODULES",
default=["0xa581c4A4DB7175302464fF3C06380BC3270b4037"],
default=[
SAFE_4337_MODULE_ADDRESS_V06, # v0.6 Safe4337Module
SAFE_4337_MODULE_ADDRESS_V07, # v0.7 Safe4337Module
],
)

# Tracing indexing configuration (not useful for L2 indexing)
Expand Down
26 changes: 26 additions & 0 deletions safe_transaction_service/account_abstraction/SafeOperation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from safe_eth.eth.account_abstraction import UserOperation as UserOperationV6
from safe_eth.eth.utils import fast_keccak
from safe_eth.safe.account_abstraction import SafeOperation as SafeOperationClass

from safe_transaction_service.account_abstraction.UserOperationV7 import UserOperationV7
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UserOperationV7 should be part of safe-eth-py. I understand for a draft is ok to be here, but for the merge both classes should be together in safe-eth-py (I will review that PR, just let me know πŸ˜ƒ )

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok



class SafeOperation(SafeOperationClass):
@classmethod
def from_user_operation(cls, user_operation: UserOperationV6 | UserOperationV7):
return cls(
user_operation.sender,
user_operation.nonce,
fast_keccak(user_operation.init_code),
fast_keccak(user_operation.call_data),
user_operation.call_gas_limit,
user_operation.verification_gas_limit,
user_operation.pre_verification_gas,
user_operation.max_fee_per_gas,
user_operation.max_priority_fee_per_gas,
fast_keccak(user_operation.paymaster_and_data),
int.from_bytes(user_operation.signature[:6], byteorder="big"),
int.from_bytes(user_operation.signature[6:12], byteorder="big"),
user_operation.entry_point,
user_operation.signature[12:],
)
115 changes: 115 additions & 0 deletions safe_transaction_service/account_abstraction/UserOperationV7.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import dataclasses

from eth_abi import encode as abi_encode
from eth_typing import ChecksumAddress
from hexbytes import HexBytes
from safe_eth.eth.account_abstraction.user_operation import UserOperationMetadata
from safe_eth.eth.utils import fast_keccak


@dataclasses.dataclass(eq=True, frozen=True)
class UserOperationV7:
"""
EIP4337 UserOperation for Entrypoint v0.7

https://github.com/eth-infinitism/account-abstraction/blob/v0.7.0/contracts/interfaces/PackedUserOperation.sol
"""

user_operation_hash: bytes
sender: ChecksumAddress
nonce: int
call_data: bytes
call_gas_limit: int
verification_gas_limit: int
pre_verification_gas: int
max_priority_fee_per_gas: int
max_fee_per_gas: int
signature: bytes
entry_point: ChecksumAddress
factory: ChecksumAddress | None = None
factory_data: bytes | None = None
paymaster_verification_gas_limit: int | None = None
paymaster_post_op_gas_limit: int | None = None
paymaster: bytes | None = None
paymaster_data: bytes | None = None
metadata: UserOperationMetadata | None = None

@property
def init_code(self) -> bytes:
"""
Returns the raw init_code bytes (factory address + factory_data).
For v0.7, this is the concatenation of factory and factory_data.
"""
if self.factory is not None and self.factory_data is not None:
return HexBytes(self.factory) + self.factory_data
else:
return b""

@property
def account_gas_limits(self) -> bytes:
"""
:return:Account Gas Limits is a `bytes32` in Solidity, first `bytes16` `verification_gas_limit` and then `call_gas_limit`
"""
return HexBytes(self.verification_gas_limit).rjust(16, b"\x00") + HexBytes(
self.call_gas_limit
).rjust(16, b"\x00")

@property
def gas_fees(self) -> bytes:
"""
:return: Gas Fees is a `bytes32` in Solidity, first `bytes16` `verification_gas_limit` and then `call_gas_limit`
"""
return HexBytes(self.max_priority_fee_per_gas).rjust(16, b"\x00") + HexBytes(
self.max_fee_per_gas
).rjust(16, b"\x00")

@property
def paymaster_and_data(self) -> bytes:
if (
not self.paymaster
or not self.paymaster_verification_gas_limit
or not self.paymaster_post_op_gas_limit
or not self.paymaster_data
):
return b""
return (
HexBytes(self.paymaster).rjust(20, b"\x00")
+ HexBytes(self.paymaster_verification_gas_limit).rjust(16, b"\x00")
+ HexBytes(self.paymaster_post_op_gas_limit).rjust(16, b"\x00")
+ HexBytes(self.paymaster_data)
)

def calculate_user_operation_hash(self, chain_id: int) -> bytes:
hash_init_code = (
fast_keccak(self.init_code) if self.init_code else fast_keccak(b"")
)
hash_call_data = fast_keccak(self.call_data)
hash_paymaster_and_data = fast_keccak(self.paymaster_and_data)
user_operation_encoded = abi_encode(
[
"address",
"uint256",
"bytes32",
"bytes32",
"bytes32",
"uint256",
"bytes32",
"bytes32",
],
[
self.sender,
self.nonce,
hash_init_code,
hash_call_data,
self.account_gas_limits,
self.pre_verification_gas,
self.gas_fees,
hash_paymaster_and_data,
],
)
return fast_keccak(
abi_encode(
["bytes32", "address", "uint256"],
[fast_keccak(user_operation_encoded), self.entry_point, chain_id],
)
)
2 changes: 2 additions & 0 deletions safe_transaction_service/account_abstraction/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,5 @@
USER_OPERATION_EVENT_TOPIC = HexBytes(
"0x49628fd1471006c1482da88028e9ce4dbb080b815c9b0344d39e5a8e6ec1419f"
)
ENTRYPOINT_V6 = "0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789"
ENTRYPOINT_V7 = "0x0000000071727De22E5E9d8BAf0edAc6f37da032"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No hardcoding, read this from base.py

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok

Original file line number Diff line number Diff line change
@@ -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'),
),
]
116 changes: 96 additions & 20 deletions safe_transaction_service/account_abstraction/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,26 @@
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.django.models import (
EthereumAddressBinaryField,
HexV2Field,
Keccak256Field,
Uint256Field,
)
from safe_eth.safe.account_abstraction import SafeOperation as SafeOperationClass
from safe_eth.eth.utils import fast_to_checksum_address
from safe_eth.safe.safe_signature import SafeSignatureType
from safe_eth.util.util import to_0x_hex_str

from safe_transaction_service.account_abstraction.constants import (
ENTRYPOINT_V6,
ENTRYPOINT_V7,
)
from safe_transaction_service.account_abstraction.SafeOperation import (
SafeOperation as SafeOperationClass,
)
from safe_transaction_service.account_abstraction.UserOperationV7 import UserOperationV7
from safe_transaction_service.history import models as history_models
from safe_transaction_service.utils.constants import SIGNATURE_LENGTH

Expand Down Expand Up @@ -52,10 +60,49 @@ class UserOperation(models.Model):
signature = models.BinaryField(null=True, blank=True, editable=True)
entry_point = EthereumAddressBinaryField(db_index=True)

# useroperation v7 specific fields
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}"
Expand All @@ -65,7 +112,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

Expand All @@ -86,23 +135,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() == 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() == 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")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do entrypoints have the same address for every network? Remember that we run tx service for a lot of different networks, and it needs to be compatible with them.

There's no other way to tell appart v6 from v7? I would prefer to check a field that exists on v7 and not on v6, for example. Checking entrypoint address is a good solution for your use case, but not for the service

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All the 4337 tooling that i know of assumes the official entrypoints addresses.
eth-infinitism/account-abstraction#372 (comment)
I think it is safe to only support the official entrypoints addresses.

Because we are using the same class for both useroperation types, a useroperation without init/factory and without paymaster would be identical for both v6 and v7(except for the entrypoint address).
Also v7, v8 and v9 have the same useroperation structure.
So i think using the entrypoint address is the only option with the current structure.
I added some model constrains as sanity checks to verify that some values should be none for each useroperation type, i can repeat these sanity checks here too.


def to_safe_operation(self) -> SafeOperationClass:
"""
Expand Down
Loading