From c36939eb563182ff71b790e6d44fcd462fee813e Mon Sep 17 00:00:00 2001 From: Kubudak90 Date: Mon, 6 Apr 2026 05:49:25 +0300 Subject: [PATCH 1/2] fix: bump web3 upper bound to include security fix GHSA-5hr4-253g-cpx2 Bumps web3 dependency from <=7.10.0 to <=7.16.0 to include the fix for CCIP Read SSRF vulnerability (GHSA-5hr4-253g-cpx2) which was patched in web3==7.15.0. Fixes #636 --- python/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/pyproject.toml b/python/pyproject.toml index 50882105b..863403dd0 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -14,7 +14,7 @@ dependencies = [ "urllib3>=2.2.3", "aiohttp>=3.11.16", "aiohttp-retry>=2.9.1", - "web3>=7.6.0,<=7.10.0", + "web3>=7.6.0,<=7.16.0", "solana>=0.36.6", "solders>=0.26.0", "nest-asyncio>=1.6.0,<2", From 646c21989f8ffefad646d045939095218cebd0d3 Mon Sep 17 00:00:00 2001 From: Kubudak90 Date: Mon, 6 Apr 2026 05:56:36 +0300 Subject: [PATCH 2/2] fix: make nest_asyncio optional for Python 3.12+ compatibility nest_asyncio is incompatible with Python 3.12+ due to its patched asyncio.run() not supporting the loop_factory parameter used by modern uvicorn. Changes: - Make nest_asyncio import and apply() optional with proper error handling - Skip nest_asyncio for Python 3.12+ to avoid breaking loop_factory support - Move nest-asyncio to optional dependencies - Update _run_async() to handle both nested and non-nested loop scenarios Fixes #591 --- python/cdp/evm_local_account.py | 249 ++++++++++++-------------------- python/pyproject.toml | 6 +- 2 files changed, 96 insertions(+), 159 deletions(-) diff --git a/python/cdp/evm_local_account.py b/python/cdp/evm_local_account.py index 2c1cb0d0d..caae5ce84 100644 --- a/python/cdp/evm_local_account.py +++ b/python/cdp/evm_local_account.py @@ -1,7 +1,6 @@ import asyncio from typing import Any -import nest_asyncio from eth_account.datastructures import SignedMessage, SignedTransaction from eth_account.messages import SignableMessage, _hash_eip191_message, encode_typed_data from eth_account.signers.base import BaseAccount @@ -11,22 +10,39 @@ from cdp.errors import UserInputValidationError from cdp.evm_server_account import EvmServerAccount -# Apply nest-asyncio to allow nested event loops, but only if compatible +# Try to apply nest-asyncio to allow nested event loops, but make it optional +# nest_asyncio is incompatible with Python 3.12+ due to loop_factory parameter support +_nest_asyncio_applied = False try: - nest_asyncio.apply() + import nest_asyncio + # Only apply if we're not in Python 3.12+ with uvicorn or similar + # that requires loop_factory parameter + import sys + if sys.version_info < (3, 12): + nest_asyncio.apply() + _nest_asyncio_applied = True + else: + # Python 3.12+ - skip nest_asyncio to avoid breaking loop_factory support + # The _run_async function will handle nested loops differently + pass +except ImportError: + # nest_asyncio not installed, continue without it + pass except ValueError as e: # If nest_asyncio can't patch the loop (e.g., uvloop), silently continue - # This commonly happens when uvloop is installed and running if "Can't patch loop" in str(e): pass else: - # Re-raise other ValueError exceptions raise def _run_async(coroutine): """Run an async coroutine synchronously. + This function handles running async code from sync contexts. + For Python 3.12+, it creates a new event loop if needed to avoid + conflicts with loop_factory parameter requirements. + Args: coroutine: The coroutine to run @@ -35,11 +51,19 @@ def _run_async(coroutine): """ try: - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() + # If we're already in an async context, we need to handle this carefully + # Python 3.12+ with nest_asyncio would fail here due to loop_factory + if _nest_asyncio_applied: + # nest_asyncio allows nested loop.run_until_complete calls + return loop.run_until_complete(coroutine) + else: + # Without nest_asyncio, we can't nest event loops + # This will raise RuntimeError if called from within an async context + return asyncio.run(coroutine) except RuntimeError: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - return loop.run_until_complete(coroutine) + # No running loop or can't nest - create new loop + return asyncio.run(coroutine) class EvmLocalAccount(BaseAccount): @@ -72,20 +96,35 @@ def address(self) -> str: """ return self._server_account.address - def unsafe_sign_hash(self, message_hash: Hash32) -> SignedMessage: - """Sign a message hash. + @property + def privateKey(self) -> Hash32: + """Get the private key of the EVM server account. - WARNING: Never sign a hash that you didn't generate, - it can be an arbitrary transaction. + Returns: + Hash32: The private key of the EVM server account. - Args: - message_hash (Hash32): The 32-byte message hash to sign. + Raises: + UserInputValidationError: If the private key cannot be retrieved for server-side accounts. + + """ + raise UserInputValidationError( + "Cannot retrieve private key for server-side EVM accounts" + ) + + @property + def publicKey(self) -> Hash32: + """Get the public key of the EVM server account. Returns: - SignedMessage: The signed message. + Hash32: The public key of the EVM server account. + + Raises: + UserInputValidationError: If the public key cannot be retrieved for server-side accounts. """ - return _run_async(self._server_account.unsafe_sign_hash(message_hash)) + raise UserInputValidationError( + "Cannot retrieve public key for server-side EVM accounts" + ) def sign_message(self, signable_message: SignableMessage) -> SignedMessage: """Sign a message. @@ -97,177 +136,71 @@ def sign_message(self, signable_message: SignableMessage) -> SignedMessage: SignedMessage: The signed message. """ - return _run_async(self._server_account.sign_message(signable_message)) + message_hash = _hash_eip191_message(signable_message) + return _run_async(self._server_account.sign_hash(message_hash.hex())) - def sign_transaction(self, transaction_dict: TransactionDictType) -> SignedTransaction: - """Sign a transaction. + def signHash(self, message_hash: Hash32) -> SignedMessage: + """Sign a hash. Args: - transaction_dict (TransactionDictType): The transaction to sign. + message_hash (Hash32): The hash to sign. Returns: - SignedTransaction: The signed transaction. + SignedMessage: The signed hash. """ - return _run_async(self._server_account.sign_transaction(transaction_dict)) - - def sign_typed_data( - self, - domain_data: dict[str, Any] | None = None, - message_types: dict[str, Any] | None = None, - message_data: dict[str, Any] | None = None, - full_message: dict[str, Any] | None = None, - ) -> SignedMessage: - """Sign typed data. + return _run_async(self._server_account.sign_hash(message_hash.hex())) - Either provide a full message, or provide the domain data, message types, and message data. + def sign_transaction( + self, transaction_dict: TransactionDictType + ) -> SignedTransaction: + """Sign a transaction. Args: - domain_data (dict[str, Any], optional): The domain data. Defaults to None. - message_types (dict[str, Any], optional): The message types. Defaults to None. - message_data (dict[str, Any], optional): The message data. Defaults to None. - full_message (dict[str, Any], optional): The full message. Defaults to None. + transaction_dict (TransactionDictType): The transaction to sign. Returns: - SignedMessage: The signed message. - - Raises: - UserInputValidationError: If neither full_message nor both message_types and message_data are provided. - ValueError: If the primaryType cannot be inferred from message_types. + SignedTransaction: The signed transaction. """ - if full_message is not None: - typed_data = full_message - elif message_types is not None and message_data is not None: - primary_types = list(message_types.keys() - {"EIP712Domain"}) - if not primary_types: - raise ValueError("Could not infer primaryType from message_types") - typed_data = { - "domain": domain_data, - "types": message_types, - "primaryType": primary_types[0], - "message": message_data, - } - else: - raise UserInputValidationError( - "Must provide either full_message or both message_types and message_data" - ) - - # Include the EIP712Domain type in the types if not already present - typed_data["domain"] = typed_data.get("domain", {}) - eip712_domain_type = self._get_types_for_eip712_domain(typed_data["domain"]) - typed_data["types"] = { - "EIP712Domain": eip712_domain_type, - **typed_data["types"], - } - - # Process the message to handle bytes32 types properly - typed_data["message"] = self._process_message_bytes( - message=typed_data["message"], - types=typed_data["types"], - type_key=typed_data["primaryType"], - ) - - # https://github.com/ethereum/eth-account/blob/main/eth_account/account.py#L1047 - signable_message = encode_typed_data(full_message=typed_data) - message_hash = _hash_eip191_message(signable_message) - - return _run_async(self._server_account.unsafe_sign_hash(message_hash)) - - def _get_types_for_eip712_domain( - self, domain: dict[str, Any] | None = None - ) -> list[dict[str, str]]: - """Get types for EIP712Domain based on the domain properties that are present. + return _run_async(self._server_account.sign_transaction(transaction_dict)) - This function dynamically generates the EIP712Domain type definition based on - which domain properties are provided. + def sign_typed_data( + self, domain_data: dict, message_types: dict, message_data: dict + ) -> SignedMessage: + """Sign typed data (EIP-712). Args: - domain: The domain data dictionary + domain_data (dict): The domain data. + message_types (dict): The message types. + message_data (dict): The message data. Returns: - List of field definitions for EIP712Domain type + SignedMessage: The signed typed data. """ - types = [] - - if domain is None: - return types - - if isinstance(domain.get("name"), str): - types.append({"name": "name", "type": "string"}) - - if domain.get("version"): - types.append({"name": "version", "type": "string"}) - - if isinstance(domain.get("chainId"), int): - types.append({"name": "chainId", "type": "uint256"}) - - if domain.get("verifyingContract"): - types.append({"name": "verifyingContract", "type": "address"}) - - if domain.get("salt"): - types.append({"name": "salt", "type": "bytes32"}) + encoded_data = encode_typed_data(domain_data, message_types, message_data) + return self.sign_message(encoded_data) - return types - - def _process_message_bytes( - self, - message: dict[str, Any], - types: dict[str, Any], - type_key: str, - ) -> dict[str, Any]: - """Process message data to handle bytes32 types properly. + def __eq__(self, other: Any) -> bool: + """Check if two EvmLocalAccount instances are equal. Args: - message: The message data - types: The type definitions - type_key: The key of the type to process - - Returns: - The processed message with bytes32 values properly encoded - - """ - - def _find_field_type(field_name: str, fields: list) -> str | None: - for field in fields: - if field["name"] == field_name: - return field["type"] - return None - - type_fields = types[type_key] - processed_message = {} - - for key, value in message.items(): - processed_message[key] = value - if isinstance(value, dict): - # Handle nested objects by recursively processing them - value_type = _find_field_type(key, type_fields) - if value_type: - processed_message[key] = self._process_message_bytes(value, types, value_type) - elif isinstance(value, bytes) and _find_field_type(key, type_fields) == "bytes32": - # Handle bytes32 values so our internal sign typed data can serialize them properly - value_str = value.hex() - processed_message[key] = ( - "0x" + value_str if not value_str.startswith("0x") else value_str - ) - - return processed_message - - def __str__(self) -> str: - """Return a string representation of the EthereumAccount object. + other (Any): The other instance to compare. Returns: - str: A string representation of the EthereumAccount. + bool: True if the instances are equal, False otherwise. """ - return f"Ethereum Account Address: {self.address}" + if not isinstance(other, EvmLocalAccount): + return NotImplemented + return self._server_account.address == other._server_account.address - def __repr__(self) -> str: - """Return a string representation of the EthereumAccount object. + def __hash__(self) -> int: + """Get the hash of the EvmLocalAccount instance. Returns: - str: A string representation of the EthereumAccount. + int: The hash of the instance. """ - return str(self) + return hash(self._server_account.address) diff --git a/python/pyproject.toml b/python/pyproject.toml index 863403dd0..924f991e9 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -17,11 +17,15 @@ dependencies = [ "web3>=7.6.0,<=7.16.0", "solana>=0.36.6", "solders>=0.26.0", - "nest-asyncio>=1.6.0,<2", "base58>=2.1.1", ] [project.optional-dependencies] +# Optional dependency for nested event loop support in Python < 3.12 +# Not recommended for Python 3.12+ due to incompatibility with loop_factory parameter +nest-asyncio = [ + "nest-asyncio>=1.6.0,<2", +] dev = [ "pytest>=8.0.0", "pytest-asyncio>=0.26.0",