Skip to content

Commit

Permalink
Refactor UserOperationReceipt into dataclass (#831)
Browse files Browse the repository at this point in the history
- That way we can add custom methods
- Add new methods for UserOperationReceipt
  - Calculate deposit to the entrypoint
  - Get Safe module address used
  • Loading branch information
Uxio0 authored Mar 14, 2024
1 parent 72b7ed6 commit ba8a613
Show file tree
Hide file tree
Showing 10 changed files with 163 additions and 382 deletions.
3 changes: 2 additions & 1 deletion gnosis/eth/account_abstraction/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@
BundlerClientException,
BundlerClientResponseException,
)
from .user_operation import UserOperation, UserOperationMetadata, UserOperationReceipt
from .user_operation import UserOperation, UserOperationMetadata
from .user_operation_receipt import UserOperationReceipt
35 changes: 27 additions & 8 deletions gnosis/eth/account_abstraction/bundler_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union

from eth_typing import ChecksumAddress, HexStr
from hexbytes import HexBytes

from gnosis.util.http import prepare_http_session

from .exceptions import BundlerClientConnectionException, BundlerClientResponseException
from .user_operation import UserOperation, UserOperationReceipt
from .user_operation import UserOperation
from .user_operation_receipt import UserOperationReceipt

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -71,9 +73,18 @@ def _do_request(
def _parse_user_operation_receipt(
user_operation_receipt: Dict[str, Any]
) -> UserOperationReceipt:
for field in ["nonce", "actualGasCost", "actualGasUsed"]:
user_operation_receipt[field] = int(user_operation_receipt[field], 16)
return user_operation_receipt
return UserOperationReceipt(
HexBytes(user_operation_receipt["userOpHash"]),
user_operation_receipt["entryPoint"],
user_operation_receipt["sender"],
int(user_operation_receipt["nonce"], 16),
user_operation_receipt["paymaster"],
int(user_operation_receipt["actualGasCost"], 16),
int(user_operation_receipt["actualGasUsed"], 16),
user_operation_receipt["success"],
user_operation_receipt["reason"],
user_operation_receipt["logs"],
)

@staticmethod
def _get_user_operation_by_hash_payload(
Expand Down Expand Up @@ -105,7 +116,9 @@ def get_user_operation_by_hash(
https://docs.alchemy.com/reference/eth-getuseroperationbyhash
:param user_operation_hash:
:return:
:return: ``UserOperation`` or ``None`` if not found
:raises BundlerClientConnectionException:
:raises BundlerClientResponseException:
"""
payload = self._get_user_operation_by_hash_payload(user_operation_hash)
result = self._do_request(payload)
Expand All @@ -123,11 +136,13 @@ def get_user_operation_receipt(
https://docs.alchemy.com/reference/eth-getuseroperationreceipt
:param user_operation_hash:
:return:
:return: ``UserOperationReceipt`` or ``None`` if not found
:raises BundlerClientConnectionException:
:raises BundlerClientResponseException:
"""
payload = self._get_user_operation_receipt_payload(user_operation_hash)
result = self._do_request(payload)
return self._parse_user_operation_receipt(result) if result else None
return UserOperationReceipt.from_bundler_response(result) if result else None

@lru_cache(maxsize=1024)
def get_user_operation_and_receipt(
Expand All @@ -138,7 +153,9 @@ def get_user_operation_and_receipt(
NOTE: Batch requests are not supported by Pimlico
:param user_operation_hash:
:return: Tuple with UserOperation and UserOperationReceipt, or None if not found
:return: Tuple with ``UserOperation`` and ``UserOperationReceipt``, or ``None`` if not found
:raises BundlerClientConnectionException:
:raises BundlerClientResponseException:
"""
payload = [
self._get_user_operation_by_hash_payload(user_operation_hash, request_id=1),
Expand All @@ -158,6 +175,8 @@ def supported_entry_points(self) -> List[ChecksumAddress]:
https://docs.alchemy.com/reference/eth-supportedentrypoints
:return: List of supported entrypoints
:raises BundlerClientConnectionException:
:raises BundlerClientResponseException:
"""
payload = {
"jsonrpc": "2.0",
Expand Down
13 changes: 13 additions & 0 deletions gnosis/eth/account_abstraction/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from hexbytes import HexBytes

# Entrypoint v0.6.0 and v0.7.0 deposited event
# Deposited (index_topic_1 address account, uint256 totalDeposit)
DEPOSIT_EVENT_TOPIC = HexBytes(
"0x2da466a7b24304f47e87fa2e1e5a81b9831ce54fec19055ce277ca2f39ba42c4"
)

# Safe > 1.4.1 event
# ExecutionFromModuleSuccess (index_topic_1 address module)
EXECUTION_FROM_MODULE_SUCCESS_TOPIC = HexBytes(
"0x6895c13664aa4f67288b25d7a21d7aaa34916e355fb9b6fae0a139a9085becb8"
)
19 changes: 3 additions & 16 deletions gnosis/eth/account_abstraction/user_operation.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import dataclasses
from functools import cached_property
from typing import Any, Dict, List, Optional, TypedDict, Union
from typing import Any, Dict, Optional, Union

from eth_abi import encode as abi_encode
from eth_typing import ChecksumAddress, HexStr
Expand All @@ -10,14 +10,14 @@
from gnosis.eth.utils import fast_keccak


@dataclasses.dataclass
@dataclasses.dataclass(eq=True, frozen=True)
class UserOperationMetadata:
transaction_hash: bytes
block_hash: bytes
block_number: int


@dataclasses.dataclass
@dataclasses.dataclass(eq=True, frozen=True)
class UserOperation:
"""
EIP4337 UserOperation for Entrypoint v0.6
Expand Down Expand Up @@ -117,16 +117,3 @@ def calculate_user_operation_hash(self, chain_id: int) -> bytes:
[fast_keccak(user_operation_encoded), self.entry_point, chain_id],
)
)


class UserOperationReceipt(TypedDict):
userOpHash: HexStr
entryPoint: HexStr
sender: ChecksumAddress
nonce: int
paymaster: ChecksumAddress
actualGasCost: int
actualGasUsed: int
success: bool
reason: str
logs: List[Dict[str, Any]]
74 changes: 74 additions & 0 deletions gnosis/eth/account_abstraction/user_operation_receipt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import dataclasses
from typing import Any, Dict, List, Optional

from eth_typing import ChecksumAddress
from hexbytes import HexBytes
from web3 import Web3
from web3.types import LogReceipt

from gnosis.eth.account_abstraction.constants import (
DEPOSIT_EVENT_TOPIC,
EXECUTION_FROM_MODULE_SUCCESS_TOPIC,
)


@dataclasses.dataclass(eq=True, frozen=True)
class UserOperationReceipt:
user_operation_hash: bytes
entry_point: ChecksumAddress
sender: ChecksumAddress
nonce: int
paymaster: ChecksumAddress
actual_gas_cost: int
actual_gas_used: int
success: bool
reason: str
logs: List[LogReceipt]

@classmethod
def from_bundler_response(
cls,
user_operation_receipt_response: Dict[str, Any],
) -> "UserOperationReceipt":
return cls(
HexBytes(user_operation_receipt_response["userOpHash"]),
user_operation_receipt_response["entryPoint"],
user_operation_receipt_response["sender"],
int(user_operation_receipt_response["nonce"], 16),
user_operation_receipt_response["paymaster"],
int(user_operation_receipt_response["actualGasCost"], 16),
int(user_operation_receipt_response["actualGasUsed"], 16),
user_operation_receipt_response["success"],
user_operation_receipt_response["reason"],
user_operation_receipt_response["logs"],
)

def get_deposit(self) -> int:
"""
:return: Deposited value on the entrypoint for running the UserOperationReceipt
"""
deposited = 0
for log in self.logs:
if (
len(log["topics"]) == 2
and HexBytes(log["topics"][0]) == DEPOSIT_EVENT_TOPIC
and Web3.to_checksum_address(log["address"]) == self.entry_point
and Web3.to_checksum_address(log["topics"][1][-40:]) == self.sender
):
deposited += int(log["data"], 16)
return deposited

def get_module_address(self) -> Optional[ChecksumAddress]:
"""
Use Safe's `ExecutionFromModuleSuccess` event to get the 4337 module address
:return: If using a ``Safe``, the ``4337 module address`` used, ``None`` otherwise
"""
for log in reversed(self.logs):
if (
len(log["topics"]) == 2
and HexBytes(log["topics"][0]) == EXECUTION_FROM_MODULE_SUCCESS_TOPIC
and Web3.to_checksum_address(log["address"]) == self.sender
):
return Web3.to_checksum_address(log["topics"][1][-40:])
return None
16 changes: 10 additions & 6 deletions gnosis/eth/tests/account_abstraction/test_bundler_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@
BundlerClientConnectionException,
BundlerClientResponseException,
UserOperation,
UserOperationReceipt,
)
from ..mocks.mock_bundler import (
safe_4337_user_operation_hash_mock,
supported_entrypoint_mock,
user_operation_mock,
user_operation_receipt_mock,
user_operation_receipt_parsed_mock,
)


Expand Down Expand Up @@ -77,9 +77,13 @@ def test_get_user_operation_receipt(self, mock_session: MagicMock):
return_value=copy.deepcopy(user_operation_receipt_mock)
)
self.bundler.get_user_operation_receipt.cache_clear()

expected_user_operation_receipt = UserOperationReceipt.from_bundler_response(
user_operation_receipt_mock["result"]
)
self.assertEqual(
self.bundler.get_user_operation_receipt(user_operation_hash),
user_operation_receipt_parsed_mock["result"],
expected_user_operation_receipt,
)
mock_session.return_value.json = MagicMock(
return_value={
Expand Down Expand Up @@ -125,6 +129,9 @@ def test_get_user_operation_and_receipt(self, mock_session: MagicMock):
expected_user_operation = UserOperation.from_bundler_response(
user_operation_hash, user_operation_mock["result"]
)
expected_user_operation_receipt = UserOperationReceipt.from_bundler_response(
user_operation_receipt_mock["result"]
)
(
user_operation,
user_operation_receipt,
Expand All @@ -134,10 +141,7 @@ def test_get_user_operation_and_receipt(self, mock_session: MagicMock):
user_operation,
expected_user_operation,
)
self.assertDictEqual(
user_operation_receipt,
user_operation_receipt_parsed_mock["result"],
)
self.assertEqual(user_operation_receipt, expected_user_operation_receipt)
mock_session.return_value.json = MagicMock(
return_value={
"jsonrpc": "2.0",
Expand Down
15 changes: 10 additions & 5 deletions gnosis/eth/tests/account_abstraction/test_e2e_bundler_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,12 @@

import pytest

from ...account_abstraction import BundlerClient, UserOperation
from ...account_abstraction import BundlerClient, UserOperation, UserOperationReceipt
from ..mocks.mock_bundler import (
safe_4337_user_operation_hash_mock,
supported_entrypoint_mock,
user_operation_mock,
user_operation_receipt_mock,
user_operation_receipt_parsed_mock,
)


Expand All @@ -36,10 +35,13 @@ def test_get_user_operation_by_hash(self):

def test_get_user_operation_receipt(self):
user_operation_hash = safe_4337_user_operation_hash_mock.hex()
expected_user_operation_receipt = UserOperationReceipt.from_bundler_response(
user_operation_receipt_mock["result"]
)

self.assertEqual(
self.bundler.get_user_operation_receipt(user_operation_hash),
user_operation_receipt_mock["result"],
expected_user_operation_receipt,
)

@pytest.mark.xfail(reason="Some bundlers don't support batch requests")
Expand All @@ -49,6 +51,9 @@ def test_get_user_operation_and_receipt(self):
expected_user_operation = UserOperation.from_bundler_response(
user_operation_hash, user_operation_mock["result"]
)
expected_user_operation_receipt = UserOperationReceipt.from_bundler_response(
user_operation_receipt_mock["result"]
)
(
user_operation,
user_operation_receipt,
Expand All @@ -57,9 +62,9 @@ def test_get_user_operation_and_receipt(self):
user_operation,
expected_user_operation,
)
self.assertDictEqual(
self.assertEqual(
user_operation_receipt,
user_operation_receipt_parsed_mock["result"],
expected_user_operation_receipt,
)

def test_supported_entry_points(self):
Expand Down
4 changes: 2 additions & 2 deletions gnosis/eth/tests/account_abstraction/test_user_operation.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from unittest import TestCase

from gnosis.eth.account_abstraction import UserOperation
from gnosis.eth.tests.mocks.mock_bundler import (
from ...account_abstraction import UserOperation
from ..mocks.mock_bundler import (
safe_4337_chain_id_mock,
safe_4337_user_operation_hash_mock,
user_operation_mock,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from unittest import TestCase

from ...account_abstraction import UserOperationReceipt
from ..mocks.mock_bundler import user_operation_receipt_mock


class TestUserOperation(TestCase):
def setUp(self):
super().setUp()
self.user_operation_receipt = UserOperationReceipt.from_bundler_response(
user_operation_receipt_mock["result"]
)

def test_calculate_deposit(self):
expected_value = 759_940_285_250_436
self.assertEqual(self.user_operation_receipt.get_deposit(), expected_value)

def test_get_module_address(self):
expected_value = "0xa581c4A4DB7175302464fF3C06380BC3270b4037"
self.assertEqual(
self.user_operation_receipt.get_module_address(), expected_value
)
Loading

0 comments on commit ba8a613

Please sign in to comment.