diff --git a/src/polymarket/_internal/actions/relayer/approvals.py b/src/polymarket/_internal/actions/relayer/approvals.py new file mode 100644 index 0000000..38d5ec1 --- /dev/null +++ b/src/polymarket/_internal/actions/relayer/approvals.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import cast + +from polymarket._internal.actions.relayer.calls import ( + MAX_UINT256, + TransactionCall, + decode_erc20_allowance_result, + decode_erc1155_is_approved_for_all_result, + erc20_allowance_call, + erc20_approval_call, + erc1155_is_approved_for_all_call, + erc1155_set_approval_for_all_call, +) +from polymarket._internal.eoa.rpc import JsonRpcClient, SyncJsonRpcClient +from polymarket.environments import Environment +from polymarket.types import EvmAddress + + +@dataclass(frozen=True, slots=True) +class _Erc20TradingApproval: + token_address: EvmAddress + spender: EvmAddress + amount: int + + +@dataclass(frozen=True, slots=True) +class _Erc1155TradingApproval: + token_address: EvmAddress + operator: EvmAddress + + +async def resolve_missing_trading_approval_calls( + rpc: JsonRpcClient, *, wallet: EvmAddress, environment: Environment +) -> list[TransactionCall]: + erc20, erc1155 = _required_trading_approvals(environment) + erc20_missing: list[TransactionCall] = [] + for approval in erc20: + check = erc20_allowance_call( + token_address=approval.token_address, + owner=wallet, + spender=approval.spender, + ) + allowance = decode_erc20_allowance_result( + await rpc.eth_call(to=str(check.to), data=check.data) + ) + if allowance < approval.amount: + erc20_missing.append( + erc20_approval_call( + token_address=approval.token_address, + spender=approval.spender, + amount=approval.amount, + ) + ) + + erc1155_missing: list[TransactionCall] = [] + for approval in erc1155: + check = erc1155_is_approved_for_all_call( + token_address=approval.token_address, + owner=wallet, + operator=approval.operator, + ) + approved = decode_erc1155_is_approved_for_all_result( + await rpc.eth_call(to=str(check.to), data=check.data) + ) + if not approved: + erc1155_missing.append( + erc1155_set_approval_for_all_call( + token_address=approval.token_address, + operator=approval.operator, + approved=True, + ) + ) + + return erc20_missing + erc1155_missing + + +def resolve_missing_trading_approval_calls_sync( + rpc: SyncJsonRpcClient, *, wallet: EvmAddress, environment: Environment +) -> list[TransactionCall]: + erc20, erc1155 = _required_trading_approvals(environment) + erc20_missing: list[TransactionCall] = [] + for approval in erc20: + check = erc20_allowance_call( + token_address=approval.token_address, + owner=wallet, + spender=approval.spender, + ) + allowance = decode_erc20_allowance_result(rpc.eth_call(to=str(check.to), data=check.data)) + if allowance < approval.amount: + erc20_missing.append( + erc20_approval_call( + token_address=approval.token_address, + spender=approval.spender, + amount=approval.amount, + ) + ) + + erc1155_missing: list[TransactionCall] = [] + for approval in erc1155: + check = erc1155_is_approved_for_all_call( + token_address=approval.token_address, + owner=wallet, + operator=approval.operator, + ) + approved = decode_erc1155_is_approved_for_all_result( + rpc.eth_call(to=str(check.to), data=check.data) + ) + if not approved: + erc1155_missing.append( + erc1155_set_approval_for_all_call( + token_address=approval.token_address, + operator=approval.operator, + approved=True, + ) + ) + + return erc20_missing + erc1155_missing + + +def _required_trading_approvals( + environment: Environment, +) -> tuple[list[_Erc20TradingApproval], list[_Erc1155TradingApproval]]: + collateral = cast(EvmAddress, environment.collateral_token) + conditional = cast(EvmAddress, environment.conditional_tokens) + return ( + [ + _Erc20TradingApproval( + token_address=collateral, + spender=cast(EvmAddress, environment.standard_exchange), + amount=MAX_UINT256, + ), + _Erc20TradingApproval( + token_address=collateral, + spender=cast(EvmAddress, environment.neg_risk_exchange), + amount=MAX_UINT256, + ), + _Erc20TradingApproval( + token_address=collateral, + spender=cast(EvmAddress, environment.neg_risk_adapter), + amount=MAX_UINT256, + ), + _Erc20TradingApproval( + token_address=collateral, + spender=cast(EvmAddress, environment.collateral_adapter), + amount=MAX_UINT256, + ), + _Erc20TradingApproval( + token_address=collateral, + spender=cast(EvmAddress, environment.neg_risk_collateral_adapter), + amount=MAX_UINT256, + ), + ], + [ + _Erc1155TradingApproval( + token_address=conditional, + operator=cast(EvmAddress, environment.standard_exchange), + ), + _Erc1155TradingApproval( + token_address=conditional, + operator=cast(EvmAddress, environment.neg_risk_exchange), + ), + _Erc1155TradingApproval( + token_address=conditional, + operator=cast(EvmAddress, environment.neg_risk_adapter), + ), + _Erc1155TradingApproval( + token_address=conditional, + operator=cast(EvmAddress, environment.collateral_adapter), + ), + _Erc1155TradingApproval( + token_address=conditional, + operator=cast(EvmAddress, environment.neg_risk_collateral_adapter), + ), + _Erc1155TradingApproval( + token_address=conditional, + operator=cast(EvmAddress, environment.auto_redeem_operator), + ), + ], + ) + + +__all__ = [ + "resolve_missing_trading_approval_calls", + "resolve_missing_trading_approval_calls_sync", +] diff --git a/src/polymarket/_internal/actions/relayer/calls.py b/src/polymarket/_internal/actions/relayer/calls.py index 6ba7a5a..f6717ab 100644 --- a/src/polymarket/_internal/actions/relayer/calls.py +++ b/src/polymarket/_internal/actions/relayer/calls.py @@ -3,11 +3,12 @@ from dataclasses import dataclass from typing import cast +from eth_abi.abi import decode as abi_decode from eth_abi.abi import encode as abi_encode from eth_utils.crypto import keccak from eth_utils.hexadecimal import decode_hex -from polymarket.errors import UserInputError +from polymarket.errors import UnexpectedResponseError, UserInputError from polymarket.types import EvmAddress, HexString MAX_UINT256 = (1 << 256) - 1 @@ -18,8 +19,10 @@ def _selector(signature: str) -> bytes: _ERC20_APPROVE_SELECTOR = _selector("approve(address,uint256)") +_ERC20_ALLOWANCE_SELECTOR = _selector("allowance(address,address)") _ERC20_TRANSFER_SELECTOR = _selector("transfer(address,uint256)") _ERC1155_SET_APPROVAL_FOR_ALL_SELECTOR = _selector("setApprovalForAll(address,bool)") +_ERC1155_IS_APPROVED_FOR_ALL_SELECTOR = _selector("isApprovedForAll(address,address)") _CTF_SPLIT_POSITION_SELECTOR = _selector("splitPosition(address,bytes32,bytes32,uint256[],uint256)") _CTF_MERGE_POSITIONS_SELECTOR = _selector( "mergePositions(address,bytes32,bytes32,uint256[],uint256)" @@ -52,6 +55,19 @@ def erc20_approval_call( ) +def erc20_allowance_call( + *, token_address: EvmAddress, owner: EvmAddress, spender: EvmAddress +) -> TransactionCall: + payload = _ERC20_ALLOWANCE_SELECTOR + abi_encode( + ["address", "address"], [str(owner), str(spender)] + ) + return TransactionCall(to=token_address, data=cast(HexString, "0x" + payload.hex())) + + +def decode_erc20_allowance_result(data: str) -> int: + return cast(int, _decode_return_data(data, "uint256")) + + def erc20_transfer_call( *, token_address: EvmAddress, recipient: EvmAddress, amount: int ) -> TransactionCall: @@ -77,6 +93,19 @@ def erc1155_set_approval_for_all_call( ) +def erc1155_is_approved_for_all_call( + *, token_address: EvmAddress, owner: EvmAddress, operator: EvmAddress +) -> TransactionCall: + payload = _ERC1155_IS_APPROVED_FOR_ALL_SELECTOR + abi_encode( + ["address", "address"], [str(owner), str(operator)] + ) + return TransactionCall(to=token_address, data=cast(HexString, "0x" + payload.hex())) + + +def decode_erc1155_is_approved_for_all_result(data: str) -> bool: + return cast(bool, _decode_return_data(data, "bool")) + + def split_position_call( *, target: EvmAddress, @@ -157,6 +186,18 @@ def _condition_id_bytes(condition_id: str) -> bytes: return raw +def _decode_return_data(data: str, abi_type: str) -> object: + try: + values = abi_decode([abi_type], decode_hex(data)) + except Exception as error: + raise UnexpectedResponseError( + f"Could not decode {abi_type} return data: {error}" + ) from error + if len(values) != 1: + raise UnexpectedResponseError(f"Expected one {abi_type} return value, got {len(values)}") + return values[0] + + def encode_proxy_call(calls: list[TransactionCall]) -> HexString: if not calls: raise UserInputError("encode_proxy_call requires at least one call") @@ -192,9 +233,13 @@ def encode_safe_multisend_call(calls: list[TransactionCall]) -> HexString: "MAX_UINT256", "TransactionCall", "ctf_redeem_positions_call", + "decode_erc1155_is_approved_for_all_result", + "decode_erc20_allowance_result", "encode_proxy_call", "encode_safe_multisend_call", + "erc1155_is_approved_for_all_call", "erc1155_set_approval_for_all_call", + "erc20_allowance_call", "erc20_approval_call", "erc20_transfer_call", "merge_positions_call", diff --git a/src/polymarket/clients/async_public.py b/src/polymarket/clients/async_public.py index 5612704..4bf1f57 100644 --- a/src/polymarket/clients/async_public.py +++ b/src/polymarket/clients/async_public.py @@ -104,7 +104,7 @@ MarketSpec, PublicSubscription, SportsSpec, - _normalize_specs, + normalize_specs, ) if TYPE_CHECKING: @@ -192,7 +192,7 @@ async def subscribe( A subscription handle. Iterate over it to receive events and close it when finished. """ - items = _normalize_specs(specs) + items = normalize_specs(specs) # AsyncSubscriptionHandle is invariant in T, so per-channel handles # can't widen to the union type at the type level. Cast at the # boundary — the underlying queue holds whatever was pushed into it. diff --git a/src/polymarket/clients/async_secure.py b/src/polymarket/clients/async_secure.py index 190e40d..a3dbb5a 100644 --- a/src/polymarket/clients/async_secure.py +++ b/src/polymarket/clients/async_secure.py @@ -60,6 +60,9 @@ build_order_typed_data, ) from polymarket._internal.actions.orders.types import OrderDraft +from polymarket._internal.actions.relayer.approvals import ( + resolve_missing_trading_approval_calls, +) from polymarket._internal.actions.relayer.auth import make_relayer_header_resolver from polymarket._internal.actions.relayer.calls import ( MAX_UINT256, @@ -189,9 +192,10 @@ SecureSubscription, SportsSpec, UserSpec, - _normalize_specs, + normalize_specs, ) from polymarket.transactions import ( + DeprecatedTransactionHandle, EoaTransactionHandle, TransactionHandle, ) @@ -262,7 +266,7 @@ async def create( Args: private_key: EVM private key used for signing. - wallet: Wallet address to act for. Defaults to the signer's address. + wallet: Wallet address to act for. Defaults to the signer's current Deposit Wallet. credentials: Existing API credentials. When omitted, credentials are derived during client creation. api_key: Optional key for gasless wallet and relayed transaction workflows. @@ -272,7 +276,7 @@ async def create( UserInputError: If key material, wallet, nonce, or credentials are invalid. RequestRejectedError: If credential derivation or validation is rejected. """ - return await cls._create( + client = await cls._create( private_key=private_key, wallet=wallet, environment=environment, @@ -282,6 +286,11 @@ async def create( validate_credentials=True, logger=logger, ) + try: + return await client._ensure_wallet_ready() + except BaseException: + await client.close() + raise @classmethod async def _create( @@ -306,7 +315,12 @@ async def _create( except (ValueError, TypeError) as error: raise UserInputError(f"Invalid private_key: {error}") from error - resolved_wallet = wallet if wallet else signer.address + resolved_wallet = await _resolve_requested_wallet( + signer=signer, + wallet=wallet, + environment=environment, + logger=logger, + ) try: wallet_checksum = to_checksum_address(resolved_wallet) except ValueError as error: @@ -468,7 +482,7 @@ async def subscribe( A subscription handle. Iterate over it to receive events and close it when finished. """ - items = _normalize_specs(specs) + items = normalize_specs(specs) handles: list[AsyncSubscriptionHandle[Any]] = [] try: for spec in items: @@ -1844,147 +1858,48 @@ async def transfer_erc20( ) return await self._dispatch_single_call(call, metadata=resolved_metadata) - async def setup_trading_approvals(self) -> TransactionHandle: + async def setup_trading_approvals(self) -> DeprecatedTransactionHandle: """Approve the standard set of trading allowances for the wallet. EOA wallets submit approvals directly. Gasless wallets submit a relayed - transaction. The returned handle represents the final transaction in the - setup workflow. + transaction. Already-approved allowances are skipped, and the method waits + internally for any submitted transactions. Returns: - A transaction handle. Await ``wait()`` to wait for a terminal outcome. + A deprecated compatibility handle whose ``wait()`` returns immediately. """ - env = self._ctx.environment - collateral = cast(EvmAddress, env.collateral_token) - conditional = cast(EvmAddress, env.conditional_tokens) - calls = [ - erc20_approval_call( - token_address=collateral, - spender=cast(EvmAddress, env.standard_exchange), - amount=MAX_UINT256, - ), - erc20_approval_call( - token_address=collateral, - spender=cast(EvmAddress, env.neg_risk_exchange), - amount=MAX_UINT256, - ), - erc20_approval_call( - token_address=collateral, - spender=cast(EvmAddress, env.neg_risk_adapter), - amount=MAX_UINT256, - ), - erc20_approval_call( - token_address=collateral, - spender=cast(EvmAddress, env.collateral_adapter), - amount=MAX_UINT256, - ), - erc20_approval_call( - token_address=collateral, - spender=cast(EvmAddress, env.neg_risk_collateral_adapter), - amount=MAX_UINT256, - ), - erc1155_set_approval_for_all_call( - token_address=conditional, - operator=cast(EvmAddress, env.standard_exchange), - approved=True, - ), - erc1155_set_approval_for_all_call( - token_address=conditional, - operator=cast(EvmAddress, env.neg_risk_exchange), - approved=True, - ), - erc1155_set_approval_for_all_call( - token_address=conditional, - operator=cast(EvmAddress, env.neg_risk_adapter), - approved=True, - ), - erc1155_set_approval_for_all_call( - token_address=conditional, - operator=cast(EvmAddress, env.collateral_adapter), - approved=True, - ), - erc1155_set_approval_for_all_call( - token_address=conditional, - operator=cast(EvmAddress, env.neg_risk_collateral_adapter), - approved=True, - ), - erc1155_set_approval_for_all_call( - token_address=conditional, - operator=cast(EvmAddress, env.auto_redeem_operator), - approved=True, - ), - ] + calls = await resolve_missing_trading_approval_calls( + self._ctx.rpc, + wallet=self._ctx.wallet, + environment=self._ctx.environment, + ) + if not calls: + return DeprecatedTransactionHandle() if self._ctx.wallet_type == "EOA": - for call in calls[:-1]: + for call in calls: handle = await self._broadcast_eoa_call(call) await handle.wait() - return await self._broadcast_eoa_call(calls[-1]) - return await prepare_gasless_transaction( + return DeprecatedTransactionHandle() + handle = await prepare_gasless_transaction( self._ctx, calls=calls, metadata="Trading setup approvals" ) + await handle.wait() + return DeprecatedTransactionHandle() async def setup_gasless_wallet(self) -> Self: - """Create or reuse the gasless wallet for the signer. - - Returns: - A new async secure client scoped to the gasless wallet. + """Return this client. - Raises: - UserInputError: If the client was not created with an API key that - can authorize gasless wallet workflows. + Deprecated. Secure client creation now sets up the wallet required for + the selected trading flow. """ - ctx = self._ctx - if ctx.api_key is None: - raise UserInputError( - "setup_gasless_wallet requires a Builder API Key or Relayer API Key. " - "Pass api_key= when constructing the client." - ) - if ctx.wallet_type != "EOA": - return type(self)._construct_for_wallet( - signer=ctx.signer, - wallet=str(ctx.wallet), - environment=ctx.environment, - credentials=ctx.credentials, - api_key=ctx.api_key, - logger=self._streams_logger, - ) - deposit_address = cast( - EvmAddress, - await derive_current_deposit_wallet_address( - ctx.rpc, ctx.signer.address, ctx.environment.wallet_derivation - ), - ) - ready = await fetch_deployed( - ctx.relayer, - address=str(deposit_address), - type=RelayerTransactionType.WALLET, - ) - if not ready: - handle = await submit_deposit_wallet_create(ctx, metadata="Deploy Deposit Wallet") - await handle.wait() - return type(self)._construct_for_wallet( - signer=ctx.signer, - wallet=str(deposit_address), - environment=ctx.environment, - credentials=ctx.credentials, - api_key=ctx.api_key, - logger=self._streams_logger, - ) + return self async def is_gasless_ready(self) -> bool: - """Return whether the signer has a deployed gasless wallet ready to use.""" - ctx = self._ctx - if ctx.wallet_type != "EOA": - type_param = ( - RelayerTransactionType.WALLET if ctx.wallet_type == "DEPOSIT_WALLET" else None - ) - return await fetch_deployed(ctx.relayer, address=str(ctx.wallet), type=type_param) - deposit_address = await derive_current_deposit_wallet_address( - ctx.rpc, ctx.signer.address, ctx.environment.wallet_derivation - ) - return await fetch_deployed( - ctx.relayer, address=deposit_address, type=RelayerTransactionType.WALLET - ) + """Return True. + + Deprecated. Secure client creation now performs the required wallet setup. + """ + return True async def _broadcast_eoa_call(self, call: TransactionCall) -> EoaTransactionHandle: env = self._ctx.environment @@ -2004,6 +1919,38 @@ async def _dispatch_single_call( return await self._broadcast_eoa_call(call) return await prepare_gasless_transaction(self._ctx, calls=[call], metadata=metadata) + async def _ensure_wallet_ready(self) -> Self: + ctx = self._ctx + if ctx.wallet_type == "EOA": + return self + deployed = await fetch_deployed( + ctx.relayer, + address=str(ctx.wallet), + type=_relayer_transaction_type_for_wallet(ctx.wallet_type), + ) + if deployed: + return self + if ctx.wallet_type == "DEPOSIT_WALLET": + await self._deploy_default_deposit_wallet() + return self + raise UserInputError( + f"Wallet {ctx.wallet} does not exist. Provide an existing wallet address, " + "or omit wallet to use the default Deposit Wallet flow." + ) + + async def _deploy_default_deposit_wallet(self) -> None: + ctx = self._ctx + current_deposit_wallet = await derive_current_deposit_wallet_address( + ctx.rpc, ctx.signer.address, ctx.environment.wallet_derivation + ) + if str(ctx.wallet).lower() != current_deposit_wallet.lower(): + raise UserInputError( + f"Wallet {ctx.wallet} does not match the expected Deposit Wallet " + f"{current_deposit_wallet} for this signer, nor a deployed wallet address." + ) + handle = await submit_deposit_wallet_create(ctx, metadata="Deploy Deposit Wallet") + await handle.wait() + async def split_position( self, *, @@ -2352,6 +2299,37 @@ async def _bootstrap_credentials( return await _auth_actions.create_or_derive_api_key(clob, signature) +async def _resolve_requested_wallet( + *, + signer: LocalAccount, + wallet: str | None, + environment: Environment, + logger: logging.Logger | None, +) -> str: + if wallet is not None: + return wallet + rpc_transport = AsyncTransport(base_url=environment.rpc_url, logger=logger) + rpc = JsonRpcClient(rpc_transport) + try: + return await derive_current_deposit_wallet_address( + rpc, signer.address, environment.wallet_derivation + ) + finally: + await rpc.close() + + +def _relayer_transaction_type_for_wallet( + wallet_type: WalletType, +) -> RelayerTransactionType | None: + if wallet_type == "DEPOSIT_WALLET": + return RelayerTransactionType.WALLET + if wallet_type == "POLY_PROXY": + return RelayerTransactionType.PROXY + if wallet_type == "GNOSIS_SAFE": + return RelayerTransactionType.SAFE + return None + + async def _credentials_are_active( *, environment: Environment, diff --git a/src/polymarket/clients/secure.py b/src/polymarket/clients/secure.py index 4b4ddc7..ec97b20 100644 --- a/src/polymarket/clients/secure.py +++ b/src/polymarket/clients/secure.py @@ -61,6 +61,9 @@ build_order_typed_data, ) from polymarket._internal.actions.orders.types import OrderDraft +from polymarket._internal.actions.relayer.approvals import ( + resolve_missing_trading_approval_calls_sync, +) from polymarket._internal.actions.relayer.auth import make_relayer_header_resolver_sync from polymarket._internal.actions.relayer.calls import ( MAX_UINT256, @@ -171,7 +174,11 @@ ) from polymarket.models.types import ConditionId from polymarket.pagination import Page, Paginator -from polymarket.transactions import SyncEoaTransactionHandle, SyncTransactionHandle +from polymarket.transactions import ( + SyncDeprecatedTransactionHandle, + SyncEoaTransactionHandle, + SyncTransactionHandle, +) from polymarket.types import EvmAddress, HexString if TYPE_CHECKING: @@ -205,7 +212,6 @@ def __init__( raise RuntimeError("Use SecureClient.create(...) to create a secure client") self._ended = False self._ctx_inner = ctx - self._logger = logger @property def _ctx(self) -> SyncSecureClientContext: @@ -236,7 +242,7 @@ def create( Args: private_key: EVM private key used for signing. - wallet: Wallet address to act for. Defaults to the signer's address. + wallet: Wallet address to act for. Defaults to the signer's current Deposit Wallet. credentials: Existing API credentials. When omitted, credentials are derived during client creation. api_key: Optional key for gasless wallet and relayed transaction workflows. @@ -246,7 +252,7 @@ def create( UserInputError: If key material, wallet, nonce, or credentials are invalid. RequestRejectedError: If credential derivation or validation is rejected. """ - return cls._create( + client = cls._create( private_key=private_key, wallet=wallet, environment=environment, @@ -256,6 +262,11 @@ def create( validate_credentials=True, logger=logger, ) + try: + return client._ensure_wallet_ready() + except BaseException: + client.close() + raise @classmethod def _create( @@ -280,7 +291,12 @@ def _create( except (ValueError, TypeError) as error: raise UserInputError(f"Invalid private_key: {error}") from error - resolved_wallet = wallet if wallet else signer.address + resolved_wallet = _resolve_requested_wallet_sync( + signer=signer, + wallet=wallet, + environment=environment, + logger=logger, + ) bootstrap_clob = SyncTransport(base_url=environment.clob_url, logger=logger) try: @@ -1897,147 +1913,48 @@ def transfer_erc20( ) return self._dispatch_single_call(call, metadata=resolved_metadata) - def setup_trading_approvals(self) -> SyncTransactionHandle: + def setup_trading_approvals(self) -> SyncDeprecatedTransactionHandle: """Approve the standard set of trading allowances for the wallet. EOA wallets submit approvals directly. Gasless wallets submit a relayed - transaction. The returned handle represents the final transaction in the - setup workflow. + transaction. Already-approved allowances are skipped, and the method waits + internally for any submitted transactions. Returns: - A transaction handle. Call ``wait()`` to wait for a terminal outcome. + A deprecated compatibility handle whose ``wait()`` returns immediately. """ - env = self._ctx.environment - collateral = cast(EvmAddress, env.collateral_token) - conditional = cast(EvmAddress, env.conditional_tokens) - calls = [ - erc20_approval_call( - token_address=collateral, - spender=cast(EvmAddress, env.standard_exchange), - amount=MAX_UINT256, - ), - erc20_approval_call( - token_address=collateral, - spender=cast(EvmAddress, env.neg_risk_exchange), - amount=MAX_UINT256, - ), - erc20_approval_call( - token_address=collateral, - spender=cast(EvmAddress, env.neg_risk_adapter), - amount=MAX_UINT256, - ), - erc20_approval_call( - token_address=collateral, - spender=cast(EvmAddress, env.collateral_adapter), - amount=MAX_UINT256, - ), - erc20_approval_call( - token_address=collateral, - spender=cast(EvmAddress, env.neg_risk_collateral_adapter), - amount=MAX_UINT256, - ), - erc1155_set_approval_for_all_call( - token_address=conditional, - operator=cast(EvmAddress, env.standard_exchange), - approved=True, - ), - erc1155_set_approval_for_all_call( - token_address=conditional, - operator=cast(EvmAddress, env.neg_risk_exchange), - approved=True, - ), - erc1155_set_approval_for_all_call( - token_address=conditional, - operator=cast(EvmAddress, env.neg_risk_adapter), - approved=True, - ), - erc1155_set_approval_for_all_call( - token_address=conditional, - operator=cast(EvmAddress, env.collateral_adapter), - approved=True, - ), - erc1155_set_approval_for_all_call( - token_address=conditional, - operator=cast(EvmAddress, env.neg_risk_collateral_adapter), - approved=True, - ), - erc1155_set_approval_for_all_call( - token_address=conditional, - operator=cast(EvmAddress, env.auto_redeem_operator), - approved=True, - ), - ] + calls = resolve_missing_trading_approval_calls_sync( + self._ctx.rpc, + wallet=self._ctx.wallet, + environment=self._ctx.environment, + ) + if not calls: + return SyncDeprecatedTransactionHandle() if self._ctx.wallet_type == "EOA": - for call in calls[:-1]: + for call in calls: handle = self._broadcast_eoa_call(call) handle.wait() - return self._broadcast_eoa_call(calls[-1]) - return prepare_gasless_transaction_sync( + return SyncDeprecatedTransactionHandle() + handle = prepare_gasless_transaction_sync( self._ctx, calls=calls, metadata="Trading setup approvals" ) + handle.wait() + return SyncDeprecatedTransactionHandle() def setup_gasless_wallet(self) -> Self: - """Create or reuse the gasless wallet for the signer. - - Returns: - A new secure client scoped to the gasless wallet. + """Return this client. - Raises: - UserInputError: If the client was not created with an API key that - can authorize gasless wallet workflows. + Deprecated. Secure client creation now sets up the wallet required for + the selected trading flow. """ - ctx = self._ctx - if ctx.api_key is None: - raise UserInputError( - "setup_gasless_wallet requires a Builder API Key or Relayer API Key. " - "Pass api_key= when constructing the client." - ) - if ctx.wallet_type != "EOA": - return type(self)._construct_for_wallet( - signer=ctx.signer, - wallet=str(ctx.wallet), - environment=ctx.environment, - credentials=ctx.credentials, - api_key=ctx.api_key, - logger=self._logger, - ) - deposit_address = cast( - EvmAddress, - derive_current_deposit_wallet_address_sync( - ctx.rpc, ctx.signer.address, ctx.environment.wallet_derivation - ), - ) - ready = fetch_deployed_sync( - ctx.relayer, - address=str(deposit_address), - type=RelayerTransactionType.WALLET, - ) - if not ready: - handle = submit_deposit_wallet_create_sync(ctx, metadata="Deploy Deposit Wallet") - handle.wait() - return type(self)._construct_for_wallet( - signer=ctx.signer, - wallet=str(deposit_address), - environment=ctx.environment, - credentials=ctx.credentials, - api_key=ctx.api_key, - logger=self._logger, - ) + return self def is_gasless_ready(self) -> bool: - """Return whether the signer has a deployed gasless wallet ready to use.""" - ctx = self._ctx - if ctx.wallet_type != "EOA": - type_param = ( - RelayerTransactionType.WALLET if ctx.wallet_type == "DEPOSIT_WALLET" else None - ) - return fetch_deployed_sync(ctx.relayer, address=str(ctx.wallet), type=type_param) - deposit_address = derive_current_deposit_wallet_address_sync( - ctx.rpc, ctx.signer.address, ctx.environment.wallet_derivation - ) - return fetch_deployed_sync( - ctx.relayer, address=deposit_address, type=RelayerTransactionType.WALLET - ) + """Return True. + + Deprecated. Secure client creation now performs the required wallet setup. + """ + return True def split_position( self, @@ -2164,6 +2081,38 @@ def _dispatch_single_call( return self._broadcast_eoa_call(call) return prepare_gasless_transaction_sync(self._ctx, calls=[call], metadata=metadata) + def _ensure_wallet_ready(self) -> Self: + ctx = self._ctx + if ctx.wallet_type == "EOA": + return self + deployed = fetch_deployed_sync( + ctx.relayer, + address=str(ctx.wallet), + type=_relayer_transaction_type_for_wallet(ctx.wallet_type), + ) + if deployed: + return self + if ctx.wallet_type == "DEPOSIT_WALLET": + self._deploy_default_deposit_wallet() + return self + raise UserInputError( + f"Wallet {ctx.wallet} does not exist. Provide an existing wallet address, " + "or omit wallet to use the default Deposit Wallet flow." + ) + + def _deploy_default_deposit_wallet(self) -> None: + ctx = self._ctx + current_deposit_wallet = derive_current_deposit_wallet_address_sync( + ctx.rpc, ctx.signer.address, ctx.environment.wallet_derivation + ) + if str(ctx.wallet).lower() != current_deposit_wallet.lower(): + raise UserInputError( + f"Wallet {ctx.wallet} does not match the expected Deposit Wallet " + f"{current_deposit_wallet} for this signer, nor a deployed wallet address." + ) + handle = submit_deposit_wallet_create_sync(ctx, metadata="Deploy Deposit Wallet") + handle.wait() + def _resolve_market_neg_risk(self, condition_id: str) -> bool: page = self.list_markets(condition_ids=[condition_id], page_size=2).first_page() markets = page.items @@ -2212,6 +2161,37 @@ def _bootstrap_credentials_sync( return _auth_actions.create_or_derive_api_key_sync(clob, signature) +def _resolve_requested_wallet_sync( + *, + signer: LocalAccount, + wallet: str | None, + environment: Environment, + logger: logging.Logger | None, +) -> str: + if wallet is not None: + return wallet + rpc_transport = SyncTransport(base_url=environment.rpc_url, logger=logger) + rpc = SyncJsonRpcClient(rpc_transport) + try: + return derive_current_deposit_wallet_address_sync( + rpc, signer.address, environment.wallet_derivation + ) + finally: + rpc.close() + + +def _relayer_transaction_type_for_wallet( + wallet_type: WalletType, +) -> RelayerTransactionType | None: + if wallet_type == "DEPOSIT_WALLET": + return RelayerTransactionType.WALLET + if wallet_type == "POLY_PROXY": + return RelayerTransactionType.PROXY + if wallet_type == "GNOSIS_SAFE": + return RelayerTransactionType.SAFE + return None + + def _credentials_are_active_sync( *, environment: Environment, diff --git a/src/polymarket/streams/_specs.py b/src/polymarket/streams/_specs.py index c264345..94d76a0 100644 --- a/src/polymarket/streams/_specs.py +++ b/src/polymarket/streams/_specs.py @@ -210,7 +210,7 @@ def __post_init__(self) -> None: _S = TypeVar("_S", bound=Subscription) -def _normalize_specs(specs: _S | Sequence[_S]) -> list[_S]: +def normalize_specs(specs: _S | Sequence[_S]) -> list[_S]: if isinstance(specs, _SPEC_TYPES): return [specs] if not isinstance(specs, Sequence) or isinstance(specs, str | bytes): @@ -240,5 +240,5 @@ def _normalize_specs(specs: _S | Sequence[_S]) -> list[_S]: "SportsSpec", "Subscription", "UserSpec", - "_normalize_specs", + "normalize_specs", ] diff --git a/src/polymarket/transactions.py b/src/polymarket/transactions.py index 2efae1d..5d6b9c5 100644 --- a/src/polymarket/transactions.py +++ b/src/polymarket/transactions.py @@ -59,6 +59,18 @@ async def wait(self) -> TransactionOutcome: ) +@dataclass(frozen=True, slots=True) +class DeprecatedTransactionHandle: + """Compatibility handle for workflows that now wait internally.""" + + transaction_hash: None = None + transaction_id: None = None + + async def wait(self) -> None: + """Return immediately; retained for backward compatibility.""" + return None + + @dataclass(frozen=True, slots=True) class SyncGaslessTransactionHandle: """Synchronous handle for a relayed gasless transaction.""" @@ -108,6 +120,18 @@ def wait(self) -> TransactionOutcome: ) +@dataclass(frozen=True, slots=True) +class SyncDeprecatedTransactionHandle: + """Synchronous compatibility handle for workflows that now wait internally.""" + + transaction_hash: None = None + transaction_id: None = None + + def wait(self) -> None: + """Return immediately; retained for backward compatibility.""" + return None + + TransactionHandle: TypeAlias = GaslessTransactionHandle | EoaTransactionHandle """Async transaction handle returned by async wallet methods.""" @@ -118,6 +142,8 @@ def wait(self) -> TransactionOutcome: __all__ = [ "EoaTransactionHandle", "GaslessTransactionHandle", + "DeprecatedTransactionHandle", + "SyncDeprecatedTransactionHandle", "SyncEoaTransactionHandle", "SyncGaslessTransactionHandle", "SyncTransactionHandle", diff --git a/tests/integration/test_relayer_approve_live.py b/tests/integration/test_relayer_approve_live.py index 783623f..fb6f482 100644 --- a/tests/integration/test_relayer_approve_live.py +++ b/tests/integration/test_relayer_approve_live.py @@ -68,11 +68,20 @@ async def run() -> str: @pytest.mark.integration -@pytest.mark.metered -def test_is_gasless_ready_live(require_env: Callable[[str], str]) -> None: - async def run() -> bool: - async with _secure_client(require_env) as client: - return await client.is_gasless_ready() +def test_secure_client_create_defaults_to_deposit_wallet( + require_env: Callable[[str], str], +) -> None: + async def run() -> None: + expected_wallet = require_env("POLYMARKET_DEPOSIT_WALLET") + client = await AsyncSecureClient.create( + private_key=require_env("POLYMARKET_PRIVATE_KEY"), + credentials=_existing_user_credentials(), + ) + try: + assert client.wallet_type == "DEPOSIT_WALLET" + assert str(client.wallet).lower() == expected_wallet.lower() + finally: + await client.close() asyncio.run(asyncio.wait_for(run(), timeout=30.0)) diff --git a/tests/unit/_relayer_helpers.py b/tests/unit/_relayer_helpers.py index 82afb9e..54a332f 100644 --- a/tests/unit/_relayer_helpers.py +++ b/tests/unit/_relayer_helpers.py @@ -8,6 +8,7 @@ from urllib.parse import urlparse import httpx +from eth_utils.crypto import keccak from polymarket import ApiKeyCreds, AsyncSecureClient, BuilderApiKey, SecureClient from polymarket.clients._transport import AsyncTransport @@ -145,6 +146,65 @@ def handler(request: httpx.Request) -> httpx.Response: return handler +def trading_approval_rpc_handler( + *, + allowance: int = 0, + approved: bool = False, + nonce: int = 7, + gas_price: int = 30_000_000_000, + gas_estimate: int = 100_000, + send_response: str | None = None, + receipt_responses: list[dict[str, object] | None] | None = None, + chain_id: int = 137, +) -> Callable[[httpx.Request], httpx.Response]: + captured: list[dict[str, object]] = [] + receipt_iter = iter(receipt_responses or []) + allowance_selector = "0x" + keccak(b"allowance(address,address)")[:4].hex() + approved_selector = "0x" + keccak(b"isApprovedForAll(address,address)")[:4].hex() + + def handler(request: httpx.Request) -> httpx.Response: + body = json.loads(request.content.decode("utf-8")) + captured.append(body) + method = body["method"] + if method == "eth_call": + data = body["params"][0]["data"] + if data.startswith(allowance_selector): + result: object = "0x" + hex(allowance)[2:].rjust(64, "0") + elif data.startswith(approved_selector): + result = "0x" + ("1" if approved else "0").rjust(64, "0") + else: + result = "0x" + "0" * 64 + elif method == "eth_chainId": + result = hex(chain_id) + elif method == "eth_getTransactionCount": + result = hex(nonce) + elif method == "eth_gasPrice": + result = hex(gas_price) + elif method == "eth_estimateGas": + result = hex(gas_estimate) + elif method == "eth_sendRawTransaction": + result = send_response or ("0x" + "ab" * 32) + elif method == "eth_getTransactionReceipt": + try: + result = next(receipt_iter) + except StopIteration: + result = None + else: + return httpx.Response( + 200, + json={"jsonrpc": "2.0", "id": body["id"], "error": {"message": "unmocked"}}, + request=request, + ) + return httpx.Response( + 200, + json={"jsonrpc": "2.0", "id": body["id"], "result": result}, + request=request, + ) + + handler.captured = captured # type: ignore[attr-defined] + return handler + + async def make_safe_client() -> AsyncSecureClient: from eth_account import Account @@ -379,4 +439,5 @@ def request_json(request: httpx.Request) -> Any: "make_sync_proxy_client", "make_sync_safe_client", "request_json", + "trading_approval_rpc_handler", ] diff --git a/tests/unit/test_account_transport.py b/tests/unit/test_account_transport.py index 9442122..12e2141 100644 --- a/tests/unit/test_account_transport.py +++ b/tests/unit/test_account_transport.py @@ -393,11 +393,14 @@ async def run() -> None: asyncio.run(run()) -def test_secure_client_defaults_wallet_to_signer_address() -> None: +def test_secure_client_defaults_wallet_to_current_deposit_wallet() -> None: from eth_account import Account - from eth_utils.address import to_checksum_address - expected = to_checksum_address(Account.from_key(PRIVATE_KEY).address) + from polymarket._internal.wallet import derive_uups_deposit_wallet_address + from polymarket.environments import PRODUCTION + + signer = Account.from_key(PRIVATE_KEY) + expected = derive_uups_deposit_wallet_address(signer.address, PRODUCTION.wallet_derivation) async def run() -> str: client = await AsyncSecureClient._create( @@ -406,7 +409,7 @@ async def run() -> str: validate_credentials=False, ) try: - assert client.wallet_type == "EOA" + assert client.wallet_type == "DEPOSIT_WALLET" return str(client.wallet) finally: await client.close() diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 5d34152..d4a3ed9 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -147,18 +147,21 @@ def test_secure_client_invalid_key_raises_user_input_error() -> None: SecureClient.create(private_key="not-a-valid-key", wallet=SIGNER_ADDRESS) -def test_secure_client_wallet_defaults_to_signer_when_omitted() -> None: +def test_secure_client_wallet_defaults_to_current_deposit_wallet_when_omitted() -> None: from eth_account import Account - from eth_utils.address import to_checksum_address - expected = to_checksum_address(Account.from_key(PRIVATE_KEY).address) + from polymarket._internal.wallet import derive_uups_deposit_wallet_address + from polymarket.environments import PRODUCTION + + signer = Account.from_key(PRIVATE_KEY) + expected = derive_uups_deposit_wallet_address(signer.address, PRODUCTION.wallet_derivation) with SecureClient._create( private_key=PRIVATE_KEY, credentials=FAKE_CREDS, validate_credentials=False, ) as client: - assert client.wallet_type == "EOA" + assert client.wallet_type == "DEPOSIT_WALLET" assert str(client.wallet) == expected @@ -170,19 +173,23 @@ async def run() -> None: asyncio.run(run()) -def test_async_secure_client_wallet_defaults_to_signer_when_omitted() -> None: +def test_async_secure_client_wallet_defaults_to_current_deposit_wallet_when_omitted() -> None: from eth_account import Account - from eth_utils.address import to_checksum_address - expected = to_checksum_address(Account.from_key(PRIVATE_KEY).address) + from polymarket._internal.wallet import derive_uups_deposit_wallet_address + from polymarket.environments import PRODUCTION + + signer = Account.from_key(PRIVATE_KEY) + expected = derive_uups_deposit_wallet_address(signer.address, PRODUCTION.wallet_derivation) async def run() -> str: client = await AsyncSecureClient._create( private_key=PRIVATE_KEY, + credentials=FAKE_CREDS, validate_credentials=False, ) try: - assert client.wallet_type == "EOA" + assert client.wallet_type == "DEPOSIT_WALLET" return str(client.wallet) finally: await client.close() diff --git a/tests/unit/test_eoa_broadcast.py b/tests/unit/test_eoa_broadcast.py index 46ddbdc..4bfce57 100644 --- a/tests/unit/test_eoa_broadcast.py +++ b/tests/unit/test_eoa_broadcast.py @@ -8,10 +8,11 @@ make_eoa_client_with_rpc, make_rpc_handler, ) +from eth_utils.crypto import keccak from polymarket import TransactionCall from polymarket.errors import TimeoutError, TransactionFailedError, UserInputError -from polymarket.transactions import EoaTransactionHandle +from polymarket.transactions import DeprecatedTransactionHandle, EoaTransactionHandle from polymarket.types import EvmAddress _TOKEN = EvmAddress("0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB") @@ -134,10 +135,10 @@ async def run() -> None: asyncio.run(run()) -def test_eoa_setup_trading_approvals_submits_eleven_sequentially() -> None: +def test_eoa_setup_trading_approvals_submits_and_waits_for_eleven_sequentially() -> None: send_hashes = ["0x" + f"{i:02x}" * 32 for i in range(1, 12)] send_iter = iter(send_hashes) - receipts: list[dict[str, object] | None] = [{"status": "0x1"} for _ in range(10)] + receipts: list[dict[str, object] | None] = [{"status": "0x1"} for _ in range(11)] receipt_iter = iter(receipts) calls: list[dict[str, object]] = [] @@ -149,6 +150,14 @@ def handler(request: httpx.Request) -> httpx.Response: method = body["method"] if method == "eth_chainId": result: object = hex(137) + elif method == "eth_call": + data = body["params"][0]["data"] + allowance_selector = "0x" + keccak(b"allowance(address,address)")[:4].hex() + approved_selector = "0x" + keccak(b"isApprovedForAll(address,address)")[:4].hex() + if data.startswith(allowance_selector) or data.startswith(approved_selector): + result = "0x" + "0" * 64 + else: + result = "0x" + "0" * 64 elif method == "eth_getTransactionCount": result = hex(7) elif method == "eth_gasPrice": @@ -182,12 +191,12 @@ async def run() -> object: await client.close() handle = asyncio.run(run()) - assert isinstance(handle, EoaTransactionHandle) - assert handle.transaction_hash == send_hashes[-1] + assert isinstance(handle, DeprecatedTransactionHandle) + assert handle.transaction_hash is None send_methods = [c for c in calls if c["method"] == "eth_sendRawTransaction"] assert len(send_methods) == 11 receipt_methods = [c for c in calls if c["method"] == "eth_getTransactionReceipt"] - assert len(receipt_methods) >= 10 + assert len(receipt_methods) >= 11 def test_rpc_client_closes_with_client() -> None: diff --git a/tests/unit/test_relayer_is_gasless_ready.py b/tests/unit/test_relayer_is_gasless_ready.py deleted file mode 100644 index 7a5af0f..0000000 --- a/tests/unit/test_relayer_is_gasless_ready.py +++ /dev/null @@ -1,203 +0,0 @@ -# pyright: reportPrivateUsage=false -import asyncio -from urllib.parse import parse_qs, urlparse - -import httpx -from _relayer_helpers import ( - FAKE_CREDS, - PK_DEPLOY_WALLET, - beacon_factory_rpc_handler, - install_relayer_handler, - install_rpc_handler, - legacy_factory_rpc_handler, - make_deposit_client, - make_proxy_client, - make_safe_client, -) - -from polymarket import AsyncSecureClient -from polymarket._internal.wallet import ( - derive_beacon_deposit_wallet_address, - derive_uups_deposit_wallet_address, -) -from polymarket.environments import PRODUCTION - - -async def _make_eoa_secure_client() -> AsyncSecureClient: - from eth_account import Account - - signer = Account.from_key(PK_DEPLOY_WALLET) - return await AsyncSecureClient._create( - private_key=PK_DEPLOY_WALLET, - wallet=signer.address, - credentials=FAKE_CREDS, - validate_credentials=False, - ) - - -def test_is_gasless_ready_eoa_legacy_factory_queries_uups_deposit_wallet() -> None: - captured: list[httpx.Request] = [] - - async def run() -> tuple[bool, str]: - client = await _make_eoa_secure_client() - signer_address = client._ctx.signer.address - - def handler(request: httpx.Request) -> httpx.Response: - captured.append(request) - return httpx.Response(200, json={"deployed": True}, request=request) - - install_relayer_handler(client, handler) - install_rpc_handler(client, legacy_factory_rpc_handler()) - try: - result = await client.is_gasless_ready() - return result, signer_address - finally: - await client.close() - - result, signer_address = asyncio.run(run()) - expected = derive_uups_deposit_wallet_address(signer_address, PRODUCTION.wallet_derivation) - assert result is True - assert len(captured) == 1 - parsed = urlparse(str(captured[0].url)) - assert parsed.path == "/deployed" - qs = parse_qs(parsed.query) - assert qs["type"] == ["WALLET"] - assert qs["address"] == [expected] - - -def test_is_gasless_ready_eoa_beacon_factory_queries_beacon_deposit_wallet() -> None: - captured: list[httpx.Request] = [] - - async def run() -> tuple[bool, str]: - client = await _make_eoa_secure_client() - signer_address = client._ctx.signer.address - - def handler(request: httpx.Request) -> httpx.Response: - captured.append(request) - return httpx.Response(200, json={"deployed": False}, request=request) - - install_relayer_handler(client, handler) - install_rpc_handler( - client, beacon_factory_rpc_handler(PRODUCTION.wallet_derivation.deposit_wallet_beacon) - ) - try: - result = await client.is_gasless_ready() - return result, signer_address - finally: - await client.close() - - result, signer_address = asyncio.run(run()) - expected = derive_beacon_deposit_wallet_address(signer_address, PRODUCTION.wallet_derivation) - assert result is False - assert len(captured) == 1 - parsed = urlparse(str(captured[0].url)) - qs = parse_qs(parsed.query) - assert qs["type"] == ["WALLET"] - assert qs["address"] == [expected] - - -def test_is_gasless_ready_eoa_propagates_generic_rpc_failure() -> None: - import pytest - - from polymarket._internal.eoa.rpc import JsonRpcCallError - - async def run() -> bool: - client = await _make_eoa_secure_client() - - def rpc_handler(request: httpx.Request) -> httpx.Response: - import json - - body = json.loads(request.content.decode("utf-8")) - return httpx.Response( - 200, - json={ - "jsonrpc": "2.0", - "id": body["id"], - "error": {"code": -32_603, "message": "upstream unavailable"}, - }, - request=request, - ) - - install_relayer_handler( - client, lambda r: httpx.Response(500, json={"error": "unused"}, request=r) - ) - install_rpc_handler(client, rpc_handler) - try: - return await client.is_gasless_ready() - finally: - await client.close() - - with pytest.raises(JsonRpcCallError, match="upstream unavailable"): - asyncio.run(run()) - - -def test_is_gasless_ready_deposit_wallet_sends_type_wallet() -> None: - captured: list[httpx.Request] = [] - - async def run() -> bool: - client = await make_deposit_client() - - def handler(request: httpx.Request) -> httpx.Response: - captured.append(request) - return httpx.Response(200, json={"deployed": True}, request=request) - - install_relayer_handler(client, handler) - try: - return await client.is_gasless_ready() - finally: - await client.close() - - result = asyncio.run(run()) - assert result is True - assert len(captured) == 1 - parsed = urlparse(str(captured[0].url)) - assert parsed.path == "/deployed" - qs = parse_qs(parsed.query) - assert qs["type"] == ["WALLET"] - - -def test_is_gasless_ready_proxy_omits_type() -> None: - captured: list[httpx.Request] = [] - - async def run() -> bool: - client = await make_proxy_client() - - def handler(request: httpx.Request) -> httpx.Response: - captured.append(request) - return httpx.Response(200, json={"deployed": False}, request=request) - - install_relayer_handler(client, handler) - try: - return await client.is_gasless_ready() - finally: - await client.close() - - result = asyncio.run(run()) - assert result is False - parsed = urlparse(str(captured[0].url)) - assert parsed.path == "/deployed" - qs = parse_qs(parsed.query) - assert "type" not in qs - - -def test_is_gasless_ready_safe_omits_type() -> None: - captured: list[httpx.Request] = [] - - async def run() -> bool: - client = await make_safe_client() - - def handler(request: httpx.Request) -> httpx.Response: - captured.append(request) - return httpx.Response(200, json={"deployed": True}, request=request) - - install_relayer_handler(client, handler) - try: - return await client.is_gasless_ready() - finally: - await client.close() - - result = asyncio.run(run()) - assert result is True - parsed = urlparse(str(captured[0].url)) - qs = parse_qs(parsed.query) - assert "type" not in qs diff --git a/tests/unit/test_relayer_setup_gasless_wallet.py b/tests/unit/test_relayer_setup_gasless_wallet.py deleted file mode 100644 index 6a0898a..0000000 --- a/tests/unit/test_relayer_setup_gasless_wallet.py +++ /dev/null @@ -1,294 +0,0 @@ -# pyright: reportPrivateUsage=false -import asyncio -import dataclasses -from urllib.parse import parse_qs, urlparse - -import httpx -import pytest -from _relayer_helpers import ( - beacon_factory_rpc_handler, - install_relayer_handler, - install_rpc_handler, - legacy_factory_rpc_handler, - make_deposit_client, - make_eoa_client, - make_proxy_client, - make_safe_client, - request_json, -) - -from polymarket._internal.wallet import ( - derive_beacon_deposit_wallet_address, - derive_uups_deposit_wallet_address, -) -from polymarket.environments import PRODUCTION -from polymarket.errors import UserInputError - - -def test_setup_gasless_wallet_rejects_when_no_api_key() -> None: - async def run() -> None: - client = await make_eoa_client(with_api_key=False) - try: - with pytest.raises(UserInputError, match="Builder API Key or Relayer API Key"): - await client.setup_gasless_wallet() - finally: - await client.close() - - asyncio.run(run()) - - -def test_setup_gasless_wallet_returns_new_client_for_deposit_wallet() -> None: - async def run() -> None: - client = await make_deposit_client() - try: - returned = await client.setup_gasless_wallet() - try: - assert returned is not client - assert returned.wallet_type == "DEPOSIT_WALLET" - assert returned.wallet == client.wallet - assert returned.signer == client.signer - finally: - await returned.close() - finally: - await client.close() - - asyncio.run(run()) - - -def test_setup_gasless_wallet_returns_new_client_for_proxy() -> None: - async def run() -> None: - client = await make_proxy_client() - try: - returned = await client.setup_gasless_wallet() - try: - assert returned is not client - assert returned.wallet_type == "POLY_PROXY" - assert returned.wallet == client.wallet - finally: - await returned.close() - finally: - await client.close() - - asyncio.run(run()) - - -def test_setup_gasless_wallet_returns_new_client_for_safe() -> None: - async def run() -> None: - client = await make_safe_client() - try: - returned = await client.setup_gasless_wallet() - try: - assert returned is not client - assert returned.wallet_type == "GNOSIS_SAFE" - assert returned.wallet == client.wallet - finally: - await returned.close() - finally: - await client.close() - - asyncio.run(run()) - - -def test_setup_gasless_wallet_eoa_skips_deploy_when_already_deployed() -> None: - captured: list[httpx.Request] = [] - - async def run() -> tuple[str, str]: - client = await make_eoa_client() - eoa_address = client._ctx.signer.address - expected_deposit = derive_uups_deposit_wallet_address( - eoa_address, PRODUCTION.wallet_derivation - ) - - def handler(request: httpx.Request) -> httpx.Response: - captured.append(request) - path = urlparse(str(request.url)).path - if path == "/deployed": - return httpx.Response(200, json={"deployed": True}, request=request) - return httpx.Response(404, request=request) - - install_relayer_handler(client, handler) - install_rpc_handler(client, legacy_factory_rpc_handler()) - try: - new_client = await client.setup_gasless_wallet() - try: - assert new_client is not client - assert client.wallet_type == "EOA" - assert new_client.wallet_type == "DEPOSIT_WALLET" - return str(new_client.wallet), expected_deposit - finally: - await new_client.close() - finally: - await client.close() - - actual, expected = asyncio.run(run()) - assert actual == expected - submit_calls = [r for r in captured if urlparse(str(r.url)).path == "/submit"] - assert submit_calls == [] - deployed_calls = [r for r in captured if urlparse(str(r.url)).path == "/deployed"] - assert len(deployed_calls) == 1 - qs = parse_qs(urlparse(str(deployed_calls[0].url)).query) - assert qs["type"] == ["WALLET"] - - -def test_setup_gasless_wallet_eoa_deploys_when_not_deployed() -> None: - captured: list[httpx.Request] = [] - deployed_calls_seen = 0 - - async def run() -> str: - nonlocal deployed_calls_seen - client = await make_eoa_client() - - def handler(request: httpx.Request) -> httpx.Response: - nonlocal deployed_calls_seen - captured.append(request) - path = urlparse(str(request.url)).path - if path == "/deployed": - deployed_calls_seen += 1 - return httpx.Response(200, json={"deployed": False}, request=request) - if path == "/submit": - return httpx.Response( - 200, - json={ - "state": "STATE_NEW", - "transactionHash": None, - "transactionID": "tx-deploy", - }, - request=request, - ) - if path.startswith("/v1/account/transactions/"): - return httpx.Response( - 200, - json={ - "state": "STATE_MINED", - "transaction_hash": "0x" + "ab" * 32, - "transaction_id": "tx-deploy", - }, - request=request, - ) - return httpx.Response(404, request=request) - - install_relayer_handler(client, handler) - install_rpc_handler(client, legacy_factory_rpc_handler()) - client._ctx = dataclasses.replace( - client._ctx, - environment=dataclasses.replace(client._ctx.environment, relayer_poll_frequency_ms=1), - ) - try: - new_client = await client.setup_gasless_wallet() - try: - assert new_client is not client - assert client.wallet_type == "EOA" - assert new_client.wallet_type == "DEPOSIT_WALLET" - return str(new_client.wallet) - finally: - await new_client.close() - finally: - await client.close() - - asyncio.run(run()) - assert deployed_calls_seen == 1 - submit_calls = [r for r in captured if urlparse(str(r.url)).path == "/submit"] - assert len(submit_calls) == 1 - body = request_json(submit_calls[0]) - assert body["type"] == "WALLET-CREATE" - assert "signature" not in body - assert body["metadata"] == "Deploy Deposit Wallet" - - -def test_setup_gasless_wallet_eoa_uses_beacon_factory_when_available() -> None: - captured: list[httpx.Request] = [] - - async def run() -> tuple[str, str]: - client = await make_eoa_client() - eoa_address = client._ctx.signer.address - expected = derive_beacon_deposit_wallet_address(eoa_address, PRODUCTION.wallet_derivation) - - def handler(request: httpx.Request) -> httpx.Response: - captured.append(request) - path = urlparse(str(request.url)).path - if path == "/deployed": - return httpx.Response(200, json={"deployed": True}, request=request) - return httpx.Response(404, request=request) - - install_relayer_handler(client, handler) - install_rpc_handler( - client, - beacon_factory_rpc_handler(PRODUCTION.wallet_derivation.deposit_wallet_beacon), - ) - try: - new_client = await client.setup_gasless_wallet() - try: - return str(new_client.wallet), expected - finally: - await new_client.close() - finally: - await client.close() - - actual, expected = asyncio.run(run()) - assert actual == expected - deployed_calls = [r for r in captured if urlparse(str(r.url)).path == "/deployed"] - assert len(deployed_calls) == 1 - qs = parse_qs(urlparse(str(deployed_calls[0].url)).query) - assert qs["address"] == [expected] - - -def test_setup_gasless_wallet_eoa_propagates_generic_rpc_failure() -> None: - import pytest - - from polymarket._internal.eoa.rpc import JsonRpcCallError - - async def run() -> None: - client = await make_eoa_client() - - def rpc_handler(request: httpx.Request) -> httpx.Response: - import json - - body = json.loads(request.content.decode("utf-8")) - return httpx.Response( - 200, - json={ - "jsonrpc": "2.0", - "id": body["id"], - "error": {"code": -32_603, "message": "upstream unavailable"}, - }, - request=request, - ) - - install_rpc_handler(client, rpc_handler) - install_relayer_handler( - client, - lambda r: httpx.Response(500, json={"error": "unused"}, request=r), - ) - try: - await client.setup_gasless_wallet() - finally: - await client.close() - - with pytest.raises(JsonRpcCallError, match="upstream unavailable"): - asyncio.run(run()) - - -def test_setup_gasless_wallet_returns_independent_client_with_fresh_transports() -> None: - captured: list[httpx.Request] = [] - - async def run() -> str: - client = await make_eoa_client() - - def handler(request: httpx.Request) -> httpx.Response: - captured.append(request) - path = urlparse(str(request.url)).path - if path == "/deployed": - return httpx.Response(200, json={"deployed": True}, request=request) - return httpx.Response(404, request=request) - - install_relayer_handler(client, handler) - install_rpc_handler(client, legacy_factory_rpc_handler()) - async with client: - returned = await client.setup_gasless_wallet() - async with returned: - assert returned is not client - assert returned.wallet_type == "DEPOSIT_WALLET" - assert client.wallet_type == "EOA" - return str(returned.wallet) - - asyncio.run(run()) diff --git a/tests/unit/test_relayer_setup_trading_approvals.py b/tests/unit/test_relayer_setup_trading_approvals.py index 0cd8789..58603a7 100644 --- a/tests/unit/test_relayer_setup_trading_approvals.py +++ b/tests/unit/test_relayer_setup_trading_approvals.py @@ -5,9 +5,11 @@ import httpx from _relayer_helpers import ( install_relayer_routes, + install_rpc_handler, make_deposit_client, make_safe_client, request_json, + trading_approval_rpc_handler, ) from eth_utils.crypto import keccak @@ -36,8 +38,14 @@ async def run() -> None: "transactionHash": None, "transactionID": "tx-setup", }, + "/v1/account/transactions/tx-setup": { + "state": "STATE_MINED", + "transaction_hash": "0x" + "ab" * 32, + "transaction_id": "tx-setup", + }, }, ) + install_rpc_handler(client, trading_approval_rpc_handler()) try: await client.setup_trading_approvals() finally: @@ -86,6 +94,27 @@ async def run() -> None: assert body["metadata"] == "Trading setup approvals" +def test_setup_trading_approvals_skips_submit_when_already_approved() -> None: + captured: list[httpx.Request] = [] + + async def run() -> None: + client = await make_deposit_client() + install_relayer_routes(client, captured, {}) + install_rpc_handler( + client, + trading_approval_rpc_handler(allowance=(1 << 256) - 1, approved=True), + ) + try: + handle = await client.setup_trading_approvals() + await handle.wait() + finally: + await client.close() + + asyncio.run(run()) + submit_calls = [r for r in captured if urlparse(str(r.url)).path == "/submit"] + assert submit_calls == [] + + def test_setup_trading_approvals_uses_safe_multisend_for_safe() -> None: captured: list[httpx.Request] = [] @@ -104,8 +133,14 @@ async def run() -> None: "transactionHash": None, "transactionID": "tx-setup-safe", }, + "/v1/account/transactions/tx-setup-safe": { + "state": "STATE_MINED", + "transaction_hash": "0x" + "cd" * 32, + "transaction_id": "tx-setup-safe", + }, }, ) + install_rpc_handler(client, trading_approval_rpc_handler()) try: await client.setup_trading_approvals() finally: diff --git a/tests/unit/test_secure_auth_sync.py b/tests/unit/test_secure_auth_sync.py index 40c25c9..7d9bf3e 100644 --- a/tests/unit/test_secure_auth_sync.py +++ b/tests/unit/test_secure_auth_sync.py @@ -169,14 +169,22 @@ def test_create_rejects_bool_nonce() -> None: ) -def test_create_defaults_wallet_to_signer_when_omitted() -> None: +def test_create_defaults_wallet_to_current_deposit_wallet() -> None: + from eth_account import Account + + from polymarket._internal.wallet import derive_uups_deposit_wallet_address + from polymarket.environments import PRODUCTION + + signer = Account.from_key(PRIVATE_KEY) + expected = derive_uups_deposit_wallet_address(signer.address, PRODUCTION.wallet_derivation) + with SecureClient._create( private_key=PRIVATE_KEY, credentials=FAKE_CREDS, validate_credentials=False, ) as client: - assert client.wallet_type == "EOA" - assert str(client.wallet) == SIGNER_ADDRESS + assert client.wallet_type == "DEPOSIT_WALLET" + assert str(client.wallet) == expected def test_create_rejects_invalid_wallet_address() -> None: diff --git a/tests/unit/test_sync_relayer_workflows.py b/tests/unit/test_sync_relayer_workflows.py index 7b6ab37..f6ad2bb 100644 --- a/tests/unit/test_sync_relayer_workflows.py +++ b/tests/unit/test_sync_relayer_workflows.py @@ -1,28 +1,23 @@ # pyright: reportPrivateUsage=false import dataclasses import json -from urllib.parse import parse_qs, urlparse +from urllib.parse import urlparse import httpx import pytest from _relayer_helpers import ( SPENDER, TOKEN, - beacon_factory_rpc_handler, install_sync_relayer_handler, install_sync_rpc_handler, - legacy_factory_rpc_handler, make_sync_deposit_client, make_sync_eoa_client, make_sync_proxy_client, make_sync_safe_client, request_json, + trading_approval_rpc_handler, ) -from polymarket._internal.wallet import ( - derive_beacon_deposit_wallet_address, - derive_uups_deposit_wallet_address, -) from polymarket.environments import PRODUCTION from polymarket.errors import ( TimeoutError as PolyTimeoutError, @@ -30,7 +25,6 @@ from polymarket.errors import ( TransactionFailedError, UnexpectedResponseError, - UserInputError, ) from polymarket.transactions import SyncEoaTransactionHandle, SyncGaslessTransactionHandle @@ -106,149 +100,6 @@ def rpc_handler(request: httpx.Request) -> httpx.Response: assert "eth_sendRawTransaction" in methods -def test_is_gasless_ready_deposit_wallet_returns_true() -> None: - captured: list[httpx.Request] = [] - - def handler(request: httpx.Request) -> httpx.Response: - captured.append(request) - return httpx.Response(200, json={"deployed": True}, request=request) - - with make_sync_deposit_client() as client: - install_sync_relayer_handler(client, handler) - result = client.is_gasless_ready() - - assert result is True - qs = parse_qs(urlparse(str(captured[0].url)).query) - assert qs["type"] == ["WALLET"] - - -def test_is_gasless_ready_eoa_legacy_factory_queries_uups_address() -> None: - captured: list[httpx.Request] = [] - - def handler(request: httpx.Request) -> httpx.Response: - captured.append(request) - return httpx.Response(200, json={"deployed": True}, request=request) - - with make_sync_eoa_client() as client: - signer_address = client._ctx.signer.address - install_sync_relayer_handler(client, handler) - install_sync_rpc_handler(client, legacy_factory_rpc_handler()) - assert client.is_gasless_ready() is True - - expected = derive_uups_deposit_wallet_address(signer_address, PRODUCTION.wallet_derivation) - qs = parse_qs(urlparse(str(captured[0].url)).query) - assert qs["address"] == [expected] - assert qs["type"] == ["WALLET"] - - -def test_is_gasless_ready_eoa_beacon_factory_queries_beacon_address() -> None: - captured: list[httpx.Request] = [] - - def handler(request: httpx.Request) -> httpx.Response: - captured.append(request) - return httpx.Response(200, json={"deployed": False}, request=request) - - with make_sync_eoa_client() as client: - signer_address = client._ctx.signer.address - install_sync_relayer_handler(client, handler) - install_sync_rpc_handler( - client, - beacon_factory_rpc_handler(PRODUCTION.wallet_derivation.deposit_wallet_beacon), - ) - assert client.is_gasless_ready() is False - - expected = derive_beacon_deposit_wallet_address(signer_address, PRODUCTION.wallet_derivation) - qs = parse_qs(urlparse(str(captured[0].url)).query) - assert qs["address"] == [expected] - assert qs["type"] == ["WALLET"] - - -def test_setup_gasless_wallet_rejects_without_api_key() -> None: - with ( - pytest.raises(UserInputError, match="Builder API Key or Relayer API Key"), - make_sync_eoa_client(with_api_key=False) as client, - ): - client.setup_gasless_wallet() - - -def test_setup_gasless_wallet_eoa_skips_deploy_when_already_deployed() -> None: - captured: list[httpx.Request] = [] - - def handler(request: httpx.Request) -> httpx.Response: - captured.append(request) - path = urlparse(str(request.url)).path - if path == "/deployed": - return httpx.Response(200, json={"deployed": True}, request=request) - return httpx.Response(404, request=request) - - with make_sync_eoa_client() as client: - signer_address = client._ctx.signer.address - install_sync_relayer_handler(client, handler) - install_sync_rpc_handler(client, legacy_factory_rpc_handler()) - gasless = client.setup_gasless_wallet() - try: - assert gasless.wallet_type == "DEPOSIT_WALLET" - expected = derive_uups_deposit_wallet_address( - signer_address, PRODUCTION.wallet_derivation - ) - assert str(gasless.wallet) == expected - finally: - gasless.close() - - submit_calls = [r for r in captured if urlparse(str(r.url)).path == "/submit"] - assert submit_calls == [] - - -def test_setup_gasless_wallet_eoa_deploys_when_not_deployed_and_returns_fresh_client() -> None: - captured: list[httpx.Request] = [] - - def handler(request: httpx.Request) -> httpx.Response: - captured.append(request) - path = urlparse(str(request.url)).path - if path == "/deployed": - return httpx.Response(200, json={"deployed": False}, request=request) - if path == "/submit": - return httpx.Response( - 200, - json={ - "state": "STATE_NEW", - "transactionHash": None, - "transactionID": "tx-deploy", - }, - request=request, - ) - if path.startswith("/v1/account/transactions/"): - return httpx.Response( - 200, - json={ - "state": "STATE_MINED", - "transaction_hash": "0x" + "ab" * 32, - "transaction_id": "tx-deploy", - }, - request=request, - ) - return httpx.Response(404, request=request) - - with make_sync_eoa_client() as client: - install_sync_relayer_handler(client, handler) - install_sync_rpc_handler(client, legacy_factory_rpc_handler()) - client._ctx = dataclasses.replace( - client._ctx, - environment=dataclasses.replace(client._ctx.environment, relayer_poll_frequency_ms=1), - ) - gasless = client.setup_gasless_wallet() - try: - assert gasless is not client - assert gasless.wallet_type == "DEPOSIT_WALLET" - finally: - gasless.close() - - submit_calls = [r for r in captured if urlparse(str(r.url)).path == "/submit"] - assert len(submit_calls) == 1 - body = request_json(submit_calls[0]) - assert body["type"] == "WALLET-CREATE" - - def test_split_position_routes_through_collateral_adapter() -> None: captured: list[httpx.Request] = [] _CONDITION_ID = "0x" + "11" * 32 @@ -319,10 +170,21 @@ def handler(request: httpx.Request) -> httpx.Response: }, request=request, ) + if path == "/v1/account/transactions/tx-setup": + return httpx.Response( + 200, + json={ + "state": "STATE_MINED", + "transaction_hash": "0x" + "ab" * 32, + "transaction_id": "tx-setup", + }, + request=request, + ) return httpx.Response(404, request=request) with make_sync_deposit_client() as client: install_sync_relayer_handler(client, handler) + install_sync_rpc_handler(client, trading_approval_rpc_handler()) client.setup_trading_approvals() submit_calls = [r for r in captured if urlparse(str(r.url)).path == "/submit"] @@ -331,6 +193,26 @@ def handler(request: httpx.Request) -> httpx.Response: assert len(inner_calls) == 11 +def test_setup_trading_approvals_skips_submit_when_already_approved() -> None: + captured: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response(404, request=request) + + with make_sync_deposit_client() as client: + install_sync_relayer_handler(client, handler) + install_sync_rpc_handler( + client, + trading_approval_rpc_handler(allowance=(1 << 256) - 1, approved=True), + ) + handle = client.setup_trading_approvals() + handle.wait() + + submit_calls = [r for r in captured if urlparse(str(r.url)).path == "/submit"] + assert submit_calls == [] + + def test_close_closes_relayer_and_rpc_transports() -> None: client = make_sync_proxy_client() client.close() @@ -377,6 +259,16 @@ def handler(request: httpx.Request) -> httpx.Response: }, request=request, ) + if path == "/v1/account/transactions/tx-safe": + return httpx.Response( + 200, + json={ + "state": "STATE_MINED", + "transaction_hash": "0x" + "cd" * 32, + "transaction_id": "tx-safe", + }, + request=request, + ) return httpx.Response(404, request=request) return handler @@ -428,6 +320,7 @@ def test_setup_trading_approvals_safe_uses_multisend_delegatecall() -> None: with make_sync_safe_client() as client: install_sync_relayer_handler(client, _safe_relayer_handler(captured)) + install_sync_rpc_handler(client, trading_approval_rpc_handler()) client.setup_trading_approvals() submit = [r for r in captured if urlparse(str(r.url)).path == "/submit"][0]