diff --git a/CHANGELOG.md b/CHANGELOG.md index 023e069..bf66b68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # CDP Python SDK Changelog +## Unreleased + +## [0.19.0] - 2025-02-21 + +### Added +- Ability to create a SmartWallet and send a user operation. + ## [0.18.1] - 2025-02-13 ## Fixed diff --git a/cdp/__init__.py b/cdp/__init__.py index ecf1fd7..d66a1a7 100644 --- a/cdp/__init__.py +++ b/cdp/__init__.py @@ -5,16 +5,20 @@ from cdp.balance_map import BalanceMap from cdp.cdp import Cdp from cdp.contract_invocation import ContractInvocation +from cdp.evm_call_types import EncodedCall, FunctionCall from cdp.external_address import ExternalAddress from cdp.faucet_transaction import FaucetTransaction from cdp.hash_utils import hash_message, hash_typed_data_message from cdp.mnemonic_seed_phrase import MnemonicSeedPhrase +from cdp.network import Network, SupportedChainId from cdp.payload_signature import PayloadSignature from cdp.smart_contract import SmartContract +from cdp.smart_wallet import SmartWallet, to_smart_wallet from cdp.sponsored_send import SponsoredSend from cdp.trade import Trade from cdp.transaction import Transaction from cdp.transfer import Transfer +from cdp.user_operation import UserOperation from cdp.wallet import Wallet from cdp.wallet_address import WalletAddress from cdp.wallet_data import WalletData @@ -40,7 +44,15 @@ "WalletAddress", "WalletData", "Webhook", + "to_smart_wallet", + "SmartWallet", "__version__", "hash_message", "hash_typed_data_message", + "Network", + "SupportedChainId", + "EncodedCall", + "FunctionCall", + "UserOperation", + "Network", ] diff --git a/cdp/__version__.py b/cdp/__version__.py index e9fa21e..11ac8e1 100644 --- a/cdp/__version__.py +++ b/cdp/__version__.py @@ -1 +1 @@ -__version__ = "0.18.1" +__version__ = "0.19.0" diff --git a/cdp/api_clients.py b/cdp/api_clients.py index 8b37942..cd75561 100644 --- a/cdp/api_clients.py +++ b/cdp/api_clients.py @@ -8,6 +8,7 @@ from cdp.client.api.fund_api import FundApi from cdp.client.api.networks_api import NetworksApi from cdp.client.api.smart_contracts_api import SmartContractsApi +from cdp.client.api.smart_wallets_api import SmartWalletsApi from cdp.client.api.trades_api import TradesApi from cdp.client.api.transaction_history_api import TransactionHistoryApi from cdp.client.api.transfers_api import TransfersApi @@ -24,6 +25,7 @@ class ApiClients: Attributes: _cdp_client (CdpApiClient): The CDP API client used to initialize individual API clients. _wallets (Optional[WalletsApi]): The WalletsApi client instance. + _smart_wallets (Optional[SmartWalletsApi]): The SmartWalletsApi client instance. _webhooks (Optional[WebhooksApi]): The WebhooksApi client instance. _addresses (Optional[AddressesApi]): The AddressesApi client instance. _external_addresses (Optional[ExternalAddressesApi]): The ExternalAddressesApi client instance. @@ -44,6 +46,7 @@ def __init__(self, cdp_client: CdpApiClient) -> None: """ self._cdp_client: CdpApiClient = cdp_client self._wallets: WalletsApi | None = None + self._smart_wallets: SmartWalletsApi | None = None self._webhooks: WebhooksApi | None = None self._addresses: AddressesApi | None = None self._external_addresses: ExternalAddressesApi | None = None @@ -73,6 +76,21 @@ def wallets(self) -> WalletsApi: self._wallets = WalletsApi(api_client=self._cdp_client) return self._wallets + @property + def smart_wallets(self) -> SmartWalletsApi: + """Get the SmartWalletsApi client instance. + + Returns: + SmartWalletsApi: The SmartWalletsApi client instance. + + Note: + This property lazily initializes the SmartWalletsApi client on first access. + + """ + if self._smart_wallets is None: + self._smart_wallets = SmartWalletsApi(api_client=self._cdp_client) + return self._smart_wallets + @property def webhooks(self) -> WebhooksApi: """Get the WebhooksApi client instance. diff --git a/cdp/client/api/smart_wallets_api.py b/cdp/client/api/smart_wallets_api.py index b240b0a..5d1913a 100644 --- a/cdp/client/api/smart_wallets_api.py +++ b/cdp/client/api/smart_wallets_api.py @@ -48,7 +48,7 @@ def __init__(self, api_client=None) -> None: def broadcast_user_operation( self, smart_wallet_address: Annotated[StrictStr, Field(description="The address of the smart wallet to broadcast the user operation from.")], - user_operation_id: Annotated[StrictStr, Field(description="The ID of the user operation to broadcast.")], + user_op_hash: Annotated[StrictStr, Field(description="The hash of the user operation to broadcast")], broadcast_user_operation_request: Optional[BroadcastUserOperationRequest] = None, _request_timeout: Union[ None, @@ -69,8 +69,8 @@ def broadcast_user_operation( :param smart_wallet_address: The address of the smart wallet to broadcast the user operation from. (required) :type smart_wallet_address: str - :param user_operation_id: The ID of the user operation to broadcast. (required) - :type user_operation_id: str + :param user_op_hash: The hash of the user operation to broadcast (required) + :type user_op_hash: str :param broadcast_user_operation_request: :type broadcast_user_operation_request: BroadcastUserOperationRequest :param _request_timeout: timeout setting for this request. If one @@ -97,7 +97,7 @@ def broadcast_user_operation( _param = self._broadcast_user_operation_serialize( smart_wallet_address=smart_wallet_address, - user_operation_id=user_operation_id, + user_op_hash=user_op_hash, broadcast_user_operation_request=broadcast_user_operation_request, _request_auth=_request_auth, _content_type=_content_type, @@ -123,7 +123,7 @@ def broadcast_user_operation( def broadcast_user_operation_with_http_info( self, smart_wallet_address: Annotated[StrictStr, Field(description="The address of the smart wallet to broadcast the user operation from.")], - user_operation_id: Annotated[StrictStr, Field(description="The ID of the user operation to broadcast.")], + user_op_hash: Annotated[StrictStr, Field(description="The hash of the user operation to broadcast")], broadcast_user_operation_request: Optional[BroadcastUserOperationRequest] = None, _request_timeout: Union[ None, @@ -144,8 +144,8 @@ def broadcast_user_operation_with_http_info( :param smart_wallet_address: The address of the smart wallet to broadcast the user operation from. (required) :type smart_wallet_address: str - :param user_operation_id: The ID of the user operation to broadcast. (required) - :type user_operation_id: str + :param user_op_hash: The hash of the user operation to broadcast (required) + :type user_op_hash: str :param broadcast_user_operation_request: :type broadcast_user_operation_request: BroadcastUserOperationRequest :param _request_timeout: timeout setting for this request. If one @@ -172,7 +172,7 @@ def broadcast_user_operation_with_http_info( _param = self._broadcast_user_operation_serialize( smart_wallet_address=smart_wallet_address, - user_operation_id=user_operation_id, + user_op_hash=user_op_hash, broadcast_user_operation_request=broadcast_user_operation_request, _request_auth=_request_auth, _content_type=_content_type, @@ -198,7 +198,7 @@ def broadcast_user_operation_with_http_info( def broadcast_user_operation_without_preload_content( self, smart_wallet_address: Annotated[StrictStr, Field(description="The address of the smart wallet to broadcast the user operation from.")], - user_operation_id: Annotated[StrictStr, Field(description="The ID of the user operation to broadcast.")], + user_op_hash: Annotated[StrictStr, Field(description="The hash of the user operation to broadcast")], broadcast_user_operation_request: Optional[BroadcastUserOperationRequest] = None, _request_timeout: Union[ None, @@ -219,8 +219,8 @@ def broadcast_user_operation_without_preload_content( :param smart_wallet_address: The address of the smart wallet to broadcast the user operation from. (required) :type smart_wallet_address: str - :param user_operation_id: The ID of the user operation to broadcast. (required) - :type user_operation_id: str + :param user_op_hash: The hash of the user operation to broadcast (required) + :type user_op_hash: str :param broadcast_user_operation_request: :type broadcast_user_operation_request: BroadcastUserOperationRequest :param _request_timeout: timeout setting for this request. If one @@ -247,7 +247,7 @@ def broadcast_user_operation_without_preload_content( _param = self._broadcast_user_operation_serialize( smart_wallet_address=smart_wallet_address, - user_operation_id=user_operation_id, + user_op_hash=user_op_hash, broadcast_user_operation_request=broadcast_user_operation_request, _request_auth=_request_auth, _content_type=_content_type, @@ -268,7 +268,7 @@ def broadcast_user_operation_without_preload_content( def _broadcast_user_operation_serialize( self, smart_wallet_address, - user_operation_id, + user_op_hash, broadcast_user_operation_request, _request_auth, _content_type, @@ -293,8 +293,8 @@ def _broadcast_user_operation_serialize( # process the path parameters if smart_wallet_address is not None: _path_params['smart_wallet_address'] = smart_wallet_address - if user_operation_id is not None: - _path_params['user_operation_id'] = user_operation_id + if user_op_hash is not None: + _path_params['user_op_hash'] = user_op_hash # process the query parameters # process the header parameters # process the form parameters @@ -332,7 +332,7 @@ def _broadcast_user_operation_serialize( return self.api_client.param_serialize( method='POST', - resource_path='/v1/smart_wallets/{smart_wallet_address}/user_operations/{user_operation_id}/broadcast', + resource_path='/v1/smart_wallets/{smart_wallet_address}/user_operations/{user_op_hash}/broadcast', path_params=_path_params, query_params=_query_params, header_params=_header_params, @@ -1192,7 +1192,7 @@ def _get_smart_wallet_serialize( def get_user_operation( self, smart_wallet_address: Annotated[StrictStr, Field(description="The address of the smart wallet the user operation belongs to.")], - user_operation_id: Annotated[StrictStr, Field(description="The ID of the user operation to fetch.")], + user_op_hash: Annotated[StrictStr, Field(description="The hash of the user operation to fetch")], _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -1212,8 +1212,8 @@ def get_user_operation( :param smart_wallet_address: The address of the smart wallet the user operation belongs to. (required) :type smart_wallet_address: str - :param user_operation_id: The ID of the user operation to fetch. (required) - :type user_operation_id: str + :param user_op_hash: The hash of the user operation to fetch (required) + :type user_op_hash: str :param _request_timeout: timeout setting for this request. If one number provided, it will be total request timeout. It can also be a pair (tuple) of @@ -1238,7 +1238,7 @@ def get_user_operation( _param = self._get_user_operation_serialize( smart_wallet_address=smart_wallet_address, - user_operation_id=user_operation_id, + user_op_hash=user_op_hash, _request_auth=_request_auth, _content_type=_content_type, _headers=_headers, @@ -1263,7 +1263,7 @@ def get_user_operation( def get_user_operation_with_http_info( self, smart_wallet_address: Annotated[StrictStr, Field(description="The address of the smart wallet the user operation belongs to.")], - user_operation_id: Annotated[StrictStr, Field(description="The ID of the user operation to fetch.")], + user_op_hash: Annotated[StrictStr, Field(description="The hash of the user operation to fetch")], _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -1283,8 +1283,8 @@ def get_user_operation_with_http_info( :param smart_wallet_address: The address of the smart wallet the user operation belongs to. (required) :type smart_wallet_address: str - :param user_operation_id: The ID of the user operation to fetch. (required) - :type user_operation_id: str + :param user_op_hash: The hash of the user operation to fetch (required) + :type user_op_hash: str :param _request_timeout: timeout setting for this request. If one number provided, it will be total request timeout. It can also be a pair (tuple) of @@ -1309,7 +1309,7 @@ def get_user_operation_with_http_info( _param = self._get_user_operation_serialize( smart_wallet_address=smart_wallet_address, - user_operation_id=user_operation_id, + user_op_hash=user_op_hash, _request_auth=_request_auth, _content_type=_content_type, _headers=_headers, @@ -1334,7 +1334,7 @@ def get_user_operation_with_http_info( def get_user_operation_without_preload_content( self, smart_wallet_address: Annotated[StrictStr, Field(description="The address of the smart wallet the user operation belongs to.")], - user_operation_id: Annotated[StrictStr, Field(description="The ID of the user operation to fetch.")], + user_op_hash: Annotated[StrictStr, Field(description="The hash of the user operation to fetch")], _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -1354,8 +1354,8 @@ def get_user_operation_without_preload_content( :param smart_wallet_address: The address of the smart wallet the user operation belongs to. (required) :type smart_wallet_address: str - :param user_operation_id: The ID of the user operation to fetch. (required) - :type user_operation_id: str + :param user_op_hash: The hash of the user operation to fetch (required) + :type user_op_hash: str :param _request_timeout: timeout setting for this request. If one number provided, it will be total request timeout. It can also be a pair (tuple) of @@ -1380,7 +1380,7 @@ def get_user_operation_without_preload_content( _param = self._get_user_operation_serialize( smart_wallet_address=smart_wallet_address, - user_operation_id=user_operation_id, + user_op_hash=user_op_hash, _request_auth=_request_auth, _content_type=_content_type, _headers=_headers, @@ -1400,7 +1400,7 @@ def get_user_operation_without_preload_content( def _get_user_operation_serialize( self, smart_wallet_address, - user_operation_id, + user_op_hash, _request_auth, _content_type, _headers, @@ -1424,8 +1424,8 @@ def _get_user_operation_serialize( # process the path parameters if smart_wallet_address is not None: _path_params['smart_wallet_address'] = smart_wallet_address - if user_operation_id is not None: - _path_params['user_operation_id'] = user_operation_id + if user_op_hash is not None: + _path_params['user_op_hash'] = user_op_hash # process the query parameters # process the header parameters # process the form parameters @@ -1449,7 +1449,7 @@ def _get_user_operation_serialize( return self.api_client.param_serialize( method='GET', - resource_path='/v1/smart_wallets/{smart_wallet_address}/user_operations/{user_operation_id}', + resource_path='/v1/smart_wallets/{smart_wallet_address}/user_operations/{user_op_hash}', path_params=_path_params, query_params=_query_params, header_params=_header_params, diff --git a/cdp/client/models/create_user_operation_request.py b/cdp/client/models/create_user_operation_request.py index e735bcb..2966cde 100644 --- a/cdp/client/models/create_user_operation_request.py +++ b/cdp/client/models/create_user_operation_request.py @@ -17,8 +17,8 @@ import re # noqa: F401 import json -from pydantic import BaseModel, ConfigDict, Field -from typing import Any, ClassVar, Dict, List +from pydantic import BaseModel, ConfigDict, Field, StrictStr +from typing import Any, ClassVar, Dict, List, Optional from cdp.client.models.call import Call from typing import Optional, Set from typing_extensions import Self @@ -28,7 +28,8 @@ class CreateUserOperationRequest(BaseModel): CreateUserOperationRequest """ # noqa: E501 calls: List[Call] = Field(description="The list of calls to make from the smart wallet.") - __properties: ClassVar[List[str]] = ["calls"] + paymaster_url: Optional[StrictStr] = Field(default=None, description="The URL of the paymaster to use for the user operation.") + __properties: ClassVar[List[str]] = ["calls", "paymaster_url"] model_config = ConfigDict( populate_by_name=True, @@ -88,7 +89,8 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: return cls.model_validate(obj) _obj = cls.model_validate({ - "calls": [Call.from_dict(_item) for _item in obj["calls"]] if obj.get("calls") is not None else None + "calls": [Call.from_dict(_item) for _item in obj["calls"]] if obj.get("calls") is not None else None, + "paymaster_url": obj.get("paymaster_url") }) return _obj diff --git a/cdp/client/models/user_operation.py b/cdp/client/models/user_operation.py index f2ca9b2..63bfc63 100644 --- a/cdp/client/models/user_operation.py +++ b/cdp/client/models/user_operation.py @@ -30,11 +30,12 @@ class UserOperation(BaseModel): id: StrictStr = Field(description="The ID of the user operation.") network_id: StrictStr = Field(description="The ID of the network the user operation is being created on.") calls: List[Call] = Field(description="The list of calls to make from the smart wallet.") + user_op_hash: StrictStr = Field(description="The unique identifier for the user operation onchain. This is the payload that must be signed by one of the owners of the smart wallet to send the user operation.") unsigned_payload: StrictStr = Field(description="The hex-encoded hash that must be signed by the user.") signature: Optional[StrictStr] = Field(default=None, description="The hex-encoded signature of the user operation.") transaction_hash: Optional[StrictStr] = Field(default=None, description="The hash of the transaction that was broadcast.") status: StrictStr = Field(description="The status of the user operation.") - __properties: ClassVar[List[str]] = ["id", "network_id", "calls", "unsigned_payload", "signature", "transaction_hash", "status"] + __properties: ClassVar[List[str]] = ["id", "network_id", "calls", "user_op_hash", "unsigned_payload", "signature", "transaction_hash", "status"] @field_validator('status') def status_validate_enum(cls, value): @@ -104,6 +105,7 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: "id": obj.get("id"), "network_id": obj.get("network_id"), "calls": [Call.from_dict(_item) for _item in obj["calls"]] if obj.get("calls") is not None else None, + "user_op_hash": obj.get("user_op_hash"), "unsigned_payload": obj.get("unsigned_payload"), "signature": obj.get("signature"), "transaction_hash": obj.get("transaction_hash"), diff --git a/cdp/evm_call_types.py b/cdp/evm_call_types.py new file mode 100644 index 0000000..45fd330 --- /dev/null +++ b/cdp/evm_call_types.py @@ -0,0 +1,26 @@ +from typing import Any + +from eth_typing import HexAddress +from pydantic import BaseModel, Field +from web3.types import HexStr, Wei + + +class EncodedCall(BaseModel): + """Represents an encoded call to a smart contract.""" + + to: HexAddress = Field(..., description="Target contract address") + value: Wei | None = Field(None, description="Amount of native currency to send") + data: HexStr | None = Field(None, description="Encoded call data") + + +class FunctionCall(BaseModel): + """Represents a call to a smart contract that needs to be encoded using the ABI.""" + + to: HexAddress = Field(..., description="Target contract address") + value: Wei | None = Field(None, description="Amount of native currency to send") + abi: list[dict[str, Any]] = Field(..., description="Contract ABI specification") + function_name: str = Field(..., description="Name of the function to call") + args: list[Any] = Field(..., description="Arguments to pass to the function") + + +ContractCall = EncodedCall | FunctionCall diff --git a/cdp/network.py b/cdp/network.py new file mode 100644 index 0000000..2449add --- /dev/null +++ b/cdp/network.py @@ -0,0 +1,25 @@ +from dataclasses import dataclass +from typing import Literal + +from cdp.client.models.network_identifier import NetworkIdentifier + +# Only SmartWallet related chains are listed here right now +SupportedChainId = Literal[8453, 84532] + +CHAIN_ID_TO_NETWORK_ID: dict[SupportedChainId, NetworkIdentifier] = { + 8453: "base-mainnet", + 84532: "base-sepolia", +} + + +@dataclass(frozen=True) +class Network: + """Represents a network with its chain ID and network identifier.""" + + chain_id: SupportedChainId + network_id: NetworkIdentifier + + @classmethod + def from_chain_id(cls, chain_id: SupportedChainId) -> "Network": + """Create a Network instance from a supported chain ID.""" + return cls(chain_id, CHAIN_ID_TO_NETWORK_ID[chain_id]) diff --git a/cdp/smart_wallet.py b/cdp/smart_wallet.py new file mode 100644 index 0000000..dec8b57 --- /dev/null +++ b/cdp/smart_wallet.py @@ -0,0 +1,240 @@ +from eth_account.signers.base import BaseAccount +from web3 import Web3 + +from cdp.cdp import Cdp +from cdp.client.models.call import Call +from cdp.client.models.create_smart_wallet_request import CreateSmartWalletRequest +from cdp.evm_call_types import ContractCall, FunctionCall +from cdp.network import Network +from cdp.user_operation import UserOperation + + +class SmartWallet: + """A class representing a smart wallet.""" + + def __init__(self, address: str, account: BaseAccount) -> None: + """Initialize the SmartWallet class. + + Args: + address (str): The smart wallet address. + account (BaseAccount): The owner of the smart wallet. + + """ + self.__address = address + self.__owners = [account] + + @property + def address(self) -> str: + """Get the Smart Wallet Address. + + Returns: + str: The Smart Wallet Address. + + """ + return self.__address + + @property + def owners(self) -> list[BaseAccount]: + """Get the wallet owners. + + Returns: + List[BaseAccount]: List of owner accounts + + """ + return self.__owners + + @classmethod + def create( + cls, + account: BaseAccount, + ) -> "SmartWallet": + """Create a new smart wallet. + + Returns: + SmartWallet: The created smart wallet object. + + Raises: + Exception: If there's an error creating the EVM smart wallet. + + """ + create_smart_wallet_request = CreateSmartWalletRequest(owner=account.address) + model = Cdp.api_clients.smart_wallets.create_smart_wallet(create_smart_wallet_request) + return SmartWallet(model.address, account) + + def use_network( + self, chain_id: int, paymaster_url: str | None = None + ) -> "NetworkScopedSmartWallet": + """Configure the wallet for a specific network. + + Args: + chain_id (int): The chain ID of the network to connect to + paymaster_url (Optional[str]): Optional URL for the paymaster service + + Returns: + NetworkScopedSmartWallet: A network-scoped version of the wallet + + """ + return NetworkScopedSmartWallet(self.__address, self.owners[0], chain_id, paymaster_url) + + def send_user_operation( + self, calls: list[ContractCall], chain_id: int, paymaster_url: str | None = None + ) -> UserOperation: + """Send a user operation. + + Args: + calls (List[EVMCall]): The calls to send. + chain_id (int): The chain ID. + paymaster_url (str): The paymaster URL. + + Returns: + UserOperation: The user operation object. + + Raises: + ValueError: If the default address does not exist. + + """ + network = Network.from_chain_id(chain_id) + + if not calls: + raise ValueError("Calls list cannot be empty") + + encoded_calls = [] + for call in calls: + if isinstance(call, FunctionCall): + contract = Web3().eth.contract(address=call.to, abi=call.abi) + data = contract.encode_abi(call.function_name, args=call.args) + value = "0" if call.value is None else str(call.value) + encoded_calls.append(Call(to=str(call.to), data=data, value=value)) + else: + value = "0" if call.value is None else str(call.value) + data = "0x" if call.data is None else call.data + encoded_calls.append(Call(to=str(call.to), data=data, value=value)) + + user_operation = UserOperation.create( + self.__address, + network.network_id, + encoded_calls, + paymaster_url, + ) + + owner = self.owners[0] + user_operation.sign(owner) + user_operation.broadcast() + return user_operation + + def __str__(self) -> str: + """Return a string representation of the SmartWallet object. + + Returns: + str: A string representation of the SmartWallet. + + """ + return f"Smart Wallet Address: {self.address}" + + def __repr__(self) -> str: + """Return a string representation of the Wallet object. + + Returns: + str: A string representation of the Wallet. + + """ + return str(self) + + +class NetworkScopedSmartWallet(SmartWallet): + """A smart wallet that's configured for a specific network.""" + + def __init__( + self, + smart_wallet_address: str, + account: BaseAccount, + chain_id: int, + paymaster_url: str | None = None, + ) -> None: + """Initialize the NetworkScopedSmartWallet. + + Args: + smart_wallet_address (str): The smart wallet address + account (BaseAccount): The account that owns the smart wallet + chain_id (int): The chain ID + paymaster_url (Optional[str]): The paymaster URL + + """ + super().__init__(smart_wallet_address, account) + self.__chain_id = chain_id + self.__paymaster_url = paymaster_url + + @property + def chain_id(self) -> int: + """Get the chain ID. + + Returns: + int: The chain ID. + + """ + return self.__chain_id + + @property + def paymaster_url(self) -> str | None: + """Get the paymaster URL. + + Returns: + str | None: The paymaster URL. + + """ + return self.__paymaster_url + + def send_user_operation( + self, + calls: list[ContractCall], + ) -> UserOperation: + """Send a user operation on the configured network. + + Args: + calls (List[EVMCall]): The calls to send. + + Returns: + UserOperation: The user operation object. + + Raises: + ValueError: If there's an error sending the operation. + + """ + return super().send_user_operation( + calls=calls, chain_id=self.chain_id, paymaster_url=self.paymaster_url + ) + + def __str__(self) -> str: + """Return a string representation of the NetworkScopedSmartWallet. + + Returns: + str: A string representation of the smart wallet. + + """ + return f"Network Scoped Smart Wallet: {self.address} (Chain ID: {self.chain_id})" + + def __repr__(self) -> str: + """Return a detailed string representation of the NetworkScopedSmartWallet. + + Returns: + str: A detailed string representation of the smart wallet. + + """ + return f"Network Scoped Smart Wallet: (model=SmartWalletModel(address='{self.address}'), network=Network(chain_id={self.chain_id}, paymaster_url={self.paymaster_url!r}))" + + +def to_smart_wallet(smart_wallet_address: str, signer: BaseAccount) -> "SmartWallet": + """Construct an existing smart wallet by its address and the signer. + + Args: + smart_wallet_address (str): The address of the smart wallet to retrieve. + signer (BaseAccount): The signer to use for the smart wallet. + + Returns: + SmartWallet: The retrieved smart wallet object. + + Raises: + Exception: If there's an error retrieving the smart wallet. + + """ + return SmartWallet(smart_wallet_address, signer) diff --git a/cdp/user_operation.py b/cdp/user_operation.py new file mode 100644 index 0000000..ded73a6 --- /dev/null +++ b/cdp/user_operation.py @@ -0,0 +1,215 @@ +import time +from enum import Enum + +from eth_account.signers.base import BaseAccount + +from cdp.cdp import Cdp +from cdp.client.models.broadcast_user_operation_request import BroadcastUserOperationRequest +from cdp.client.models.call import Call +from cdp.client.models.create_user_operation_request import CreateUserOperationRequest +from cdp.client.models.user_operation import UserOperation as UserOperationModel + + +class UserOperation: + """A class representing a user operation.""" + + class Status(Enum): + """Enumeration of User Operation statuses.""" + + PENDING = "pending" + SIGNED = "signed" + BROADCAST = "broadcast" + COMPLETE = "complete" + FAILED = "failed" + UNSPECIFIED = "unspecified" + + @classmethod + def terminal_states(cls): + """Get the terminal states. + + Returns: + List[str]: The terminal states. + + """ + return [cls.COMPLETE, cls.FAILED] + + def __str__(self) -> str: + """Return a string representation of the Status.""" + return self.value + + def __repr__(self) -> str: + """Return a string representation of the Status.""" + return str(self) + + def __init__(self, model: UserOperationModel, smart_wallet_address: str) -> None: + """Initialize the UserOperation class. + + Args: + model (UserOperationModel): The model representing the user operation. + smart_wallet_address (str): The smart wallet address that created the user operation. + + """ + self._model = model + self._smart_wallet_address = smart_wallet_address + self._signature = None + + @property + def smart_wallet_address(self) -> str: + """Get the smart wallet address of the user operation. + + Returns: + str: The smart wallet address. + + """ + return self._smart_wallet_address + + @property + def user_op_hash(self) -> str: + """Get the user operation hash. + + Returns: + str: The user operation hash. + + """ + return self._model.user_op_hash + + @property + def signature(self) -> str: + """Get the signature of the user operation. + + Returns: + str: The signature of the user operation. + + """ + return self._signature + + @property + def status(self) -> Status: + """Get the status of the user operation. + + Returns: + str: The status. + + """ + return self.Status(self._model.status) + + @property + def terminal_state(self) -> bool: + """Check if the User Operation is in a terminal state.""" + return self.status in self.Status.terminal_states() + + @property + def transaction_hash(self) -> str: + """Get the transaction hash of the user operation. + + Returns: + str: The transaction hash. + + """ + return self._model.transaction_hash + + @classmethod + def create( + cls, + smart_wallet_address: str, + network_id: str, + calls: list[Call], + paymaster_url: str | None = None, + ) -> "UserOperation": + """Create a new UserOperation object. + + Args: + smart_wallet_address (str): The smart wallet address. + network_id (str): The Network ID. + calls (list[Call]): The calls to send. + paymaster_url (Optional[str]): The paymaster URL. + + Returns: + UserOperation: The new UserOperation object. + + """ + create_user_operation_request = CreateUserOperationRequest( + calls=calls, + paymaster_url=paymaster_url, + ) + model = Cdp.api_clients.smart_wallets.create_user_operation( + smart_wallet_address, network_id, create_user_operation_request + ) + return UserOperation(model, smart_wallet_address) + + def sign(self, account: BaseAccount) -> "UserOperation": + """Sign the user operation. + + Returns: + UserOperation: The signed UserOperation. + + """ + signed_message = account.unsafe_sign_hash(self.user_op_hash) + self._signature = "0x" + signed_message.signature.hex() + return self + + def broadcast(self) -> "UserOperation": + """Broadcast the user operation. + + Returns: + UserOperation: The broadcasted UserOperation. + + """ + broadcast_user_operation_request = BroadcastUserOperationRequest( + signature=self.signature, + ) + model = Cdp.api_clients.smart_wallets.broadcast_user_operation( + smart_wallet_address=self.smart_wallet_address, + user_op_hash=self.user_op_hash, + broadcast_user_operation_request=broadcast_user_operation_request, + ) + return UserOperation(model, self.smart_wallet_address) + + def wait(self, interval_seconds: float = 0.2, timeout_seconds: float = 20) -> "UserOperation": + """Wait until the user operation is processed or fails by polling the server. + + Args: + interval_seconds: The interval at which to poll the server. + timeout_seconds: The maximum time to wait before timing out. + + Returns: + UserOperation: The completed UserOperation. + + Raises: + TimeoutError: If the user operation takes longer than the given timeout. + + """ + start_time = time.time() + while not self.terminal_state: + self.reload() + + if time.time() - start_time > timeout_seconds: + raise TimeoutError("User Operation timed out") + + time.sleep(interval_seconds) + + return self + + def reload(self) -> "UserOperation": + """Reload the UserOperation model with the latest version from the server. + + Returns: + UserOperation: The updated UserOperation object. + + """ + model = Cdp.api_clients.smart_wallets.get_user_operation( + smart_wallet_address=self.smart_wallet_address, + user_op_hash=self.user_op_hash, + ) + + self._model = model + + return self + + def __str__(self) -> str: + """Return a string representation of the UserOperation.""" + return f"UserOperation: (user_op_hash: {self.user_op_hash}, status: {self.status})" + + def __repr__(self) -> str: + """Return a string representation of the UserOperation.""" + return str(self) diff --git a/docs/cdp.client.api.rst b/docs/cdp.client.api.rst index ac43c90..e5d60c2 100644 --- a/docs/cdp.client.api.rst +++ b/docs/cdp.client.api.rst @@ -108,6 +108,14 @@ cdp.client.api.smart\_contracts\_api module :undoc-members: :show-inheritance: +cdp.client.api.smart\_wallets\_api module +----------------------------------------- + +.. automodule:: cdp.client.api.smart_wallets_api + :members: + :undoc-members: + :show-inheritance: + cdp.client.api.stake\_api module -------------------------------- diff --git a/docs/cdp.client.models.rst b/docs/cdp.client.models.rst index 8756b3e..ab8a312 100644 --- a/docs/cdp.client.models.rst +++ b/docs/cdp.client.models.rst @@ -100,6 +100,22 @@ cdp.client.models.broadcast\_contract\_invocation\_request module :undoc-members: :show-inheritance: +cdp.client.models.broadcast\_external\_transaction200\_response module +---------------------------------------------------------------------- + +.. automodule:: cdp.client.models.broadcast_external_transaction200_response + :members: + :undoc-members: + :show-inheritance: + +cdp.client.models.broadcast\_external\_transaction\_request module +------------------------------------------------------------------ + +.. automodule:: cdp.client.models.broadcast_external_transaction_request + :members: + :undoc-members: + :show-inheritance: + cdp.client.models.broadcast\_external\_transfer\_request module --------------------------------------------------------------- @@ -132,6 +148,14 @@ cdp.client.models.broadcast\_transfer\_request module :undoc-members: :show-inheritance: +cdp.client.models.broadcast\_user\_operation\_request module +------------------------------------------------------------ + +.. automodule:: cdp.client.models.broadcast_user_operation_request + :members: + :undoc-members: + :show-inheritance: + cdp.client.models.build\_staking\_operation\_request module ----------------------------------------------------------- @@ -140,6 +164,14 @@ cdp.client.models.build\_staking\_operation\_request module :undoc-members: :show-inheritance: +cdp.client.models.call module +----------------------------- + +.. automodule:: cdp.client.models.call + :members: + :undoc-members: + :show-inheritance: + cdp.client.models.compile\_smart\_contract\_request module ---------------------------------------------------------- @@ -252,6 +284,14 @@ cdp.client.models.create\_smart\_contract\_request module :undoc-members: :show-inheritance: +cdp.client.models.create\_smart\_wallet\_request module +------------------------------------------------------- + +.. automodule:: cdp.client.models.create_smart_wallet_request + :members: + :undoc-members: + :show-inheritance: + cdp.client.models.create\_staking\_operation\_request module ------------------------------------------------------------ @@ -276,6 +316,14 @@ cdp.client.models.create\_transfer\_request module :undoc-members: :show-inheritance: +cdp.client.models.create\_user\_operation\_request module +--------------------------------------------------------- + +.. automodule:: cdp.client.models.create_user_operation_request + :members: + :undoc-members: + :show-inheritance: + cdp.client.models.create\_wallet\_request module ------------------------------------------------ @@ -708,6 +756,22 @@ cdp.client.models.smart\_contract\_type module :undoc-members: :show-inheritance: +cdp.client.models.smart\_wallet module +-------------------------------------- + +.. automodule:: cdp.client.models.smart_wallet + :members: + :undoc-members: + :show-inheritance: + +cdp.client.models.smart\_wallet\_list module +-------------------------------------------- + +.. automodule:: cdp.client.models.smart_wallet_list + :members: + :undoc-members: + :show-inheritance: + cdp.client.models.solidity\_value module ---------------------------------------- @@ -836,6 +900,22 @@ cdp.client.models.transaction\_content module :undoc-members: :show-inheritance: +cdp.client.models.transaction\_log module +----------------------------------------- + +.. automodule:: cdp.client.models.transaction_log + :members: + :undoc-members: + :show-inheritance: + +cdp.client.models.transaction\_receipt module +--------------------------------------------- + +.. automodule:: cdp.client.models.transaction_receipt + :members: + :undoc-members: + :show-inheritance: + cdp.client.models.transaction\_type module ------------------------------------------ @@ -884,6 +964,14 @@ cdp.client.models.user module :undoc-members: :show-inheritance: +cdp.client.models.user\_operation module +---------------------------------------- + +.. automodule:: cdp.client.models.user_operation + :members: + :undoc-members: + :show-inheritance: + cdp.client.models.validator module ---------------------------------- @@ -980,6 +1068,14 @@ cdp.client.models.webhook\_smart\_contract\_event\_filter module :undoc-members: :show-inheritance: +cdp.client.models.webhook\_status module +---------------------------------------- + +.. automodule:: cdp.client.models.webhook_status + :members: + :undoc-members: + :show-inheritance: + cdp.client.models.webhook\_wallet\_activity\_filter module ---------------------------------------------------------- diff --git a/docs/cdp.rst b/docs/cdp.rst index 8d087ca..ddbf2c2 100644 --- a/docs/cdp.rst +++ b/docs/cdp.rst @@ -108,6 +108,14 @@ cdp.errors module :undoc-members: :show-inheritance: +cdp.evm\_call\_types module +--------------------------- + +.. automodule:: cdp.evm_call_types + :members: + :undoc-members: + :show-inheritance: + cdp.external\_address module ---------------------------- @@ -172,6 +180,14 @@ cdp.mnemonic\_seed\_phrase module :undoc-members: :show-inheritance: +cdp.network module +------------------ + +.. automodule:: cdp.network + :members: + :undoc-members: + :show-inheritance: + cdp.payload\_signature module ----------------------------- @@ -188,6 +204,14 @@ cdp.smart\_contract module :undoc-members: :show-inheritance: +cdp.smart\_wallet module +------------------------ + +.. automodule:: cdp.smart_wallet + :members: + :undoc-members: + :show-inheritance: + cdp.sponsored\_send module -------------------------- @@ -220,6 +244,14 @@ cdp.transfer module :undoc-members: :show-inheritance: +cdp.user\_operation module +-------------------------- + +.. automodule:: cdp.user_operation + :members: + :undoc-members: + :show-inheritance: + cdp.wallet module ----------------- diff --git a/docs/conf.py b/docs/conf.py index d5b0378..ce99321 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -14,7 +14,7 @@ project = 'CDP SDK' author = 'Coinbase Developer Platform' -release = '0.18.1' +release = '0.19.0' # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration diff --git a/pyproject.toml b/pyproject.toml index 3a94a54..959a03c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "cdp-sdk" -version = "0.18.1" +version = "0.19.0" description = "CDP Python SDK" authors = ["John Peterson "] license = "LICENSE.md" diff --git a/tests/factories/account_factory.py b/tests/factories/account_factory.py new file mode 100644 index 0000000..bc655da --- /dev/null +++ b/tests/factories/account_factory.py @@ -0,0 +1,12 @@ +import pytest +from eth_account import Account + + +@pytest.fixture +def account_factory(): + """Create and return a factory for test accounts.""" + + def _create_account(private_key="0x" + "1" * 64): + return Account.from_key(private_key) + + return _create_account diff --git a/tests/factories/smart_wallet_factory.py b/tests/factories/smart_wallet_factory.py new file mode 100644 index 0000000..78ad23b --- /dev/null +++ b/tests/factories/smart_wallet_factory.py @@ -0,0 +1,32 @@ +import pytest + +from cdp.client.models.smart_wallet import SmartWallet as SmartWalletModel +from cdp.smart_wallet import SmartWallet + + +@pytest.fixture +def smart_wallet_model_factory(account_factory): + """Create and return a factory for WalletModel fixtures.""" + + def _create_smart_wallet_model( + address="0x1234567890123456789012345678901234567890", + owner=None, + ): + if owner is None: + owner = account_factory() + return SmartWalletModel(address=address, owners=[owner]) + + return _create_smart_wallet_model + + +@pytest.fixture +def smart_wallet_factory(smart_wallet_model_factory, account_factory): + """Create and return a factory for SmartWallet fixtures.""" + + def _create_smart_wallet(smart_wallet_address, account=account_factory): + smart_wallet_model = smart_wallet_model_factory( + address=smart_wallet_address, owner=account.address + ) + return SmartWallet(smart_wallet_model.address, account) + + return _create_smart_wallet diff --git a/tests/factories/user_operation_factory.py b/tests/factories/user_operation_factory.py new file mode 100644 index 0000000..bc62543 --- /dev/null +++ b/tests/factories/user_operation_factory.py @@ -0,0 +1,39 @@ +import pytest + +from cdp.client.models.call import Call +from cdp.client.models.user_operation import UserOperation as UserOperationModel + + +@pytest.fixture +def user_operation_model_factory(): + """Create and return a factory for UserOperationModel fixtures.""" + + def _create_user_operation_model( + id="test-user-operation-id", + network_id="base-sepolia", + calls=None, + user_op_hash="0x" + "1" * 64, + signature="0x3456789012345678901234567890123456789012", + transaction_hash="0x4567890123456789012345678901234567890123", + status="pending", + ): + if calls is None: + calls = [ + Call( + to="0x1234567890123456789012345678901234567890", + value="1000000000000000000", + data="0x", + ) + ] + return UserOperationModel( + id=id, + network_id=network_id, + calls=calls, + unsigned_payload=user_op_hash, + user_op_hash=user_op_hash, + signature=signature, + transaction_hash=transaction_hash, + status=status, + ) + + return _create_user_operation_model diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 98032c0..53e8be8 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -94,7 +94,8 @@ def test_wallet_transfer(imported_wallet): assert final_dest_balance > initial_dest_balance -@pytest.mark.e2e +# CDPSDK-265: Flaky test +@pytest.mark.skip(reason="Flaky test") def test_transaction_history(imported_wallet): """Test transaction history retrieval.""" destination_wallet = Wallet.create() @@ -171,7 +172,9 @@ def test_historical_balances(imported_wallet): assert balances assert all(balance.amount > 0 for balance in balances) -@pytest.mark.e2e + +# CDPSDK-265: Flaky test +@pytest.mark.skip(reason="Faucet can be unstable") def test_invoke_contract_with_transaction_receipt(imported_wallet): """Test invoke contract with transaction receipt.""" destination_wallet = Wallet.create() @@ -183,7 +186,7 @@ def test_invoke_contract_with_transaction_receipt(imported_wallet): invocation = imported_wallet.invoke_contract( contract_address="0x036CbD53842c5426634e7929541eC2318f3dCF7e", method="transfer", - args={"to": destination_wallet.default_address.address_id, "value": "1"} + args={"to": destination_wallet.default_address.address_id, "value": "1"}, ) invocation.wait() @@ -202,6 +205,7 @@ def test_invoke_contract_with_transaction_receipt(imported_wallet): assert transaction_log.topics[1] == f"from: {imported_wallet.default_address.address_id}" assert transaction_log.topics[2] == f"to: {destination_wallet.default_address.address_id}" + @pytest.mark.skip(reason="Gasless transfers have unpredictable latency") def test_gasless_transfer(imported_wallet): """Test gasless transfer.""" diff --git a/tests/test_evm_call_types.py b/tests/test_evm_call_types.py new file mode 100644 index 0000000..e417000 --- /dev/null +++ b/tests/test_evm_call_types.py @@ -0,0 +1,88 @@ +import pytest +from web3.types import Wei + +from cdp.evm_call_types import ContractCall, EncodedCall, FunctionCall + + +def test_evm_call_dict_valid(): + """Test that the EVMCallDict is valid.""" + call = EncodedCall(to="0x742d35Cc6634C0532925a3b844Bc454e4438f44e") + assert isinstance(call.to, str) + assert call.value is None + assert call.data is None + + call = EncodedCall( + to="0x742d35Cc6634C0532925a3b844Bc454e4438f44e", + value=Wei(1000000000000000000), + data="0x095ea7b30000000000000000000000742d35cc6634c0532925a3b844bc454e4438f44e", + ) + assert isinstance(call.to, str) + assert call.value == 1000000000000000000 + assert isinstance(call.data, str) + + +def test_evm_abi_call_dict_valid(): + """Test that the EVMAbiCallDict is valid.""" + call = FunctionCall( + to="0x742d35Cc6634C0532925a3b844Bc454e4438f44e", + abi=[ + { + "inputs": [], + "name": "totalSupply", + "outputs": [{"type": "uint256"}], + "stateMutability": "view", + "type": "function", + } + ], + function_name="totalSupply", + args=[], + ) + assert isinstance(call.to, str) + assert call.value is None + assert isinstance(call.abi, list) + assert call.function_name == "totalSupply" + assert call.args == [] + + call = FunctionCall( + to="0x742d35Cc6634C0532925a3b844Bc454e4438f44e", + value=Wei(1000000000000000000), + abi=[ + { + "inputs": [ + {"name": "spender", "type": "address"}, + {"name": "amount", "type": "uint256"}, + ], + "name": "approve", + "outputs": [{"type": "bool"}], + "stateMutability": "nonpayable", + "type": "function", + } + ], + function_name="approve", + args=["0x742d35Cc6634C0532925a3b844Bc454e4438f44e", 1000000], + ) + assert isinstance(call.to, str) + assert call.value == 1000000000000000000 + assert isinstance(call.abi, list) + assert call.function_name == "approve" + assert len(call.args) == 2 + + +def test_evm_abi_call_dict_invalid(): + """Test that the EVMAbiCallDict is invalid.""" + with pytest.raises(ValueError): + FunctionCall(to="0x742d35Cc6634C0532925a3b844Bc454e4438f44e", function_name="test", args=[]) + + +def test_evm_call_union(): + """Test that the EVMCall union is valid.""" + call_dict = EncodedCall(to="0x742d35Cc6634C0532925a3b844Bc454e4438f44e") + abi_call_dict = FunctionCall( + to="0x742d35Cc6634C0532925a3b844Bc454e4438f44e", abi=[], function_name="test", args=[] + ) + + call: ContractCall = call_dict + assert isinstance(call, EncodedCall) + + call = abi_call_dict + assert isinstance(call, FunctionCall) diff --git a/tests/test_network.py b/tests/test_network.py new file mode 100644 index 0000000..ff0007a --- /dev/null +++ b/tests/test_network.py @@ -0,0 +1,26 @@ +from dataclasses import FrozenInstanceError + +import pytest + +from cdp.network import Network + + +def test_network_creation(): + """Test basic network instance creation.""" + network = Network(8453, "base-mainnet") + assert network.chain_id == 8453 + assert network.network_id == "base-mainnet" + + +def test_network_immutability(): + """Test that the network instance is immutable.""" + network = Network(8453, "base-mainnet") + with pytest.raises(FrozenInstanceError): + network.chain_id = 84532 + + +def test_from_chain_id_factory(): + """Test the from_chain_id factory method.""" + network = Network.from_chain_id(8453) + assert network.chain_id == 8453 + assert network.network_id == "base-mainnet" diff --git a/tests/test_smart_wallet.py b/tests/test_smart_wallet.py new file mode 100644 index 0000000..9455807 --- /dev/null +++ b/tests/test_smart_wallet.py @@ -0,0 +1,256 @@ +from unittest.mock import Mock, patch + +import pytest +from eth_account import Account + +from cdp.client.models.call import Call +from cdp.client.models.create_smart_wallet_request import CreateSmartWalletRequest +from cdp.evm_call_types import EncodedCall, FunctionCall +from cdp.smart_wallet import SmartWallet, to_smart_wallet +from cdp.user_operation import UserOperation + + +def test_smart_wallet_initialization(smart_wallet_factory, account_factory): + """Test SmartWallet initialization.""" + account = account_factory() + smart_wallet_address = "0x1234567890123456789012345678901234567890" + smart_wallet = smart_wallet_factory(smart_wallet_address, account) + assert smart_wallet.address == smart_wallet_address + assert smart_wallet.owners == [account] + + +@patch("cdp.Cdp.api_clients") +def test_smart_wallet_create( + mock_api_clients, + smart_wallet_model_factory, +): + """Test SmartWallet create method.""" + account = Account.create() + smart_wallet_address = "0x1234567890123456789012345678901234567890" + mock_create_smart_wallet = Mock( + return_value=smart_wallet_model_factory(address=smart_wallet_address, owner=account.address) + ) + mock_api_clients.smart_wallets.create_smart_wallet = mock_create_smart_wallet + + smart_wallet = SmartWallet.create(account) + mock_create_smart_wallet.assert_called_once_with( + CreateSmartWalletRequest(owner=account.address) + ) + + assert smart_wallet.address == smart_wallet_address + assert smart_wallet.owners == [account] + + +def test_smart_wallet_use_network(smart_wallet_factory): + """Test SmartWallet use_network method.""" + account = Account.create() + smart_wallet_address = "0x1234567890123456789012345678901234567890" + smart_wallet = smart_wallet_factory(smart_wallet_address, account) + network_scoped_wallet = smart_wallet.use_network(84532, "https://paymaster.com") + assert network_scoped_wallet.chain_id == 84532 + assert network_scoped_wallet.paymaster_url == "https://paymaster.com" + + +@patch("cdp.Cdp.api_clients") +def test_smart_wallet_send_user_operation_with_encoded_call( + mock_api_clients, smart_wallet_factory, user_operation_model_factory, account_factory +): + """Test SmartWallet send_user_operation method.""" + account = account_factory() + smart_wallet_address = "0x1234567890123456789012345678901234567890" + smart_wallet = smart_wallet_factory(smart_wallet_address, account) + + mock_user_operation = user_operation_model_factory( + id="test-user-operation-id", + network_id="base-sepolia", + calls=[Call(to=account.address, value="1000000000000000000", data="0x")], + user_op_hash="0x" + "0" * 64, # 32 bytes in hex + signature="0xe0a180fdd0fe38037cc878c03832861b40a29d32bd7b40b10c9e1efc8c1468a05ae06d1624896d0d29f4b31e32772ea3cb1b4d7ed4e077e5da28dcc33c0e78121c", + transaction_hash="0x4567890123456789012345678901234567890123", + status="pending", + ) + + mock_create_user_operation = Mock(return_value=mock_user_operation) + mock_api_clients.smart_wallets.create_user_operation = mock_create_user_operation + + calls = [EncodedCall(to=account.address, value=1000000000000000000, data="0x")] + + user_operation = smart_wallet.send_user_operation( + calls=calls, chain_id=84532, paymaster_url="https://paymaster.com" + ) + + mock_create_user_operation.assert_called_once() + + assert user_operation.smart_wallet_address == smart_wallet_address + assert user_operation.user_op_hash == mock_user_operation.user_op_hash + assert user_operation.signature == mock_user_operation.signature + assert user_operation.transaction_hash == mock_user_operation.transaction_hash + assert user_operation.status == UserOperation.Status(mock_user_operation.status) + assert not user_operation.terminal_state + + +@patch("cdp.Cdp.api_clients") +def test_send_user_operation_with_abi_call( + mock_api_clients, smart_wallet_factory, user_operation_model_factory, account_factory +): + """Test sending user operation with EVMAbiCallDict.""" + account = account_factory() + smart_wallet_address = "0x1234567890123456789012345678901234567890" + smart_wallet = smart_wallet_factory(smart_wallet_address, account) + + mock_user_operation = user_operation_model_factory( + id="test-op-id", + network_id="base-sepolia", + calls=[Call(to=account.address, data="0x123", value="0")], + user_op_hash="0x" + "0" * 64, + signature="0xe0a180fdd0fe38037cc878c03832861b40a29d32bd7b40b10c9e1efc8c1468a05ae06d1624896d0d29f4b31e32772ea3cb1b4d7ed4e077e5da28dcc33c0e78121c", + transaction_hash="0x4567890123456789012345678901234567890123", + status="pending", + ) + + mock_create_user_operation = Mock(return_value=mock_user_operation) + mock_api_clients.smart_wallets.create_user_operation = mock_create_user_operation + + abi_call = FunctionCall( + to=account.address, + abi=[{"inputs": [], "name": "transfer", "type": "function"}], + function_name="transfer", + args=[], + value=None, + ) + + user_operation = smart_wallet.send_user_operation(calls=[abi_call], chain_id=84532) + + assert user_operation.smart_wallet_address == smart_wallet_address + assert user_operation.user_op_hash == mock_user_operation.user_op_hash + assert user_operation.signature == mock_user_operation.signature + assert user_operation.transaction_hash == mock_user_operation.transaction_hash + assert user_operation.status == UserOperation.Status(mock_user_operation.status) + assert not user_operation.terminal_state + mock_create_user_operation.assert_called_once() + + +@patch("cdp.Cdp.api_clients") +def test_smart_wallet_multiple_calls( + mock_api_clients, smart_wallet_factory, account_factory, user_operation_model_factory +): + """Test sending multiple calls in one operation.""" + account = account_factory() + smart_wallet_address = "0x1234567890123456789012345678901234567890" + smart_wallet = smart_wallet_factory(smart_wallet_address, account) + + calls = [ + EncodedCall(to=account.address, value=1000000000000000000, data="0x"), + EncodedCall(to=account.address, value=0, data="0x123"), + EncodedCall(to=account.address, value=None, data=None), + ] + + model_calls = [Call(to=c.to, value=str(c.value or 0), data=c.data or "0x") for c in calls] + + mock_user_operation = user_operation_model_factory( + id="test-op-id", + network_id="base-sepolia", + calls=model_calls, + user_op_hash="0x" + "0" * 64, + signature="0x" + "0" * 130, + transaction_hash="0x" + "0" * 64, + status="pending", + ) + + mock_api_clients.smart_wallets.create_user_operation.return_value = mock_user_operation + + user_operation = smart_wallet.send_user_operation(calls=calls, chain_id=84532) + + assert user_operation.user_op_hash == mock_user_operation.user_op_hash + assert user_operation.transaction_hash == mock_user_operation.transaction_hash + assert user_operation.status == UserOperation.Status(mock_user_operation.status) + assert not user_operation.terminal_state + + mock_api_clients.smart_wallets.create_user_operation.assert_called_once() + + +def test_network_scoped_wallet_initialization(smart_wallet_factory, account_factory): + """Test NetworkScopedSmartWallet initialization.""" + account = account_factory() + smart_wallet_address = "0x1234567890123456789012345678901234567890" + + network_wallet = smart_wallet_factory(smart_wallet_address, account).use_network( + 84532, "https://paymaster.com" + ) + assert network_wallet.chain_id == 84532 + assert network_wallet.paymaster_url == "https://paymaster.com" + + network_wallet = smart_wallet_factory(smart_wallet_address, account).use_network(84532) + assert network_wallet.chain_id == 84532 + assert network_wallet.paymaster_url is None + + +@patch("cdp.Cdp.api_clients") +def test_network_scoped_wallet_send_operation( + mock_api_clients, smart_wallet_factory, user_operation_model_factory, account_factory +): + """Test NetworkScopedSmartWallet send_user_operation method.""" + account = account_factory() + smart_wallet_address = "0x1234567890123456789012345678901234567890" + network_wallet = smart_wallet_factory(smart_wallet_address, account).use_network(84532) + + mock_user_operation = user_operation_model_factory( + id="test-op-id", + network_id="base-sepolia", + calls=[Call(to=account.address, value="1000000000000000000", data="0x")], + user_op_hash="0x" + "0" * 64, + signature="0xe0a180fdd0fe38037cc878c03832861b40a29d32bd7b40b10c9e1efc8c1468a05ae06d1624896d0d29f4b31e32772ea3cb1b4d7ed4e077e5da28dcc33c0e78121c", + transaction_hash="0x4567890123456789012345678901234567890123", + status="pending", + ) + + mock_create_user_operation = Mock(return_value=mock_user_operation) + mock_api_clients.smart_wallets.create_user_operation = mock_create_user_operation + + calls = [EncodedCall(to=account.address, value=1000000000000000000, data="0x")] + user_operation = network_wallet.send_user_operation(calls=calls) + + assert user_operation.smart_wallet_address == smart_wallet_address + assert user_operation.user_op_hash == mock_user_operation.user_op_hash + assert user_operation.signature == mock_user_operation.signature + assert user_operation.transaction_hash == mock_user_operation.transaction_hash + assert user_operation.status == UserOperation.Status(mock_user_operation.status) + assert not user_operation.terminal_state + + +def test_network_scoped_wallet_string_representations(smart_wallet_factory, account_factory): + """Test NetworkScopedSmartWallet string representations.""" + account = account_factory() + address = "0x1234567890123456789012345678901234567890" + network_wallet = smart_wallet_factory(address, account).use_network(84532) + + assert str(network_wallet) == f"Network Scoped Smart Wallet: {address} (Chain ID: 84532)" + assert ( + repr(network_wallet) + == f"Network Scoped Smart Wallet: (model=SmartWalletModel(address='{address}'), network=Network(chain_id=84532, paymaster_url=None))" + ) + + +def test_to_smart_wallet_creation(account_factory): + """Test to_smart_wallet function.""" + signer = account_factory() + address = "0x1234567890123456789012345678901234567890" + + wallet = to_smart_wallet(address, signer) + assert wallet.address == address + assert wallet.owners == [signer] + + +@patch("cdp.Cdp.api_clients") +def test_send_user_operation_with_empty_calls( + mock_api_clients, smart_wallet_factory, account_factory +): + """Test that sending empty calls list raises ValueError.""" + account = account_factory() + smart_wallet_address = "0x1234567890123456789012345678901234567890" + smart_wallet = smart_wallet_factory(smart_wallet_address, account) + + with pytest.raises(ValueError, match="Calls list cannot be empty"): + smart_wallet.send_user_operation(calls=[], chain_id=84532) + + mock_api_clients.smart_wallets.create_user_operation.assert_not_called() diff --git a/tests/test_user_operation.py b/tests/test_user_operation.py new file mode 100644 index 0000000..2f8be8f --- /dev/null +++ b/tests/test_user_operation.py @@ -0,0 +1,216 @@ +from unittest.mock import Mock, call, patch + +import pytest + +from cdp.client.models.broadcast_user_operation_request import BroadcastUserOperationRequest +from cdp.client.models.call import Call +from cdp.client.models.create_user_operation_request import CreateUserOperationRequest +from cdp.user_operation import UserOperation + + +def test_user_operation_properties(user_operation_model_factory): + """Test the properties of a UserOperation object.""" + call = Call(to="0x1234567890123456789012345678901234567890", data="0xdata", value="0") + + model = user_operation_model_factory( + "test-user-operation-id", + "base-sepolia", + [call], + "0x" + "1" * 64, + None, + "0x4567890123456789012345678901234567890123", + "pending", + ) + user_operation = UserOperation(model, "0x1234567890123456789012345678901234567890") + assert user_operation.smart_wallet_address == "0x1234567890123456789012345678901234567890" + assert user_operation.user_op_hash == model.user_op_hash + assert user_operation.status == UserOperation.Status.PENDING + assert user_operation.signature is None + assert user_operation.transaction_hash == model.transaction_hash + + +@patch("cdp.Cdp.api_clients") +def test_create_user_operation(mock_api_clients, user_operation_model_factory): + """Test the creation of a UserOperation object.""" + model = user_operation_model_factory() + mock_create_operation = Mock() + mock_create_operation.return_value = model + mock_api_clients.smart_wallets.create_user_operation = mock_create_operation + + call = Call(to="0x1234567890123456789012345678901234567890", data="0xdata", value="0") + calls = [call] + + user_operation = UserOperation.create( + smart_wallet_address="0xsmartwallet", + network_id="base-sepolia", + calls=calls, + paymaster_url="https://paymaster.example.com", + ) + + assert isinstance(user_operation, UserOperation) + + # Verify the create operation was called with correct arguments + mock_create_operation.assert_called_once() + args = mock_create_operation.call_args.args + + assert args[0] == "0xsmartwallet" # smart_wallet_address + assert args[1] == "base-sepolia" # network_id + + # Verify the CreateUserOperationRequest object + request = args[2] + assert isinstance(request, CreateUserOperationRequest) + assert request.calls == calls + assert request.paymaster_url == "https://paymaster.example.com" + + +def test_sign_user_operation(user_operation_model_factory, account_factory): + """Test signing a UserOperation object.""" + model = user_operation_model_factory(signature=None) + user_operation = UserOperation(model, "0xsmartwallet") + account = account_factory() + + result = user_operation.sign(account) + + assert isinstance(result, UserOperation) + assert result.signature is not None + assert result.signature.startswith("0x") + # Verify the signature length (0x + 130 hex characters for r, s, v) + assert len(result.signature) == 132 + + +@patch("cdp.Cdp.api_clients") +def test_broadcast_user_operation(mock_api_clients, user_operation_model_factory): + """Test broadcasting a UserOperation object.""" + initial_model = user_operation_model_factory(status="signed") + initial_operation = UserOperation(initial_model, "0xsmartwallet") + initial_operation._signature = "0xsignature" + + broadcast_model = user_operation_model_factory(status="broadcast") + mock_broadcast = Mock(return_value=broadcast_model) + mock_api_clients.smart_wallets.broadcast_user_operation = mock_broadcast + + result = initial_operation.broadcast() + + assert isinstance(result, UserOperation) + assert result.status == UserOperation.Status.BROADCAST + mock_broadcast.assert_called_once_with( + smart_wallet_address=initial_operation.smart_wallet_address, + user_op_hash=initial_operation.user_op_hash, + broadcast_user_operation_request=BroadcastUserOperationRequest(signature="0xsignature"), + ) + + +@patch("cdp.Cdp.api_clients") +def test_reload_user_operation(mock_api_clients, user_operation_model_factory): + """Test reloading a UserOperation object.""" + pending_model = user_operation_model_factory(status="pending") + complete_model = user_operation_model_factory(status="complete") + pending_operation = UserOperation(pending_model, "0xsmartwallet") + + mock_get_operation = Mock(return_value=complete_model) + mock_api_clients.smart_wallets.get_user_operation = mock_get_operation + + pending_operation.reload() + + mock_get_operation.assert_called_once_with( + smart_wallet_address=pending_operation.smart_wallet_address, + user_op_hash=pending_operation.user_op_hash, + ) + assert pending_operation.status == UserOperation.Status.COMPLETE + + +@patch("cdp.Cdp.api_clients") +@patch("cdp.user_operation.time.sleep") +@patch("cdp.user_operation.time.time") +def test_wait_for_user_operation( + mock_time, mock_sleep, mock_api_clients, user_operation_model_factory +): + """Test waiting for a UserOperation object to complete.""" + pending_model = user_operation_model_factory(status="pending") + complete_model = user_operation_model_factory(status="complete") + pending_operation = UserOperation(pending_model, "0xsmartwallet") + + mock_get_operation = Mock() + mock_api_clients.smart_wallets.get_user_operation = mock_get_operation + mock_get_operation.side_effect = [pending_model, complete_model] + + mock_time.side_effect = [0, 0.2, 0.4] + + result = pending_operation.wait(interval_seconds=0.2, timeout_seconds=1) + + assert result.status == UserOperation.Status.COMPLETE + mock_get_operation.assert_called_with( + smart_wallet_address=pending_operation.smart_wallet_address, + user_op_hash=pending_operation.user_op_hash, + ) + assert mock_get_operation.call_count == 2 + mock_sleep.assert_has_calls([call(0.2)] * 2) + assert mock_time.call_count == 3 + + +@patch("cdp.Cdp.api_clients") +@patch("cdp.user_operation.time.sleep") +@patch("cdp.user_operation.time.time") +def test_wait_for_user_operation_timeout( + mock_time, mock_sleep, mock_api_clients, user_operation_model_factory +): + """Test waiting for a UserOperation object to complete with a timeout.""" + pending_model = user_operation_model_factory(status="pending") + pending_operation = UserOperation(pending_model, "0xsmartwallet") + + mock_get_operation = Mock(return_value=pending_model) + mock_api_clients.smart_wallets.get_user_operation = mock_get_operation + + mock_time.side_effect = [0, 0.5, 1.0, 1.5, 2.0, 2.5] + + with pytest.raises(TimeoutError, match="User Operation timed out"): + pending_operation.wait(interval_seconds=0.5, timeout_seconds=2) + + assert mock_get_operation.call_count == 5 + mock_sleep.assert_has_calls([call(0.5)] * 4) + assert mock_time.call_count == 6 + + +def test_terminal_states(): + """Test the terminal states of UserOperation Status.""" + assert UserOperation.Status.terminal_states() == [ + UserOperation.Status.COMPLETE, + UserOperation.Status.FAILED, + ] + + +def test_status_string_representation(): + """Test the string representation of all UserOperation Status values.""" + expected_statuses = { + UserOperation.Status.PENDING: "pending", + UserOperation.Status.SIGNED: "signed", + UserOperation.Status.BROADCAST: "broadcast", + UserOperation.Status.COMPLETE: "complete", + UserOperation.Status.FAILED: "failed", + UserOperation.Status.UNSPECIFIED: "unspecified", + } + + for status, expected_str in expected_statuses.items(): + # Test str() representation + assert str(status) == expected_str, f"str({status}) should be '{expected_str}'" + + # Test repr() representation + assert repr(status) == expected_str, f"repr({status}) should be '{expected_str}'" + + +def test_user_operation_str_representation(user_operation_model_factory): + """Test the string representation of a UserOperation object.""" + model = user_operation_model_factory() + user_operation = UserOperation(model, "0xsmartwallet") + expected_str = ( + f"UserOperation: (user_op_hash: {user_operation.user_op_hash}, " + f"status: {user_operation.status})" + ) + assert str(user_operation) == expected_str + + +def test_user_operation_repr(user_operation_model_factory): + """Test the representation of a UserOperation object.""" + model = user_operation_model_factory() + user_operation = UserOperation(model, "0xsmartwallet") + assert repr(user_operation) == str(user_operation) diff --git a/tests/test_wallet.py b/tests/test_wallet.py index 81bda76..fc8f814 100644 --- a/tests/test_wallet.py +++ b/tests/test_wallet.py @@ -891,6 +891,7 @@ def test_wallet_import_from_mnemonic_invalid_phrase(): with pytest.raises(ValueError, match="Invalid BIP-39 mnemonic seed phrase"): Wallet.import_wallet(MnemonicSeedPhrase("invalid mnemonic phrase")) + @patch("cdp.Cdp.use_server_signer", False) @patch("cdp.Cdp.api_clients") @patch("cdp.wallet.Account")