diff --git a/src/polymarket/__init__.py b/src/polymarket/__init__.py index e857d8b..9fd3db9 100644 --- a/src/polymarket/__init__.py +++ b/src/polymarket/__init__.py @@ -69,6 +69,8 @@ CurrentRewardConfig, DepositActivity, EarningBreakdown, + Erc20TradingApproval, + Erc1155TradingApproval, Event, EventId, GaslessTransaction, @@ -93,6 +95,7 @@ MergeActivity, MetaHolder, MetaMarketPosition, + MissingTradingApprovals, Notification, OpenInterest, OpenOrder, @@ -163,6 +166,7 @@ TradeActivity, TradedMarketCount, TraderLeaderboardEntry, + TradingApprovalsState, TransactionOutcome, UnknownActivity, UserEarning, @@ -273,6 +277,8 @@ "CurrentRewardConfig", "DepositActivity", "EarningBreakdown", + "Erc20TradingApproval", + "Erc1155TradingApproval", "EoaTransactionHandle", "Environment", "Event", @@ -310,6 +316,7 @@ "MergePositionRequest", "MetaHolder", "MetaMarketPosition", + "MissingTradingApprovals", "Notification", "OpenInterest", "OpenOrder", @@ -423,6 +430,7 @@ "TransactionHandle", "TransactionHash", "TransactionOutcome", + "TradingApprovalsState", "TransportError", "UnexpectedResponseError", "UnknownActivity", diff --git a/src/polymarket/_internal/actions/relayer/approvals.py b/src/polymarket/_internal/actions/relayer/approvals.py index cbd4ace..4b3e102 100644 --- a/src/polymarket/_internal/actions/relayer/approvals.py +++ b/src/polymarket/_internal/actions/relayer/approvals.py @@ -1,8 +1,9 @@ from __future__ import annotations -from dataclasses import dataclass from typing import cast +from eth_utils.address import to_checksum_address + from polymarket._internal.actions.relayer.calls import ( MAX_UINT256, TransactionCall, @@ -15,77 +16,72 @@ ) from polymarket._internal.environment import EnvironmentConfig from polymarket._internal.eoa.rpc import JsonRpcClient, SyncJsonRpcClient +from polymarket.errors import UserInputError +from polymarket.models.trading import ( + Erc20TradingApproval, + Erc1155TradingApproval, + MissingTradingApprovals, + TradingApprovalsState, +) from polymarket.types import EvmAddress -@dataclass(frozen=True, slots=True) -class _Erc20TradingApproval: - token_address: EvmAddress - spender: EvmAddress - amount: int +async def get_trading_approvals_state( + rpc: JsonRpcClient, *, wallet: str, config: EnvironmentConfig +) -> TradingApprovalsState: + wallet_address = _normalize_wallet(wallet) + erc20, erc1155 = _required_trading_approvals(config) + erc20_checks, erc1155_checks = _build_approval_checks( + wallet=wallet_address, erc20=erc20, erc1155=erc1155 + ) + results = await rpc.eth_call_batch( + [(str(check.to), check.data) for check in [*erc20_checks, *erc1155_checks]] + ) + return _parse_trading_approvals_state(erc20=erc20, erc1155=erc1155, results=results) -@dataclass(frozen=True, slots=True) -class _Erc1155TradingApproval: - token_address: EvmAddress - operator: EvmAddress +def get_trading_approvals_state_sync( + rpc: SyncJsonRpcClient, *, wallet: str, config: EnvironmentConfig +) -> TradingApprovalsState: + wallet_address = _normalize_wallet(wallet) + erc20, erc1155 = _required_trading_approvals(config) + erc20_checks, erc1155_checks = _build_approval_checks( + wallet=wallet_address, erc20=erc20, erc1155=erc1155 + ) + results = rpc.eth_call_batch( + [(str(check.to), check.data) for check in [*erc20_checks, *erc1155_checks]] + ) + return _parse_trading_approvals_state(erc20=erc20, erc1155=erc1155, results=results) -async def resolve_missing_trading_approval_calls( - rpc: JsonRpcClient, *, wallet: EvmAddress, config: EnvironmentConfig +def build_missing_trading_approval_calls( + missing: MissingTradingApprovals, ) -> list[TransactionCall]: - erc20, erc1155 = _required_trading_approvals(config) - erc20_checks = [ - erc20_allowance_call( + erc20_calls = [ + erc20_approval_call( token_address=approval.token_address, - owner=wallet, spender=approval.spender, + amount=approval.amount, ) - for approval in erc20 + for approval in missing.erc20 ] - erc1155_checks = [ - erc1155_is_approved_for_all_call( + erc1155_calls = [ + erc1155_set_approval_for_all_call( token_address=approval.token_address, - owner=wallet, operator=approval.operator, + approved=True, ) - for approval in erc1155 + for approval in missing.erc1155 ] - results = await rpc.eth_call_batch( - [(str(check.to), check.data) for check in [*erc20_checks, *erc1155_checks]] - ) + return erc20_calls + erc1155_calls - erc20_missing: list[TransactionCall] = [] - for approval, result in zip(erc20, results[: len(erc20)], strict=True): - allowance = decode_erc20_allowance_result(result) - 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, result in zip(erc1155, results[len(erc20) :], strict=True): - approved = decode_erc1155_is_approved_for_all_result(result) - 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, config: EnvironmentConfig -) -> list[TransactionCall]: - erc20, erc1155 = _required_trading_approvals(config) + +def _build_approval_checks( + *, + wallet: EvmAddress, + erc20: tuple[Erc20TradingApproval, ...], + erc1155: tuple[Erc1155TradingApproval, ...], +) -> tuple[list[TransactionCall], list[TransactionCall]]: erc20_checks = [ erc20_allowance_call( token_address=approval.token_address, @@ -102,118 +98,121 @@ def resolve_missing_trading_approval_calls_sync( ) for approval in erc1155 ] - results = rpc.eth_call_batch( - [(str(check.to), check.data) for check in [*erc20_checks, *erc1155_checks]] + return erc20_checks, erc1155_checks + + +def _parse_trading_approvals_state( + *, + erc20: tuple[Erc20TradingApproval, ...], + erc1155: tuple[Erc1155TradingApproval, ...], + results: list[str], +) -> TradingApprovalsState: + missing_erc20 = tuple( + approval + for approval, result in zip(erc20, results[: len(erc20)], strict=True) + if decode_erc20_allowance_result(result) < approval.amount + ) + missing_erc1155 = tuple( + approval + for approval, result in zip(erc1155, results[len(erc20) :], strict=True) + if not decode_erc1155_is_approved_for_all_result(result) ) + missing = MissingTradingApprovals(erc20=missing_erc20, erc1155=missing_erc1155) + return TradingApprovalsState( + missing=missing, + is_fully_approved=not missing.erc20 and not missing.erc1155, + ) + - erc20_missing: list[TransactionCall] = [] - for approval, result in zip(erc20, results[: len(erc20)], strict=True): - allowance = decode_erc20_allowance_result(result) - 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, result in zip(erc1155, results[len(erc20) :], strict=True): - approved = decode_erc1155_is_approved_for_all_result(result) - 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 _normalize_wallet(wallet: str) -> EvmAddress: + try: + return cast(EvmAddress, to_checksum_address(wallet)) + except ValueError as error: + raise UserInputError(f"Invalid wallet address: {error}") from error def _required_trading_approvals( config: EnvironmentConfig, -) -> tuple[list[_Erc20TradingApproval], list[_Erc1155TradingApproval]]: +) -> tuple[tuple[Erc20TradingApproval, ...], tuple[Erc1155TradingApproval, ...]]: collateral = cast(EvmAddress, config.collateral_token) conditional = cast(EvmAddress, config.conditional_tokens) return ( - [ - _Erc20TradingApproval( + ( + Erc20TradingApproval( token_address=collateral, spender=cast(EvmAddress, config.standard_exchange), amount=MAX_UINT256, ), - _Erc20TradingApproval( + Erc20TradingApproval( token_address=collateral, spender=cast(EvmAddress, config.neg_risk_exchange), amount=MAX_UINT256, ), - _Erc20TradingApproval( + Erc20TradingApproval( token_address=collateral, spender=cast(EvmAddress, config.collateral_adapter), amount=MAX_UINT256, ), - _Erc20TradingApproval( + Erc20TradingApproval( token_address=collateral, spender=cast(EvmAddress, config.neg_risk_collateral_adapter), amount=MAX_UINT256, ), - _Erc20TradingApproval( + Erc20TradingApproval( token_address=collateral, spender=cast(EvmAddress, config.protocol_v2_router), amount=MAX_UINT256, ), - _Erc20TradingApproval( + Erc20TradingApproval( token_address=collateral, spender=cast(EvmAddress, config.exchange_v3), amount=MAX_UINT256, ), - _Erc20TradingApproval( + Erc20TradingApproval( token_address=collateral, spender=cast(EvmAddress, config.perps_deposit_contract), amount=MAX_UINT256, ), - ], - [ - _Erc1155TradingApproval( + ), + ( + Erc1155TradingApproval( token_address=conditional, operator=cast(EvmAddress, config.standard_exchange), ), - _Erc1155TradingApproval( + Erc1155TradingApproval( token_address=conditional, operator=cast(EvmAddress, config.neg_risk_exchange), ), - _Erc1155TradingApproval( + Erc1155TradingApproval( token_address=conditional, operator=cast(EvmAddress, config.collateral_adapter), ), - _Erc1155TradingApproval( + Erc1155TradingApproval( token_address=conditional, operator=cast(EvmAddress, config.neg_risk_collateral_adapter), ), - _Erc1155TradingApproval( + Erc1155TradingApproval( token_address=conditional, operator=cast(EvmAddress, config.auto_redeem_operator), ), - _Erc1155TradingApproval( + Erc1155TradingApproval( token_address=cast(EvmAddress, config.position_manager), operator=cast(EvmAddress, config.protocol_v2_router), ), - _Erc1155TradingApproval( + Erc1155TradingApproval( token_address=cast(EvmAddress, config.position_manager), operator=cast(EvmAddress, config.exchange_v3), ), - _Erc1155TradingApproval( + Erc1155TradingApproval( token_address=cast(EvmAddress, config.position_manager), operator=cast(EvmAddress, config.auto_redeem_operator), ), - ], + ), ) __all__ = [ - "resolve_missing_trading_approval_calls", - "resolve_missing_trading_approval_calls_sync", + "build_missing_trading_approval_calls", + "get_trading_approvals_state", + "get_trading_approvals_state_sync", ] diff --git a/src/polymarket/clients/async_public.py b/src/polymarket/clients/async_public.py index 546b794..ac831c2 100644 --- a/src/polymarket/clients/async_public.py +++ b/src/polymarket/clients/async_public.py @@ -39,6 +39,7 @@ ) from polymarket._internal.actions.orders.types import MarketOrderType from polymarket._internal.actions.perps import public as _perps_actions +from polymarket._internal.actions.relayer.approvals import get_trading_approvals_state from polymarket._internal.context import AsyncClientContext from polymarket._internal.dispatch import ( async_dispatch, @@ -47,6 +48,7 @@ async_paginate_page_based, ) from polymarket._internal.environment import get_environment_config +from polymarket._internal.eoa.rpc import JsonRpcClient from polymarket._internal.streams.handle import AsyncSubscriptionHandle, SubscriptionHandle from polymarket.clients._transport import AsyncTransport from polymarket.environments import PRODUCTION, Environment @@ -72,6 +74,7 @@ Tag, TagReference, Team, + TradingApprovalsState, ) from polymarket.models.clob.builder import BuilderTrade from polymarket.models.clob.market_events import MarketEvent @@ -163,6 +166,7 @@ def __init__( clob=AsyncTransport(base_url=config.clob_url, logger=logger), perps=AsyncTransport(base_url=config.perps_url, logger=logger), ) + self._rpc = JsonRpcClient(AsyncTransport(base_url=config.rpc_url, logger=logger)) self._market_manager: ClobMarketStreamManager | None = None self._sports_manager: SportsStreamManager | None = None self._rtds_manager: RtdsStreamManager | None = None @@ -365,7 +369,10 @@ async def close(self) -> None: try: await self._ctx.clob.close() finally: - await self._ctx.perps.close() + try: + await self._ctx.perps.close() + finally: + await self._rpc.close() @overload async def get_market( @@ -550,6 +557,14 @@ async def get_public_profile(self, address: str) -> PublicProfile | None: return None raise + async def get_trading_approvals_state(self, *, wallet: str) -> TradingApprovalsState: + """Get the trading approvals that a wallet still needs to grant.""" + return await get_trading_approvals_state( + self._rpc, + wallet=wallet, + config=self._ctx.environment_config, + ) + async def get_comment_thread( self, id: str, *, get_positions: bool | None = None ) -> tuple[Comment, ...]: diff --git a/src/polymarket/clients/async_secure.py b/src/polymarket/clients/async_secure.py index 01efbcc..fb35dd7 100644 --- a/src/polymarket/clients/async_secure.py +++ b/src/polymarket/clients/async_secure.py @@ -83,7 +83,8 @@ from polymarket._internal.actions.perps import funds as _perps_funds from polymarket._internal.actions.perps import public as _perps_actions from polymarket._internal.actions.relayer.approvals import ( - resolve_missing_trading_approval_calls, + build_missing_trading_approval_calls, + get_trading_approvals_state, ) from polymarket._internal.actions.relayer.auth import make_relayer_header_resolver from polymarket._internal.actions.relayer.calls import ( @@ -180,6 +181,7 @@ Tag, TagReference, Team, + TradingApprovalsState, ) from polymarket.models.clob.api_key import BuilderApiKeyInfo from polymarket.models.clob.cancel import CancelOrdersResponse @@ -2388,6 +2390,16 @@ async def transfer_erc20( ) return await self._dispatch_single_call(call, metadata=resolved_metadata) + async def get_trading_approvals_state( + self, *, wallet: str | None = None + ) -> TradingApprovalsState: + """Get missing trading approvals for a wallet or the authenticated wallet.""" + return await get_trading_approvals_state( + self._ctx.rpc, + wallet=self._ctx.wallet if wallet is None else wallet, + config=self._ctx.environment_config, + ) + async def setup_trading_approvals(self) -> DeprecatedTransactionHandle: """Approve the standard set of trading allowances for the wallet. @@ -2398,11 +2410,8 @@ async def setup_trading_approvals(self) -> DeprecatedTransactionHandle: Returns: A deprecated compatibility handle whose ``wait()`` returns immediately. """ - calls = await resolve_missing_trading_approval_calls( - self._ctx.rpc, - wallet=self._ctx.wallet, - config=self._ctx.environment_config, - ) + state = await self.get_trading_approvals_state() + calls = build_missing_trading_approval_calls(state.missing) if not calls: return DeprecatedTransactionHandle() if self._ctx.wallet_type == "EOA": diff --git a/src/polymarket/clients/public.py b/src/polymarket/clients/public.py index e1b602e..129dd23 100644 --- a/src/polymarket/clients/public.py +++ b/src/polymarket/clients/public.py @@ -37,6 +37,7 @@ estimate_market_price_sync as _estimate_market_price_sync, ) from polymarket._internal.actions.orders.types import MarketOrderType +from polymarket._internal.actions.relayer.approvals import get_trading_approvals_state_sync from polymarket._internal.context import SyncClientContext from polymarket._internal.dispatch import ( sync_dispatch, @@ -45,6 +46,7 @@ sync_paginate_page_based, ) from polymarket._internal.environment import get_environment_config +from polymarket._internal.eoa.rpc import SyncJsonRpcClient from polymarket.clients._transport import SyncTransport from polymarket.environments import PRODUCTION, Environment from polymarket.errors import RequestRejectedError @@ -69,6 +71,7 @@ Tag, TagReference, Team, + TradingApprovalsState, ) from polymarket.models.clob import BuilderTrade from polymarket.models.clob.rewards import CurrentReward, MarketReward @@ -118,6 +121,7 @@ def __init__( rfq=SyncTransport(base_url=config.rfq_url, logger=logger), clob=SyncTransport(base_url=config.clob_url, logger=logger), ) + self._rpc = SyncJsonRpcClient(SyncTransport(base_url=config.rpc_url, logger=logger)) @property def environment(self) -> Environment: @@ -146,7 +150,10 @@ def close(self) -> None: try: self._ctx.rfq.close() finally: - self._ctx.clob.close() + try: + self._ctx.clob.close() + finally: + self._rpc.close() @overload def get_market( @@ -331,6 +338,14 @@ def get_public_profile(self, address: str) -> PublicProfile | None: return None raise + def get_trading_approvals_state(self, *, wallet: str) -> TradingApprovalsState: + """Get the trading approvals that a wallet still needs to grant.""" + return get_trading_approvals_state_sync( + self._rpc, + wallet=wallet, + config=self._ctx.environment_config, + ) + def get_comment_thread( self, id: str, *, get_positions: bool | None = None ) -> tuple[Comment, ...]: diff --git a/src/polymarket/clients/secure.py b/src/polymarket/clients/secure.py index 35520e2..81e793a 100644 --- a/src/polymarket/clients/secure.py +++ b/src/polymarket/clients/secure.py @@ -70,7 +70,8 @@ ) from polymarket._internal.actions.orders.types import OrderDraft from polymarket._internal.actions.relayer.approvals import ( - resolve_missing_trading_approval_calls_sync, + build_missing_trading_approval_calls, + get_trading_approvals_state_sync, ) from polymarket._internal.actions.relayer.auth import make_relayer_header_resolver_sync from polymarket._internal.actions.relayer.calls import ( @@ -162,6 +163,7 @@ Tag, TagReference, Team, + TradingApprovalsState, ) from polymarket.models.clob import BuilderApiKeyInfo, BuilderTrade from polymarket.models.clob.cancel import CancelOrdersResponse @@ -2161,6 +2163,14 @@ def transfer_erc20( ) return self._dispatch_single_call(call, metadata=resolved_metadata) + def get_trading_approvals_state(self, *, wallet: str | None = None) -> TradingApprovalsState: + """Get missing trading approvals for a wallet or the authenticated wallet.""" + return get_trading_approvals_state_sync( + self._ctx.rpc, + wallet=self._ctx.wallet if wallet is None else wallet, + config=self._ctx.environment_config, + ) + def setup_trading_approvals(self) -> SyncDeprecatedTransactionHandle: """Approve the standard set of trading allowances for the wallet. @@ -2171,11 +2181,8 @@ def setup_trading_approvals(self) -> SyncDeprecatedTransactionHandle: Returns: A deprecated compatibility handle whose ``wait()`` returns immediately. """ - calls = resolve_missing_trading_approval_calls_sync( - self._ctx.rpc, - wallet=self._ctx.wallet, - config=self._ctx.environment_config, - ) + state = self.get_trading_approvals_state() + calls = build_missing_trading_approval_calls(state.missing) if not calls: return SyncDeprecatedTransactionHandle() if self._ctx.wallet_type == "EOA": diff --git a/src/polymarket/models/__init__.py b/src/polymarket/models/__init__.py index 86e3b4c..0a15b74 100644 --- a/src/polymarket/models/__init__.py +++ b/src/polymarket/models/__init__.py @@ -189,6 +189,12 @@ PerpsPlacedTpSlOrders, ) from polymarket.models.rfq import ComboMarket, ComboMarketOutcome, ComboMarketOutcomes +from polymarket.models.trading import ( + Erc20TradingApproval, + Erc1155TradingApproval, + MissingTradingApprovals, + TradingApprovalsState, +) from polymarket.models.types import ( ComboActivityId, ComboConditionId, @@ -225,6 +231,8 @@ "CurrentReward", "CurrentRewardConfig", "EarningBreakdown", + "Erc20TradingApproval", + "Erc1155TradingApproval", "GaslessTransaction", "LastTradePrice", "LastTradePriceForToken", @@ -307,6 +315,7 @@ "MergeActivity", "MetaHolder", "MetaMarketPosition", + "MissingTradingApprovals", "OpenInterest", "OrderId", "PerpsAccountConfig", @@ -405,6 +414,7 @@ "TradeActivity", "TradedMarketCount", "TraderLeaderboardEntry", + "TradingApprovalsState", "UnknownActivity", "WithdrawalActivity", "YieldActivity", diff --git a/src/polymarket/models/trading.py b/src/polymarket/models/trading.py new file mode 100644 index 0000000..332335f --- /dev/null +++ b/src/polymarket/models/trading.py @@ -0,0 +1,46 @@ +"""Trading approval state objects.""" + +from dataclasses import dataclass + +from polymarket.types import EvmAddress + + +@dataclass(frozen=True, slots=True, kw_only=True) +class Erc20TradingApproval: + """One required ERC-20 allowance that is not configured for a wallet.""" + + token_address: EvmAddress + spender: EvmAddress + amount: int + + +@dataclass(frozen=True, slots=True, kw_only=True) +class Erc1155TradingApproval: + """One required ERC-1155 operator approval that is not configured for a wallet.""" + + token_address: EvmAddress + operator: EvmAddress + + +@dataclass(frozen=True, slots=True, kw_only=True) +class MissingTradingApprovals: + """Trading approvals that a wallet still needs to grant.""" + + erc20: tuple[Erc20TradingApproval, ...] = () + erc1155: tuple[Erc1155TradingApproval, ...] = () + + +@dataclass(frozen=True, slots=True, kw_only=True) +class TradingApprovalsState: + """Current trading approval state for a wallet.""" + + missing: MissingTradingApprovals + is_fully_approved: bool + + +__all__ = [ + "Erc20TradingApproval", + "Erc1155TradingApproval", + "MissingTradingApprovals", + "TradingApprovalsState", +] diff --git a/tests/integration/test_relayer_approve_live.py b/tests/integration/test_relayer_approve_live.py index 4a93cee..fa45745 100644 --- a/tests/integration/test_relayer_approve_live.py +++ b/tests/integration/test_relayer_approve_live.py @@ -6,9 +6,18 @@ import pytest -from polymarket import ApiKeyCreds, AsyncSecureClient, BuilderApiKey, GaslessTransaction +from polymarket import ( + ApiKeyCreds, + AsyncSecureClient, + BuilderApiKey, + GaslessTransaction, + PublicClient, + TradingApprovalsState, +) from polymarket._internal.environment import PRODUCTION_CONFIG +_READ_ONLY_UNAPPROVED_WALLET = "0x0000000000000000000000000000000000000000" + def _builder_auth(require_env: Callable[[str], str]) -> BuilderApiKey: return BuilderApiKey( @@ -86,6 +95,17 @@ async def run() -> None: asyncio.run(asyncio.wait_for(run(), timeout=30.0)) +@pytest.mark.integration +def test_public_client_reads_trading_approvals_without_a_signer() -> None: + with PublicClient() as client: + state = client.get_trading_approvals_state(wallet=_READ_ONLY_UNAPPROVED_WALLET) + + assert isinstance(state, TradingApprovalsState) + assert len(state.missing.erc20) == 7 + assert len(state.missing.erc1155) == 8 + assert state.is_fully_approved is False + + _SKIP_REASON = ( "Requires a Builder/Relayer API Key authorized to submit for the test wallet's " "signer. Enable when authorized credentials are available."