From 2b8389bfedf745e1e706486d91b1bbeb1c50f150 Mon Sep 17 00:00:00 2001 From: kartojal Date: Wed, 12 Aug 2026 16:11:04 +0200 Subject: [PATCH 1/4] feat(client): add account funding workflows --- src/polymarket/__init__.py | 22 ++ src/polymarket/_internal/actions/funding.py | 148 ++++++++ src/polymarket/_internal/context.py | 2 + src/polymarket/clients/async_public.py | 87 ++++- src/polymarket/clients/async_secure.py | 77 ++++ src/polymarket/clients/public.py | 83 ++++- src/polymarket/clients/secure.py | 90 ++++- src/polymarket/environments.py | 2 + src/polymarket/models/__init__.py | 24 ++ src/polymarket/models/funding.py | 287 +++++++++++++++ tests/integration/test_funding.py | 80 +++++ tests/unit/test_builder_trades.py | 2 + tests/unit/test_funding_actions.py | 378 ++++++++++++++++++++ tests/unit/test_funding_clients.py | 348 ++++++++++++++++++ tests/unit/test_funding_models.py | 272 ++++++++++++++ 15 files changed, 1888 insertions(+), 14 deletions(-) create mode 100644 src/polymarket/_internal/actions/funding.py create mode 100644 src/polymarket/models/funding.py create mode 100644 tests/integration/test_funding.py create mode 100644 tests/unit/test_funding_actions.py create mode 100644 tests/unit/test_funding_clients.py create mode 100644 tests/unit/test_funding_models.py diff --git a/src/polymarket/__init__.py b/src/polymarket/__init__.py index 50d2201..0cd1156 100644 --- a/src/polymarket/__init__.py +++ b/src/polymarket/__init__.py @@ -75,8 +75,19 @@ Erc1155TradingApproval, Event, EventId, + FundingAddresses, + FundingAddressSet, + FundingAsset, + FundingAssetCatalog, + FundingFeeBreakdown, + FundingQuote, + FundingToken, + FundingTransaction, + FundingTransactionStatus, + FundingWarning, GaslessTransaction, Holder, + KnownFundingTransactionStatus, LastTradePrice, LastTradePriceForToken, LeaderboardCategory, @@ -306,6 +317,17 @@ "Event", "EventId", "EvmAddress", + "FundingAddressSet", + "FundingAddresses", + "FundingAsset", + "FundingAssetCatalog", + "FundingFeeBreakdown", + "FundingQuote", + "FundingToken", + "FundingTransaction", + "FundingTransactionStatus", + "FundingWarning", + "KnownFundingTransactionStatus", "GaslessTransaction", "GaslessTransactionHandle", "HexString", diff --git a/src/polymarket/_internal/actions/funding.py b/src/polymarket/_internal/actions/funding.py new file mode 100644 index 0000000..29c6443 --- /dev/null +++ b/src/polymarket/_internal/actions/funding.py @@ -0,0 +1,148 @@ +"""Shared request construction and response parsing for account funding.""" + +from urllib.parse import quote + +from eth_utils.address import to_checksum_address + +from polymarket._internal.validation import require_nonempty, validate_builder_code +from polymarket.errors import UserInputError +from polymarket.models.base import BaseModel +from polymarket.models.funding import ( + FundingAddressSet, + FundingAssetCatalog, + FundingQuote, + FundingTransaction, +) + +_BUILDER_CODE_HEADER = "X-Builder-Code" + + +class _FundingTransactionsResponse(BaseModel): + transactions: tuple[FundingTransaction, ...] + + +def _validate_evm_address(name: str, value: object) -> str: + validated = require_nonempty(name, value) + if len(validated) != 42 or not validated.startswith("0x"): + raise UserInputError(f"{name} must be a valid EVM address.") + try: + int(validated[2:], 16) + return to_checksum_address(validated) + except ValueError as error: + raise UserInputError(f"{name} must be a valid EVM address.") from error + + +def _require_nonblank(name: str, value: object) -> str: + validated = require_nonempty(name, value).strip() + if not validated: + raise UserInputError(f"{name} is required") + return validated + + +def _validate_positive_integer(name: str, value: object) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + raise UserInputError(f"{name} must be a positive integer.") + return value + + +def _builder_headers(builder_code: str | None) -> dict[str, str]: + if builder_code is None: + return {} + return {_BUILDER_CODE_HEADER: validate_builder_code(builder_code)} + + +def build_create_deposit_addresses_request( + *, wallet: str, builder_code: str | None = None +) -> tuple[str, dict[str, str], dict[str, str]]: + """Build a request for chain-specific deposit addresses.""" + return ( + "/deposit", + {"address": _validate_evm_address("wallet", wallet)}, + _builder_headers(builder_code), + ) + + +def build_create_withdrawal_addresses_request( + *, + wallet: str, + destination_chain_id: int, + destination_token_address: str, + recipient_address: str, + builder_code: str | None = None, +) -> tuple[str, dict[str, str], dict[str, str]]: + """Build a request for chain-specific withdrawal addresses.""" + return ( + "/withdraw", + { + "address": _validate_evm_address("wallet", wallet), + "toChainId": str( + _validate_positive_integer("destination_chain_id", destination_chain_id) + ), + "toTokenAddress": _require_nonblank( + "destination_token_address", destination_token_address + ), + "recipientAddr": _require_nonblank("recipient_address", recipient_address), + }, + _builder_headers(builder_code), + ) + + +def build_funding_quote_request( + *, + amount: int, + source_chain_id: int, + source_token_address: str, + destination_chain_id: int, + destination_token_address: str, + recipient_address: str, +) -> tuple[str, dict[str, str]]: + """Build a request for a funding transfer quote.""" + return ( + "/quote", + { + "fromAmountBaseUnit": str(_validate_positive_integer("amount", amount)), + "fromChainId": str(_validate_positive_integer("source_chain_id", source_chain_id)), + "fromTokenAddress": _require_nonblank("source_token_address", source_token_address), + "recipientAddress": _require_nonblank("recipient_address", recipient_address), + "toChainId": str( + _validate_positive_integer("destination_chain_id", destination_chain_id) + ), + "toTokenAddress": _require_nonblank( + "destination_token_address", destination_token_address + ), + }, + ) + + +def build_funding_status_request(*, address: str) -> str: + """Build a status path for an EVM, SVM, Bitcoin, or Tron address.""" + validated = _require_nonblank("address", address) + return f"/status/{quote(validated, safe='')}" + + +def parse_funding_address_set(data: object) -> FundingAddressSet: + return FundingAddressSet.parse_response(data) + + +def parse_funding_asset_catalog(data: object) -> FundingAssetCatalog: + return FundingAssetCatalog.parse_response(data) + + +def parse_funding_quote(data: object) -> FundingQuote: + return FundingQuote.parse_response(data) + + +def parse_funding_transactions(data: object) -> tuple[FundingTransaction, ...]: + return _FundingTransactionsResponse.parse_response(data).transactions + + +__all__ = [ + "build_create_deposit_addresses_request", + "build_create_withdrawal_addresses_request", + "build_funding_quote_request", + "build_funding_status_request", + "parse_funding_address_set", + "parse_funding_asset_catalog", + "parse_funding_quote", + "parse_funding_transactions", +] diff --git a/src/polymarket/_internal/context.py b/src/polymarket/_internal/context.py index 88930cb..d7c1efa 100644 --- a/src/polymarket/_internal/context.py +++ b/src/polymarket/_internal/context.py @@ -24,6 +24,7 @@ class SyncClientContext: environment_config: EnvironmentConfig = field(init=False, repr=False) gamma: SyncTransport data: SyncTransport + bridge: SyncTransport rfq: SyncTransport clob: SyncTransport _resolved_environment_config: InitVar[EnvironmentConfig | None] = None @@ -54,6 +55,7 @@ class AsyncClientContext: environment_config: EnvironmentConfig = field(init=False, repr=False) gamma: AsyncTransport data: AsyncTransport + bridge: AsyncTransport rfq: AsyncTransport clob: AsyncTransport perps: AsyncTransport diff --git a/src/polymarket/clients/async_public.py b/src/polymarket/clients/async_public.py index e02e5b3..2f85356 100644 --- a/src/polymarket/clients/async_public.py +++ b/src/polymarket/clients/async_public.py @@ -10,6 +10,7 @@ from polymarket._internal.actions import builders as _builders_actions from polymarket._internal.actions import clob as _clob_actions from polymarket._internal.actions import data as _data_actions +from polymarket._internal.actions import funding as _funding_actions from polymarket._internal.actions import gamma as _gamma_actions from polymarket._internal.actions import rewards as _rewards_actions from polymarket._internal.actions import rfq as _rfq_actions @@ -57,6 +58,10 @@ ComboMarket, Comment, Event, + FundingAddressSet, + FundingAssetCatalog, + FundingQuote, + FundingTransaction, LastTradePrice, LastTradePriceForToken, Market, @@ -162,6 +167,7 @@ def __init__( _resolved_environment_config=config, gamma=AsyncTransport(base_url=config.gamma_url, logger=logger), data=AsyncTransport(base_url=config.data_url, logger=logger), + bridge=AsyncTransport(base_url=config.bridge_url, logger=logger), rfq=AsyncTransport(base_url=config.rfq_url, logger=logger), clob=AsyncTransport(base_url=config.clob_url, logger=logger), perps=AsyncTransport(base_url=config.perps_url, logger=logger), @@ -364,15 +370,88 @@ async def close(self) -> None: await self._ctx.data.close() finally: try: - await self._ctx.rfq.close() + await self._ctx.bridge.close() finally: try: - await self._ctx.clob.close() + await self._ctx.rfq.close() finally: try: - await self._ctx.perps.close() + await self._ctx.clob.close() finally: - await self._rpc.close() + try: + await self._ctx.perps.close() + finally: + await self._rpc.close() + + async def create_deposit_addresses( + self, *, wallet: str, builder_code: str | None = None + ) -> FundingAddressSet: + """Create chain-specific deposit addresses for a wallet.""" + path, body, headers = _funding_actions.build_create_deposit_addresses_request( + wallet=wallet, + builder_code=builder_code, + ) + return _funding_actions.parse_funding_address_set( + await self._ctx.bridge.post_json(path, json=body, headers=headers) + ) + + async def create_withdrawal_addresses( + self, + *, + wallet: str, + destination_chain_id: int, + destination_token_address: str, + recipient_address: str, + builder_code: str | None = None, + ) -> FundingAddressSet: + """Create addresses for withdrawing to a destination chain and token. + + Send pUSD to the returned EVM address to start the withdrawal. + """ + path, body, headers = _funding_actions.build_create_withdrawal_addresses_request( + wallet=wallet, + destination_chain_id=destination_chain_id, + destination_token_address=destination_token_address, + recipient_address=recipient_address, + builder_code=builder_code, + ) + return _funding_actions.parse_funding_address_set( + await self._ctx.bridge.post_json(path, json=body, headers=headers) + ) + + async def get_supported_funding_assets(self) -> FundingAssetCatalog: + """Get the chain and token pairs supported for account funding.""" + return _funding_actions.parse_funding_asset_catalog( + await self._ctx.bridge.get_json("/supported-assets") + ) + + async def get_funding_quote( + self, + *, + amount: int, + source_chain_id: int, + source_token_address: str, + destination_chain_id: int, + destination_token_address: str, + recipient_address: str, + ) -> FundingQuote: + """Estimate a transfer between supported funding assets.""" + path, body = _funding_actions.build_funding_quote_request( + amount=amount, + source_chain_id=source_chain_id, + source_token_address=source_token_address, + destination_chain_id=destination_chain_id, + destination_token_address=destination_token_address, + recipient_address=recipient_address, + ) + return _funding_actions.parse_funding_quote( + await self._ctx.bridge.post_json(path, json=body) + ) + + async def get_funding_transactions(self, *, address: str) -> tuple[FundingTransaction, ...]: + """Get deposit or withdrawal transactions observed for an address.""" + path = _funding_actions.build_funding_status_request(address=address) + return _funding_actions.parse_funding_transactions(await self._ctx.bridge.get_json(path)) @overload async def get_market( diff --git a/src/polymarket/clients/async_secure.py b/src/polymarket/clients/async_secure.py index 5cd117a..6197e03 100644 --- a/src/polymarket/clients/async_secure.py +++ b/src/polymarket/clients/async_secure.py @@ -28,6 +28,7 @@ from polymarket._internal.actions import combo_rfq as _combo_rfq_actions from polymarket._internal.actions import combos as _combos_actions from polymarket._internal.actions import data as _data_actions +from polymarket._internal.actions import funding as _funding_actions from polymarket._internal.actions import gamma as _gamma_actions from polymarket._internal.actions import rewards as _rewards_actions from polymarket._internal.actions import rfq as _rfq_actions @@ -166,6 +167,10 @@ ComboMarket, Comment, Event, + FundingAddressSet, + FundingAssetCatalog, + FundingQuote, + FundingTransaction, LastTradePrice, LastTradePriceForToken, Market, @@ -483,6 +488,7 @@ def _construct_for_wallet( gamma = AsyncTransport(base_url=config.gamma_url, logger=logger) data = AsyncTransport(base_url=config.data_url, logger=logger) + bridge = AsyncTransport(base_url=config.bridge_url, logger=logger) rfq = AsyncTransport(base_url=config.rfq_url, logger=logger) clob = AsyncTransport( base_url=config.clob_url, @@ -519,6 +525,7 @@ def _construct_for_wallet( _resolved_environment_config=config, gamma=gamma, data=data, + bridge=bridge, rfq=rfq, clob=clob, perps=AsyncTransport(base_url=config.perps_url, logger=logger), @@ -959,6 +966,7 @@ async def close(self) -> None: _RfqSessionCloser(self._close_rfq_session), ctx.gamma, ctx.data, + ctx.bridge, ctx.rfq, ctx.clob, ctx.perps, @@ -969,6 +977,75 @@ async def close(self) -> None: ctx.rpc, ) + async def create_deposit_addresses( + self, *, builder_code: str | None = None + ) -> FundingAddressSet: + """Create chain-specific deposit addresses for this client's wallet.""" + path, body, headers = _funding_actions.build_create_deposit_addresses_request( + wallet=self._ctx.wallet, + builder_code=builder_code, + ) + return _funding_actions.parse_funding_address_set( + await self._ctx.bridge.post_json(path, json=body, headers=headers) + ) + + async def create_withdrawal_addresses( + self, + *, + destination_chain_id: int, + destination_token_address: str, + recipient_address: str, + builder_code: str | None = None, + ) -> FundingAddressSet: + """Create withdrawal addresses for this client's wallet. + + Send pUSD to the returned EVM address to start the withdrawal. + """ + path, body, headers = _funding_actions.build_create_withdrawal_addresses_request( + wallet=self._ctx.wallet, + destination_chain_id=destination_chain_id, + destination_token_address=destination_token_address, + recipient_address=recipient_address, + builder_code=builder_code, + ) + return _funding_actions.parse_funding_address_set( + await self._ctx.bridge.post_json(path, json=body, headers=headers) + ) + + async def get_supported_funding_assets(self) -> FundingAssetCatalog: + """Get the chain and token pairs supported for account funding.""" + return _funding_actions.parse_funding_asset_catalog( + await self._ctx.bridge.get_json("/supported-assets") + ) + + async def get_funding_quote( + self, + *, + amount: int, + source_chain_id: int, + source_token_address: str, + destination_chain_id: int, + destination_token_address: str, + recipient_address: str, + ) -> FundingQuote: + """Estimate a transfer between supported funding assets.""" + path, body = _funding_actions.build_funding_quote_request( + amount=amount, + source_chain_id=source_chain_id, + source_token_address=source_token_address, + destination_chain_id=destination_chain_id, + destination_token_address=destination_token_address, + recipient_address=recipient_address, + ) + return _funding_actions.parse_funding_quote( + await self._ctx.bridge.post_json(path, json=body) + ) + + async def get_funding_transactions(self, *, address: str) -> tuple[FundingTransaction, ...]: + """Get deposit or withdrawal transactions observed for an address.""" + path = _funding_actions.build_funding_status_request(address=address) + return _funding_actions.parse_funding_transactions(await self._ctx.bridge.get_json(path)) + async def _close_rfq_session(self) -> None: opening = self._rfq_session_opening connecting = self._rfq_session_connecting diff --git a/src/polymarket/clients/public.py b/src/polymarket/clients/public.py index ad7ff32..0010e20 100644 --- a/src/polymarket/clients/public.py +++ b/src/polymarket/clients/public.py @@ -9,6 +9,7 @@ from polymarket._internal.actions import builders as _builders_actions from polymarket._internal.actions import clob as _clob_actions from polymarket._internal.actions import data as _data_actions +from polymarket._internal.actions import funding as _funding_actions from polymarket._internal.actions import gamma as _gamma_actions from polymarket._internal.actions import rewards as _rewards_actions from polymarket._internal.actions import rfq as _rfq_actions @@ -54,6 +55,10 @@ ComboMarket, Comment, Event, + FundingAddressSet, + FundingAssetCatalog, + FundingQuote, + FundingTransaction, LastTradePrice, LastTradePriceForToken, Market, @@ -118,6 +123,7 @@ def __init__( _resolved_environment_config=config, gamma=SyncTransport(base_url=config.gamma_url, logger=logger), data=SyncTransport(base_url=config.data_url, logger=logger), + bridge=SyncTransport(base_url=config.bridge_url, logger=logger), rfq=SyncTransport(base_url=config.rfq_url, logger=logger), clob=SyncTransport(base_url=config.clob_url, logger=logger), ) @@ -148,12 +154,83 @@ def close(self) -> None: self._ctx.data.close() finally: try: - self._ctx.rfq.close() + self._ctx.bridge.close() finally: try: - self._ctx.clob.close() + self._ctx.rfq.close() finally: - self._rpc.close() + try: + self._ctx.clob.close() + finally: + self._rpc.close() + + def create_deposit_addresses( + self, *, wallet: str, builder_code: str | None = None + ) -> FundingAddressSet: + """Create chain-specific deposit addresses for a wallet.""" + path, body, headers = _funding_actions.build_create_deposit_addresses_request( + wallet=wallet, + builder_code=builder_code, + ) + return _funding_actions.parse_funding_address_set( + self._ctx.bridge.post_json(path, json=body, headers=headers) + ) + + def create_withdrawal_addresses( + self, + *, + wallet: str, + destination_chain_id: int, + destination_token_address: str, + recipient_address: str, + builder_code: str | None = None, + ) -> FundingAddressSet: + """Create addresses for withdrawing to a destination chain and token. + + Send pUSD to the returned EVM address to start the withdrawal. + """ + path, body, headers = _funding_actions.build_create_withdrawal_addresses_request( + wallet=wallet, + destination_chain_id=destination_chain_id, + destination_token_address=destination_token_address, + recipient_address=recipient_address, + builder_code=builder_code, + ) + return _funding_actions.parse_funding_address_set( + self._ctx.bridge.post_json(path, json=body, headers=headers) + ) + + def get_supported_funding_assets(self) -> FundingAssetCatalog: + """Get the chain and token pairs supported for account funding.""" + return _funding_actions.parse_funding_asset_catalog( + self._ctx.bridge.get_json("/supported-assets") + ) + + def get_funding_quote( + self, + *, + amount: int, + source_chain_id: int, + source_token_address: str, + destination_chain_id: int, + destination_token_address: str, + recipient_address: str, + ) -> FundingQuote: + """Estimate a transfer between supported funding assets.""" + path, body = _funding_actions.build_funding_quote_request( + amount=amount, + source_chain_id=source_chain_id, + source_token_address=source_token_address, + destination_chain_id=destination_chain_id, + destination_token_address=destination_token_address, + recipient_address=recipient_address, + ) + return _funding_actions.parse_funding_quote(self._ctx.bridge.post_json(path, json=body)) + + def get_funding_transactions(self, *, address: str) -> tuple[FundingTransaction, ...]: + """Get deposit or withdrawal transactions observed for an address.""" + path = _funding_actions.build_funding_status_request(address=address) + return _funding_actions.parse_funding_transactions(self._ctx.bridge.get_json(path)) @overload def get_market( diff --git a/src/polymarket/clients/secure.py b/src/polymarket/clients/secure.py index 73d4a1e..07677a4 100644 --- a/src/polymarket/clients/secure.py +++ b/src/polymarket/clients/secure.py @@ -18,6 +18,7 @@ from polymarket._internal.actions import combo_rfq as _combo_rfq_actions from polymarket._internal.actions import combos as _combos_actions from polymarket._internal.actions import data as _data_actions +from polymarket._internal.actions import funding as _funding_actions from polymarket._internal.actions import gamma as _gamma_actions from polymarket._internal.actions import rewards as _rewards_actions from polymarket._internal.actions import rfq as _rfq_actions @@ -148,6 +149,10 @@ ComboMarket, Comment, Event, + FundingAddressSet, + FundingAssetCatalog, + FundingQuote, + FundingTransaction, LastTradePrice, LastTradePriceForToken, Market, @@ -400,6 +405,7 @@ def _construct_for_wallet( gamma = SyncTransport(base_url=config.gamma_url, logger=logger) data = SyncTransport(base_url=config.data_url, logger=logger) + bridge = SyncTransport(base_url=config.bridge_url, logger=logger) rfq = SyncTransport(base_url=config.rfq_url, logger=logger) clob = SyncTransport( base_url=config.clob_url, @@ -438,6 +444,7 @@ def _construct_for_wallet( except BaseException: gamma.close() data.close() + bridge.close() rfq.close() clob.close() relayer.close() @@ -450,6 +457,7 @@ def _construct_for_wallet( _resolved_environment_config=config, gamma=gamma, data=data, + bridge=bridge, rfq=rfq, clob=clob, signer=signer, @@ -512,24 +520,92 @@ def close(self) -> None: ctx.data.close() finally: try: - ctx.clob.close() + ctx.bridge.close() finally: try: - ctx.rfq.close() + ctx.clob.close() finally: try: - ctx.secure_clob.close() + ctx.rfq.close() finally: try: - ctx.relayer.close() + ctx.secure_clob.close() finally: try: - ctx.combos.close() + ctx.relayer.close() finally: try: - ctx.builder_gateway.close() + ctx.combos.close() finally: - ctx.rpc.close() + try: + ctx.builder_gateway.close() + finally: + ctx.rpc.close() + + def create_deposit_addresses(self, *, builder_code: str | None = None) -> FundingAddressSet: + """Create chain-specific deposit addresses for this client's wallet.""" + path, body, headers = _funding_actions.build_create_deposit_addresses_request( + wallet=self._ctx.wallet, + builder_code=builder_code, + ) + return _funding_actions.parse_funding_address_set( + self._ctx.bridge.post_json(path, json=body, headers=headers) + ) + + def create_withdrawal_addresses( + self, + *, + destination_chain_id: int, + destination_token_address: str, + recipient_address: str, + builder_code: str | None = None, + ) -> FundingAddressSet: + """Create withdrawal addresses for this client's wallet. + + Send pUSD to the returned EVM address to start the withdrawal. + """ + path, body, headers = _funding_actions.build_create_withdrawal_addresses_request( + wallet=self._ctx.wallet, + destination_chain_id=destination_chain_id, + destination_token_address=destination_token_address, + recipient_address=recipient_address, + builder_code=builder_code, + ) + return _funding_actions.parse_funding_address_set( + self._ctx.bridge.post_json(path, json=body, headers=headers) + ) + + def get_supported_funding_assets(self) -> FundingAssetCatalog: + """Get the chain and token pairs supported for account funding.""" + return _funding_actions.parse_funding_asset_catalog( + self._ctx.bridge.get_json("/supported-assets") + ) + + def get_funding_quote( + self, + *, + amount: int, + source_chain_id: int, + source_token_address: str, + destination_chain_id: int, + destination_token_address: str, + recipient_address: str, + ) -> FundingQuote: + """Estimate a transfer between supported funding assets.""" + path, body = _funding_actions.build_funding_quote_request( + amount=amount, + source_chain_id=source_chain_id, + source_token_address=source_token_address, + destination_chain_id=destination_chain_id, + destination_token_address=destination_token_address, + recipient_address=recipient_address, + ) + return _funding_actions.parse_funding_quote(self._ctx.bridge.post_json(path, json=body)) + + def get_funding_transactions(self, *, address: str) -> tuple[FundingTransaction, ...]: + """Get deposit or withdrawal transactions observed for an address.""" + path = _funding_actions.build_funding_status_request(address=address) + return _funding_actions.parse_funding_transactions(self._ctx.bridge.get_json(path)) def _user_or_wallet(self, user: str | None) -> str: return self._ctx.wallet if user is None else user diff --git a/src/polymarket/environments.py b/src/polymarket/environments.py index b0307b9..04be940 100644 --- a/src/polymarket/environments.py +++ b/src/polymarket/environments.py @@ -34,6 +34,7 @@ class _EnvironmentConfig: relayer_url: str gamma_url: str data_url: str + bridge_url: str rfq_url: str rtds_ws_url: str sports_ws_url: str @@ -100,6 +101,7 @@ def _create_environment(*, name: str, config: _EnvironmentConfig) -> Environment relayer_url="https://relayer-v2.polymarket.com", gamma_url="https://gamma-api.polymarket.com", data_url="https://data-api.polymarket.com", + bridge_url="https://bridge.polymarket.com", rfq_url="https://combos-rfq-api.polymarket.com", rtds_ws_url="wss://ws-live-data.polymarket.com", sports_ws_url="wss://sports-api.polymarket.com/ws", diff --git a/src/polymarket/models/__init__.py b/src/polymarket/models/__init__.py index d363efe..7d375ba 100644 --- a/src/polymarket/models/__init__.py +++ b/src/polymarket/models/__init__.py @@ -123,6 +123,19 @@ WithdrawalActivity, YieldActivity, ) +from polymarket.models.funding import ( + FundingAddresses, + FundingAddressSet, + FundingAsset, + FundingAssetCatalog, + FundingFeeBreakdown, + FundingQuote, + FundingToken, + FundingTransaction, + FundingTransactionStatus, + FundingWarning, + KnownFundingTransactionStatus, +) from polymarket.models.gamma import ( Comment, Event, @@ -263,6 +276,17 @@ "Erc20TradingApproval", "Erc1155TradingApproval", "GaslessTransaction", + "FundingAddressSet", + "FundingAddresses", + "FundingAsset", + "FundingAssetCatalog", + "FundingFeeBreakdown", + "FundingQuote", + "FundingToken", + "FundingTransaction", + "FundingTransactionStatus", + "FundingWarning", + "KnownFundingTransactionStatus", "LastTradePrice", "LastTradePriceForToken", "MakerOrder", diff --git a/src/polymarket/models/funding.py b/src/polymarket/models/funding.py new file mode 100644 index 0000000..8957fc9 --- /dev/null +++ b/src/polymarket/models/funding.py @@ -0,0 +1,287 @@ +"""Account-funding models.""" + +from datetime import UTC, datetime, timedelta +from decimal import Decimal, DecimalException +from enum import StrEnum +from typing import TypeAlias + +from pydantic import AliasChoices, Field, field_validator + +from polymarket.models.base import BaseModel +from polymarket.types import EvmAddress, TransactionHash + + +class KnownFundingTransactionStatus(StrEnum): + """Known lifecycle states for an account-funding transaction.""" + + DEPOSIT_DETECTED = "DEPOSIT_DETECTED" + PROCESSING = "PROCESSING" + ORIGIN_TRANSACTION_CONFIRMED = "ORIGIN_TX_CONFIRMED" + SUBMITTED = "SUBMITTED" + COMPLETED = "COMPLETED" + FAILED = "FAILED" + + +# The provider may add transaction states between SDK releases. Preserve +# unknown values as strings while making current states discoverable. +FundingTransactionStatus: TypeAlias = KnownFundingTransactionStatus | str + + +def _parse_decimal_number(value: object) -> Decimal: + if isinstance(value, bool): + raise ValueError(f"expected a decimal number, got bool {value!r}") + if isinstance(value, Decimal): + result = value + elif isinstance(value, str | int | float): + try: + result = Decimal(str(value)) + except DecimalException as error: + raise ValueError(f"invalid decimal number: {value!r}") from error + else: + raise ValueError(f"expected a decimal number, got {type(value).__name__}") + if not result.is_finite(): + raise ValueError(f"expected a finite decimal number, got {value!r}") + return result + + +def _parse_nonnegative_integer(value: object) -> int: + if isinstance(value, bool): + raise ValueError(f"expected a non-negative integer, got bool {value!r}") + if isinstance(value, int): + result = value + elif isinstance(value, str) and value.isdecimal(): + result = int(value) + else: + raise ValueError(f"expected a non-negative integer, got {value!r}") + if result < 0: + raise ValueError(f"expected a non-negative integer, got {value!r}") + return result + + +def _parse_positive_integer(value: object) -> int: + result = _parse_nonnegative_integer(value) + if result == 0: + raise ValueError(f"expected a positive integer, got {value!r}") + return result + + +def _parse_evm_address(value: object) -> EvmAddress: + if not isinstance(value, str) or len(value) != 42 or not value.startswith("0x"): + raise ValueError(f"expected an EVM address, got {value!r}") + try: + int(value[2:], 16) + except ValueError as error: + raise ValueError(f"expected an EVM address, got {value!r}") from error + return EvmAddress(value) + + +def _parse_epoch_milliseconds(value: object) -> datetime: + if isinstance(value, datetime): + return value + milliseconds = _parse_nonnegative_integer(value) + try: + return datetime.fromtimestamp(milliseconds / 1000, tz=UTC) + except (OverflowError, OSError, ValueError) as error: + raise ValueError(f"invalid epoch-millisecond timestamp: {value!r}") from error + + +def _parse_milliseconds_duration(value: object) -> timedelta: + if isinstance(value, timedelta): + return value + milliseconds = _parse_nonnegative_integer(value) + return timedelta(milliseconds=milliseconds) + + +class FundingAddresses(BaseModel): + """Chain-specific addresses configured for one funding workflow.""" + + evm: EvmAddress + svm: str = Field(min_length=1) + btc: str = Field(min_length=1) + tron: str | None = Field( + default=None, + min_length=1, + validation_alias=AliasChoices("tron", "tvm"), + ) + + @field_validator("evm", mode="before") + @classmethod + def _parse_evm(cls, value: object) -> EvmAddress: + return _parse_evm_address(value) + + +class FundingWarning(BaseModel): + """A non-fatal warning returned while creating funding addresses.""" + + code: str + message: str + + +class FundingAddressSet(BaseModel): + """Addresses and advisories for a deposit or withdrawal workflow.""" + + addresses: FundingAddresses = Field(validation_alias="address") + note: str | None = None + warnings: tuple[FundingWarning, ...] = () + + +class FundingToken(BaseModel): + """A token available for account funding or withdrawal.""" + + name: str + symbol: str + address: str + decimals: int = Field(ge=0) + + +class FundingAsset(BaseModel): + """A supported chain and token pair.""" + + chain_id: int = Field(validation_alias="chainId") + chain_name: str = Field(validation_alias="chainName") + token: FundingToken + minimum_amount_usd: Decimal = Field(validation_alias="minCheckoutUsd", ge=0) + + @field_validator("chain_id", mode="before") + @classmethod + def _parse_chain_id(cls, value: object) -> int: + return _parse_positive_integer(value) + + @field_validator("minimum_amount_usd", mode="before") + @classmethod + def _parse_minimum_amount(cls, value: object) -> Decimal: + return _parse_decimal_number(value) + + +class FundingAssetCatalog(BaseModel): + """Supported account-funding assets and any current advisory.""" + + assets: tuple[FundingAsset, ...] = Field(validation_alias="supportedAssets") + note: str | None = None + + +class FundingFeeBreakdown(BaseModel): + """Estimated costs included in a funding quote.""" + + app_fee_label: str = Field(validation_alias="appFeeLabel") + app_fee_percent: Decimal = Field(validation_alias="appFeePercent") + app_fee_usd: Decimal = Field(validation_alias="appFeeUsd") + fill_cost_percent: Decimal = Field(validation_alias="fillCostPercent") + fill_cost_usd: Decimal = Field(validation_alias="fillCostUsd") + gas_usd: Decimal = Field(validation_alias="gasUsd") + max_slippage: Decimal = Field(validation_alias="maxSlippage") + minimum_received: Decimal = Field(validation_alias="minReceived") + swap_impact: Decimal = Field(validation_alias="swapImpact") + swap_impact_usd: Decimal = Field(validation_alias="swapImpactUsd") + total_impact: Decimal = Field(validation_alias="totalImpact") + total_impact_usd: Decimal = Field(validation_alias="totalImpactUsd") + + @field_validator( + "app_fee_percent", + "app_fee_usd", + "fill_cost_percent", + "fill_cost_usd", + "gas_usd", + "max_slippage", + "minimum_received", + "swap_impact", + "swap_impact_usd", + "total_impact", + "total_impact_usd", + mode="before", + ) + @classmethod + def _parse_decimal_fields(cls, value: object) -> Decimal: + return _parse_decimal_number(value) + + +class FundingQuote(BaseModel): + """Estimated result and costs for a funding transfer.""" + + estimated_checkout_time: timedelta = Field(validation_alias="estCheckoutTimeMs") + estimated_fees: FundingFeeBreakdown = Field(validation_alias="estFeeBreakdown") + estimated_input_usd: Decimal = Field(validation_alias="estInputUsd") + estimated_output_usd: Decimal = Field(validation_alias="estOutputUsd") + estimated_destination_amount: int = Field(validation_alias="estToTokenBaseUnit") + quote_id: str = Field(validation_alias="quoteId", min_length=1) + + @field_validator("estimated_checkout_time", mode="before") + @classmethod + def _parse_checkout_time(cls, value: object) -> timedelta: + return _parse_milliseconds_duration(value) + + @field_validator("estimated_input_usd", "estimated_output_usd", mode="before") + @classmethod + def _parse_decimal_fields(cls, value: object) -> Decimal: + return _parse_decimal_number(value) + + @field_validator("estimated_destination_amount", mode="before") + @classmethod + def _parse_destination_amount(cls, value: object) -> int: + return _parse_nonnegative_integer(value) + + +class FundingTransaction(BaseModel): + """The current state of one account-funding transaction.""" + + source_chain_id: int = Field(validation_alias="fromChainId") + source_token_address: str = Field(validation_alias="fromTokenAddress") + source_amount: int = Field(validation_alias="fromAmountBaseUnit") + destination_chain_id: int = Field(validation_alias="toChainId") + destination_token_address: str = Field(validation_alias="toTokenAddress") + status: FundingTransactionStatus + transaction_hash: TransactionHash | None = Field(default=None, validation_alias="txHash") + created_at: datetime | None = Field(default=None, validation_alias="createdTimeMs") + + @field_validator("source_chain_id", "destination_chain_id", mode="before") + @classmethod + def _parse_chain_ids(cls, value: object) -> int: + return _parse_positive_integer(value) + + @field_validator("source_amount", mode="before") + @classmethod + def _parse_source_amount(cls, value: object) -> int: + return _parse_nonnegative_integer(value) + + @field_validator("status", mode="before") + @classmethod + def _parse_status(cls, value: object) -> FundingTransactionStatus: + if isinstance(value, KnownFundingTransactionStatus): + return value + if not isinstance(value, str) or not value: + raise ValueError(f"expected a funding transaction status, got {value!r}") + try: + return KnownFundingTransactionStatus(value) + except ValueError: + return value + + @field_validator("transaction_hash", mode="before") + @classmethod + def _parse_transaction_hash(cls, value: object) -> TransactionHash | None: + if value is None: + return None + if not isinstance(value, str) or not value: + raise ValueError(f"expected a transaction hash, got {value!r}") + return TransactionHash(value) + + @field_validator("created_at", mode="before") + @classmethod + def _parse_created_at(cls, value: object) -> datetime | None: + if value is None: + return None + return _parse_epoch_milliseconds(value) + + +__all__ = [ + "FundingAddressSet", + "FundingAddresses", + "FundingAsset", + "FundingAssetCatalog", + "FundingFeeBreakdown", + "FundingQuote", + "FundingToken", + "FundingTransaction", + "FundingTransactionStatus", + "FundingWarning", + "KnownFundingTransactionStatus", +] diff --git a/tests/integration/test_funding.py b/tests/integration/test_funding.py new file mode 100644 index 0000000..ffcbff8 --- /dev/null +++ b/tests/integration/test_funding.py @@ -0,0 +1,80 @@ +"""Live account-funding workflow coverage.""" + +from decimal import Decimal + +import pytest + +from polymarket import AsyncPublicClient + +pytestmark = [pytest.mark.anyio, pytest.mark.integration] + +_WALLET = "0x0000000000000000000000000000000000000001" +_POLYGON_CHAIN_ID = 137 +_POLYGON_USDC = "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359" +_POLYGON_POLYMARKET_USDC = "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB" +_DOCUMENTED_FUNDING_ADDRESS = "0x23566f8b2E82aDfCf01846E54899d110e97AC053" + + +@pytest.mark.metered +async def test_create_deposit_addresses_live(public_client: AsyncPublicClient) -> None: + """Creates provider-side address records but does not move or spend funds.""" + result = await public_client.create_deposit_addresses(wallet=_WALLET) + + assert result.addresses.evm + assert result.addresses.svm + assert result.addresses.btc + + +@pytest.mark.metered +async def test_create_withdrawal_addresses_live(public_client: AsyncPublicClient) -> None: + """Creates provider-side address records but does not initiate a transfer.""" + result = await public_client.create_withdrawal_addresses( + wallet=_WALLET, + destination_chain_id=_POLYGON_CHAIN_ID, + destination_token_address=_POLYGON_USDC, + recipient_address=_WALLET, + ) + + assert result.addresses.evm + assert result.addresses.svm + assert result.addresses.btc + + +async def test_get_supported_funding_assets_live(public_client: AsyncPublicClient) -> None: + catalog = await public_client.get_supported_funding_assets() + + assert catalog.assets + asset = catalog.assets[0] + assert asset.chain_id > 0 + assert asset.chain_name + assert asset.token.symbol + assert asset.token.decimals >= 0 + assert asset.minimum_amount_usd >= Decimal(0) + + +async def test_get_funding_quote_live(public_client: AsyncPublicClient) -> None: + quote = await public_client.get_funding_quote( + amount=10_000_000, + source_chain_id=_POLYGON_CHAIN_ID, + source_token_address=_POLYGON_USDC, + destination_chain_id=_POLYGON_CHAIN_ID, + destination_token_address=_POLYGON_POLYMARKET_USDC, + recipient_address=_WALLET, + ) + + assert quote.quote_id + assert quote.estimated_destination_amount > 0 + assert quote.estimated_checkout_time.total_seconds() >= 0 + + +async def test_get_funding_transactions_live(public_client: AsyncPublicClient) -> None: + transactions = await public_client.get_funding_transactions(address=_DOCUMENTED_FUNDING_ADDRESS) + + assert transactions + transaction = transactions[0] + assert transaction.source_chain_id > 0 + assert transaction.source_token_address + assert transaction.source_amount >= 0 + assert transaction.destination_chain_id > 0 + assert transaction.destination_token_address + assert transaction.status diff --git a/tests/unit/test_builder_trades.py b/tests/unit/test_builder_trades.py index 4b7bf3b..e2d3503 100644 --- a/tests/unit/test_builder_trades.py +++ b/tests/unit/test_builder_trades.py @@ -277,6 +277,7 @@ def test_first_page_hits_builder_trades_endpoint_with_filters(self) -> None: environment=client._ctx.environment, gamma=client._ctx.gamma, data=client._ctx.data, + bridge=client._ctx.bridge, rfq=client._ctx.rfq, clob=SyncTransport( base_url=PRODUCTION_CONFIG.clob_url, @@ -327,6 +328,7 @@ async def run() -> list[str]: environment=client._ctx.environment, gamma=client._ctx.gamma, data=client._ctx.data, + bridge=client._ctx.bridge, rfq=client._ctx.rfq, clob=AsyncTransport( base_url=PRODUCTION_CONFIG.clob_url, diff --git a/tests/unit/test_funding_actions.py b/tests/unit/test_funding_actions.py new file mode 100644 index 0000000..75bb512 --- /dev/null +++ b/tests/unit/test_funding_actions.py @@ -0,0 +1,378 @@ +from collections.abc import Callable + +import pytest + +from polymarket._internal.actions.funding import ( + build_create_deposit_addresses_request, + build_create_withdrawal_addresses_request, + build_funding_quote_request, + build_funding_status_request, + parse_funding_address_set, + parse_funding_asset_catalog, + parse_funding_quote, + parse_funding_transactions, +) +from polymarket.errors import UnexpectedResponseError, UserInputError +from polymarket.models.funding import ( + FundingAddressSet, + FundingAssetCatalog, + FundingQuote, + FundingTransaction, +) + +_WALLET_LOWER = "0x52908400098527886e0f7030069857d2e4169ee7" +_WALLET_CHECKSUM = "0x52908400098527886E0F7030069857D2E4169EE7" +_BUILDER_CODE = "0x" + "ab" * 32 + + +def _address_set_payload() -> dict[str, object]: + return { + "address": { + "evm": _WALLET_CHECKSUM, + "svm": "CrvTBvzryYxBHbWu2TiQpcqD5M7Le7iBKzVmEj3f36Jb", + "btc": "bc1q8eau83qffxcj8ht4hsjdza3lha9r3egfqysj3g", + "tron": "TP1mjRAUVe5qXfCnpczdWhSrGpos2Arzir", + }, + "note": "Only supported assets should be sent.", + "warnings": [ + { + "code": "missing_builder_code", + "message": "Include X-Builder-Code for attribution.", + } + ], + } + + +def _asset_catalog_payload() -> dict[str, object]: + return { + "supportedAssets": [ + { + "chainId": "728126428", + "chainName": "Tron", + "token": { + "name": "Tether USD", + "symbol": "USDT", + "address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + "decimals": 6, + }, + "minCheckoutUsd": 7, + } + ], + "note": "Assets may be used for deposits and withdrawals.", + } + + +def _quote_payload() -> dict[str, object]: + return { + "estCheckoutTimeMs": 25_000, + "estFeeBreakdown": { + "appFeeLabel": "Fun.xyz fee", + "appFeePercent": 0, + "appFeeUsd": 0, + "fillCostPercent": 0, + "fillCostUsd": 0, + "gasUsd": 0.003854, + "maxSlippage": 0, + "minReceived": 14.488305, + "swapImpact": 0, + "swapImpactUsd": 0, + "totalImpact": 0, + "totalImpactUsd": 0, + }, + "estInputUsd": 14.488305, + "estOutputUsd": 14.488305, + "estToTokenBaseUnit": "14491203", + "quoteId": "0xquote", + } + + +def _transaction_payload() -> dict[str, object]: + return { + "fromChainId": "1151111081099710", + "fromTokenAddress": "11111111111111111111111111111111", + "fromAmountBaseUnit": "13566635", + "toChainId": "137", + "toTokenAddress": "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB", + "status": "COMPLETED", + "txHash": "3atr19NAiNCYt24RHM1WnzZp47RXskpTDzspJoCBBaMFw", + "createdTimeMs": 1_757_531_217_339, + } + + +def test_build_deposit_request_serializes_wallet_and_builder_header() -> None: + path, body, headers = build_create_deposit_addresses_request( + wallet=_WALLET_LOWER, + builder_code=_BUILDER_CODE, + ) + + assert path == "/deposit" + assert body == {"address": _WALLET_CHECKSUM} + assert headers == {"X-Builder-Code": _BUILDER_CODE} + + +def test_build_deposit_request_omits_builder_header_when_unset() -> None: + _, _, headers = build_create_deposit_addresses_request(wallet=_WALLET_LOWER) + + assert headers == {} + + +@pytest.mark.parametrize( + "builder_code", + [ + "", + "ab" * 32, + "0x" + "ab" * 31, + "0x" + "ab" * 33, + "0x" + "zz" * 32, + ], +) +def test_build_deposit_request_rejects_malformed_builder_code(builder_code: str) -> None: + with pytest.raises(UserInputError, match="builder_code"): + build_create_deposit_addresses_request( + wallet=_WALLET_LOWER, + builder_code=builder_code, + ) + + +def test_build_deposit_request_rejects_non_string_builder_code() -> None: + with pytest.raises(UserInputError, match="builder_code"): + build_create_deposit_addresses_request( + wallet=_WALLET_LOWER, + builder_code=42, # type: ignore[arg-type] + ) + + +@pytest.mark.parametrize( + "wallet", + ["", "0x1234", "not-an-address", "52908400098527886e0f7030069857d2e4169ee7"], +) +def test_build_deposit_request_rejects_invalid_wallet(wallet: str) -> None: + with pytest.raises(UserInputError, match="wallet"): + build_create_deposit_addresses_request(wallet=wallet) + + +def test_build_deposit_request_rejects_non_string_wallet() -> None: + with pytest.raises(UserInputError, match="wallet"): + build_create_deposit_addresses_request(wallet=42) # type: ignore[arg-type] + + +def test_build_withdrawal_request_uses_wire_names_and_string_chain_id() -> None: + path, body, headers = build_create_withdrawal_addresses_request( + wallet=_WALLET_LOWER, + destination_chain_id=728126428, + destination_token_address="TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + recipient_address="TP1mjRAUVe5qXfCnpczdWhSrGpos2Arzir", + builder_code=_BUILDER_CODE, + ) + + assert path == "/withdraw" + assert body == { + "address": _WALLET_CHECKSUM, + "toChainId": "728126428", + "toTokenAddress": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + "recipientAddr": "TP1mjRAUVe5qXfCnpczdWhSrGpos2Arzir", + } + assert headers == {"X-Builder-Code": _BUILDER_CODE} + + +def test_build_withdrawal_request_rejects_invalid_builder_code() -> None: + with pytest.raises(UserInputError, match="builder_code"): + build_create_withdrawal_addresses_request( + wallet=_WALLET_LOWER, + destination_chain_id=1, + destination_token_address="USDC", + recipient_address="recipient", + builder_code="invalid", + ) + + +@pytest.mark.parametrize("destination_chain_id", [0, -1, True, 1.5]) +def test_build_withdrawal_request_rejects_invalid_chain_id( + destination_chain_id: object, +) -> None: + with pytest.raises(UserInputError, match="destination_chain_id"): + build_create_withdrawal_addresses_request( + wallet=_WALLET_LOWER, + destination_chain_id=destination_chain_id, # type: ignore[arg-type] + destination_token_address="USDC", + recipient_address="recipient", + ) + + +def test_build_withdrawal_request_rejects_empty_destination_token() -> None: + with pytest.raises(UserInputError, match="destination_token_address"): + build_create_withdrawal_addresses_request( + wallet=_WALLET_LOWER, + destination_chain_id=1, + destination_token_address="", + recipient_address="recipient", + ) + + +def test_build_withdrawal_request_rejects_empty_recipient() -> None: + with pytest.raises(UserInputError, match="recipient_address"): + build_create_withdrawal_addresses_request( + wallet=_WALLET_LOWER, + destination_chain_id=1, + destination_token_address="USDC", + recipient_address="", + ) + + +def test_build_quote_request_serializes_integers_as_wire_strings() -> None: + path, body = build_funding_quote_request( + amount=10_000_000, + source_chain_id=137, + source_token_address="0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", + destination_chain_id=137, + destination_token_address="0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB", + recipient_address=_WALLET_CHECKSUM, + ) + + assert path == "/quote" + assert body == { + "fromAmountBaseUnit": "10000000", + "fromChainId": "137", + "fromTokenAddress": "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", + "recipientAddress": _WALLET_CHECKSUM, + "toChainId": "137", + "toTokenAddress": "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB", + } + + +@pytest.mark.parametrize("amount", [0, -1, True, 1.5]) +def test_build_quote_request_rejects_invalid_amount(amount: object) -> None: + with pytest.raises(UserInputError, match="amount"): + build_funding_quote_request( + amount=amount, # type: ignore[arg-type] + source_chain_id=1, + source_token_address="source-token", + destination_chain_id=137, + destination_token_address="destination-token", + recipient_address="recipient", + ) + + +@pytest.mark.parametrize("source_chain_id", [0, -1, True, 1.5]) +def test_build_quote_request_rejects_invalid_source_chain_id(source_chain_id: object) -> None: + with pytest.raises(UserInputError, match="source_chain_id"): + build_funding_quote_request( + amount=1, + source_chain_id=source_chain_id, # type: ignore[arg-type] + source_token_address="source-token", + destination_chain_id=137, + destination_token_address="destination-token", + recipient_address="recipient", + ) + + +def test_build_quote_request_rejects_empty_source_token() -> None: + with pytest.raises(UserInputError, match="source_token_address"): + build_funding_quote_request( + amount=1, + source_chain_id=1, + source_token_address="", + destination_chain_id=137, + destination_token_address="destination-token", + recipient_address="recipient", + ) + + +def test_build_quote_request_rejects_empty_destination_token() -> None: + with pytest.raises(UserInputError, match="destination_token_address"): + build_funding_quote_request( + amount=1, + source_chain_id=1, + source_token_address="source-token", + destination_chain_id=137, + destination_token_address="", + recipient_address="recipient", + ) + + +def test_build_quote_request_rejects_empty_recipient() -> None: + with pytest.raises(UserInputError, match="recipient_address"): + build_funding_quote_request( + amount=1, + source_chain_id=1, + source_token_address="source-token", + destination_chain_id=137, + destination_token_address="destination-token", + recipient_address="", + ) + + +def test_build_quote_request_trims_generic_chain_addresses() -> None: + _, body = build_funding_quote_request( + amount=1, + source_chain_id=1, + source_token_address=" source-token ", + destination_chain_id=137, + destination_token_address=" destination-token ", + recipient_address=" recipient ", + ) + + assert body["fromTokenAddress"] == "source-token" + assert body["toTokenAddress"] == "destination-token" + assert body["recipientAddress"] == "recipient" + + +@pytest.mark.parametrize( + "field", ["source_token_address", "destination_token_address", "recipient_address"] +) +def test_build_quote_request_rejects_whitespace_only_address(field: str) -> None: + values = { + "amount": 1, + "source_chain_id": 1, + "source_token_address": "source-token", + "destination_chain_id": 137, + "destination_token_address": "destination-token", + "recipient_address": "recipient", + } + values[field] = " " + + with pytest.raises(UserInputError, match=field): + build_funding_quote_request(**values) # type: ignore[arg-type] + + +def test_build_status_request_percent_encodes_non_evm_address() -> None: + assert build_funding_status_request(address="tron/address with space") == ( + "/status/tron%2Faddress%20with%20space" + ) + + +@pytest.mark.parametrize("address", ["", " ", 42]) +def test_build_status_request_rejects_invalid_address(address: object) -> None: + with pytest.raises(UserInputError, match="address"): + build_funding_status_request(address=address) # type: ignore[arg-type] + + +def test_funding_parsers_return_public_models() -> None: + address_set = parse_funding_address_set(_address_set_payload()) + catalog = parse_funding_asset_catalog(_asset_catalog_payload()) + quote = parse_funding_quote(_quote_payload()) + transactions = parse_funding_transactions({"transactions": [_transaction_payload()]}) + + assert isinstance(address_set, FundingAddressSet) + assert isinstance(catalog, FundingAssetCatalog) + assert isinstance(quote, FundingQuote) + assert len(transactions) == 1 + assert isinstance(transactions[0], FundingTransaction) + + +@pytest.mark.parametrize( + ("parser", "payload"), + [ + (parse_funding_address_set, {}), + (parse_funding_asset_catalog, {"supportedAssets": "not-a-list"}), + (parse_funding_quote, {}), + (parse_funding_transactions, {}), + (parse_funding_transactions, {"transactions": {}}), + ], +) +def test_funding_parsers_map_malformed_responses_to_unexpected_response( + parser: Callable[[object], object], + payload: object, +) -> None: + with pytest.raises(UnexpectedResponseError): + parser(payload) diff --git a/tests/unit/test_funding_clients.py b/tests/unit/test_funding_clients.py new file mode 100644 index 0000000..0979ee4 --- /dev/null +++ b/tests/unit/test_funding_clients.py @@ -0,0 +1,348 @@ +# pyright: reportPrivateUsage=false +import asyncio +import dataclasses +import inspect +import json +from typing import Any, cast +from urllib.parse import urlparse + +import httpx + +from polymarket import ( + ApiKeyCreds, + AsyncPublicClient, + AsyncSecureClient, + FundingAddressSet, + FundingAssetCatalog, + FundingQuote, + FundingTransaction, + KnownFundingTransactionStatus, + PublicClient, + SecureClient, +) +from polymarket._internal.context import AsyncSecureClientContext, SyncSecureClientContext +from polymarket.clients._transport import AsyncTransport, SyncTransport + +_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" +_SIGNER_ADDRESS = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" +_BOUND_WALLET = "0xBc0fF067b7740Eff76C1ca93c875Ba6B890d6B50" +_PUBLIC_WALLET = "0x52908400098527886e0f7030069857d2e4169ee7" +_PUBLIC_WALLET_CHECKSUM = "0x52908400098527886E0F7030069857D2E4169EE7" +_BUILDER_CODE = "0x" + "ab" * 32 +_SOURCE_TOKEN = "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359" +_DESTINATION_TOKEN = "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB" +_TRON_TOKEN = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t" +_TRON_RECIPIENT = "TP1mjRAUVe5qXfCnpczdWhSrGpos2Arzir" +_FAKE_CREDS = ApiKeyCreds( + key="test-key", + passphrase="test-passphrase", + secret="dGVzdA==", +) + +_ADDRESS_SET_PAYLOAD: dict[str, object] = { + "address": { + "evm": _PUBLIC_WALLET_CHECKSUM, + "svm": "CrvTBvzryYxBHbWu2TiQpcqD5M7Le7iBKzVmEj3f36Jb", + "btc": "bc1q8eau83qffxcj8ht4hsjdza3lha9r3egfqysj3g", + "tron": _TRON_RECIPIENT, + } +} +_ASSET_CATALOG_PAYLOAD: dict[str, object] = { + "supportedAssets": [ + { + "chainId": "137", + "chainName": "Polygon", + "token": { + "name": "USD Coin", + "symbol": "USDC.e", + "address": _DESTINATION_TOKEN, + "decimals": 6, + }, + "minCheckoutUsd": "5", + } + ] +} +_QUOTE_PAYLOAD: dict[str, object] = { + "estCheckoutTimeMs": 25_000, + "estFeeBreakdown": { + "appFeeLabel": "Fun.xyz fee", + "appFeePercent": 0, + "appFeeUsd": 0, + "fillCostPercent": 0, + "fillCostUsd": 0, + "gasUsd": "0.01", + "maxSlippage": "0.5", + "minReceived": "9.9", + "swapImpact": 0, + "swapImpactUsd": 0, + "totalImpact": 0, + "totalImpactUsd": 0, + }, + "estInputUsd": "10", + "estOutputUsd": "9.99", + "estToTokenBaseUnit": "9990000", + "quoteId": "quote-1", +} +_TRANSACTIONS_PAYLOAD: dict[str, object] = { + "transactions": [ + { + "fromChainId": "1", + "fromTokenAddress": _SOURCE_TOKEN, + "fromAmountBaseUnit": "10000000", + "toChainId": "137", + "toTokenAddress": _DESTINATION_TOKEN, + "status": "COMPLETED", + } + ] +} + + +def _bridge_handler(captured: list[httpx.Request]) -> httpx.MockTransport: + responses: dict[tuple[str, str], dict[str, object]] = { + ("POST", "/deposit"): _ADDRESS_SET_PAYLOAD, + ("POST", "/withdraw"): _ADDRESS_SET_PAYLOAD, + ("GET", "/supported-assets"): _ASSET_CATALOG_PAYLOAD, + ("POST", "/quote"): _QUOTE_PAYLOAD, + ("GET", f"/status/{_PUBLIC_WALLET_CHECKSUM}"): _TRANSACTIONS_PAYLOAD, + } + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + route = (request.method, urlparse(str(request.url)).path) + payload = responses.get(route) + if payload is None: + raise AssertionError(f"Unexpected funding request: {route!r}") + return httpx.Response(200, json=payload, request=request) + + return httpx.MockTransport(handler) + + +def _install_sync_bridge( + client: PublicClient | SecureClient, + handler: httpx.MockTransport, +) -> httpx.Client: + http_client = httpx.Client(base_url="https://bridge.test", transport=handler) + bridge = SyncTransport(base_url="https://bridge.test", client=http_client) + bridge._owns_client = True + client._ctx.bridge.close() + client._ctx = cast( + SyncSecureClientContext, + dataclasses.replace(client._ctx, bridge=bridge), + ) + return http_client + + +async def _install_async_bridge( + client: AsyncPublicClient | AsyncSecureClient, + handler: httpx.MockTransport, +) -> httpx.AsyncClient: + http_client = httpx.AsyncClient(base_url="https://bridge.test", transport=handler) + bridge = AsyncTransport(base_url="https://bridge.test", client=http_client) + bridge._owns_client = True + await client._ctx.bridge.close() + client._ctx = cast( + AsyncSecureClientContext, + dataclasses.replace(client._ctx, bridge=bridge), + ) + return http_client + + +def _assert_public_results( + deposit: FundingAddressSet, + withdrawal: FundingAddressSet, + catalog: FundingAssetCatalog, + quote: FundingQuote, + transactions: tuple[FundingTransaction, ...], +) -> None: + assert deposit.addresses.tron == _TRON_RECIPIENT + assert withdrawal.addresses.evm == _PUBLIC_WALLET_CHECKSUM + assert catalog.assets[0].chain_id == 137 + assert quote.quote_id == "quote-1" + assert len(transactions) == 1 + assert transactions[0].status is KnownFundingTransactionStatus.COMPLETED + + +def _assert_public_requests(captured: list[httpx.Request]) -> None: + assert [(request.method, urlparse(str(request.url)).path) for request in captured] == [ + ("POST", "/deposit"), + ("POST", "/withdraw"), + ("GET", "/supported-assets"), + ("POST", "/quote"), + ("GET", f"/status/{_PUBLIC_WALLET_CHECKSUM}"), + ] + assert all(request.url.host == "bridge.test" for request in captured) + assert json.loads(captured[0].content) == {"address": _PUBLIC_WALLET_CHECKSUM} + assert captured[0].headers["X-Builder-Code"] == _BUILDER_CODE + assert json.loads(captured[1].content) == { + "address": _PUBLIC_WALLET_CHECKSUM, + "toChainId": "728126428", + "toTokenAddress": _TRON_TOKEN, + "recipientAddr": _TRON_RECIPIENT, + } + assert captured[1].headers["X-Builder-Code"] == _BUILDER_CODE + assert captured[2].content == b"" + assert json.loads(captured[3].content) == { + "fromAmountBaseUnit": "10000000", + "fromChainId": "137", + "fromTokenAddress": _SOURCE_TOKEN, + "recipientAddress": _PUBLIC_WALLET_CHECKSUM, + "toChainId": "137", + "toTokenAddress": _DESTINATION_TOKEN, + } + assert "X-Builder-Code" not in captured[3].headers + assert captured[4].content == b"" + + +def _public_funding_args() -> dict[str, Any]: + return { + "amount": 10_000_000, + "source_chain_id": 137, + "source_token_address": _SOURCE_TOKEN, + "destination_chain_id": 137, + "destination_token_address": _DESTINATION_TOKEN, + "recipient_address": _PUBLIC_WALLET_CHECKSUM, + } + + +def test_sync_public_funding_calls_use_bridge_transport_and_close_it() -> None: + captured: list[httpx.Request] = [] + + with PublicClient() as client: + bridge_client = _install_sync_bridge(client, _bridge_handler(captured)) + deposit = client.create_deposit_addresses( + wallet=_PUBLIC_WALLET, + builder_code=_BUILDER_CODE, + ) + withdrawal = client.create_withdrawal_addresses( + wallet=_PUBLIC_WALLET, + destination_chain_id=728126428, + destination_token_address=_TRON_TOKEN, + recipient_address=_TRON_RECIPIENT, + builder_code=_BUILDER_CODE, + ) + catalog = client.get_supported_funding_assets() + quote = client.get_funding_quote(**_public_funding_args()) + transactions = client.get_funding_transactions(address=_PUBLIC_WALLET_CHECKSUM) + + assert bridge_client.is_closed + _assert_public_results(deposit, withdrawal, catalog, quote, transactions) + _assert_public_requests(captured) + + +def test_async_public_funding_calls_use_bridge_transport_and_close_it() -> None: + captured: list[httpx.Request] = [] + + async def run() -> tuple[ + FundingAddressSet, + FundingAddressSet, + FundingAssetCatalog, + FundingQuote, + tuple[FundingTransaction, ...], + httpx.AsyncClient, + ]: + async with AsyncPublicClient() as client: + bridge_client = await _install_async_bridge(client, _bridge_handler(captured)) + deposit = await client.create_deposit_addresses( + wallet=_PUBLIC_WALLET, + builder_code=_BUILDER_CODE, + ) + withdrawal = await client.create_withdrawal_addresses( + wallet=_PUBLIC_WALLET, + destination_chain_id=728126428, + destination_token_address=_TRON_TOKEN, + recipient_address=_TRON_RECIPIENT, + builder_code=_BUILDER_CODE, + ) + catalog = await client.get_supported_funding_assets() + quote = await client.get_funding_quote(**_public_funding_args()) + transactions = await client.get_funding_transactions(address=_PUBLIC_WALLET_CHECKSUM) + return deposit, withdrawal, catalog, quote, transactions, bridge_client + + deposit, withdrawal, catalog, quote, transactions, bridge_client = asyncio.run(run()) + + assert bridge_client.is_closed + _assert_public_results(deposit, withdrawal, catalog, quote, transactions) + _assert_public_requests(captured) + + +def _assert_secure_address_requests( + captured: list[httpx.Request], + *, + wallet: str, +) -> None: + assert [(request.method, urlparse(str(request.url)).path) for request in captured] == [ + ("POST", "/deposit"), + ("POST", "/withdraw"), + ] + assert all(request.url.host == "bridge.test" for request in captured) + assert json.loads(captured[0].content) == {"address": wallet} + assert json.loads(captured[1].content) == { + "address": wallet, + "toChainId": "728126428", + "toTokenAddress": _TRON_TOKEN, + "recipientAddr": _TRON_RECIPIENT, + } + assert all(request.headers["X-Builder-Code"] == _BUILDER_CODE for request in captured) + + +def test_sync_secure_address_creation_uses_only_bound_wallet() -> None: + assert "wallet" not in inspect.signature(SecureClient.create_deposit_addresses).parameters + assert "wallet" not in inspect.signature(SecureClient.create_withdrawal_addresses).parameters + captured: list[httpx.Request] = [] + + with SecureClient._create( + private_key=_PRIVATE_KEY, + wallet=_BOUND_WALLET, + credentials=_FAKE_CREDS, + validate_credentials=False, + ) as client: + assert client.wallet != client.signer + bound_wallet = str(client.wallet) + bridge_client = _install_sync_bridge(client, _bridge_handler(captured)) + deposit = client.create_deposit_addresses(builder_code=_BUILDER_CODE) + withdrawal = client.create_withdrawal_addresses( + destination_chain_id=728126428, + destination_token_address=_TRON_TOKEN, + recipient_address=_TRON_RECIPIENT, + builder_code=_BUILDER_CODE, + ) + + assert bridge_client.is_closed + assert isinstance(deposit, FundingAddressSet) + assert isinstance(withdrawal, FundingAddressSet) + _assert_secure_address_requests(captured, wallet=bound_wallet) + + +def test_async_secure_address_creation_uses_only_bound_wallet() -> None: + assert "wallet" not in inspect.signature(AsyncSecureClient.create_deposit_addresses).parameters + assert ( + "wallet" not in inspect.signature(AsyncSecureClient.create_withdrawal_addresses).parameters + ) + captured: list[httpx.Request] = [] + + async def run() -> tuple[str, FundingAddressSet, FundingAddressSet, httpx.AsyncClient]: + client = await AsyncSecureClient._create( + private_key=_PRIVATE_KEY, + wallet=_BOUND_WALLET, + credentials=_FAKE_CREDS, + validate_credentials=False, + ) + async with client: + assert client.wallet != client.signer + bound_wallet = str(client.wallet) + bridge_client = await _install_async_bridge(client, _bridge_handler(captured)) + deposit = await client.create_deposit_addresses(builder_code=_BUILDER_CODE) + withdrawal = await client.create_withdrawal_addresses( + destination_chain_id=728126428, + destination_token_address=_TRON_TOKEN, + recipient_address=_TRON_RECIPIENT, + builder_code=_BUILDER_CODE, + ) + return bound_wallet, deposit, withdrawal, bridge_client + + bound_wallet, deposit, withdrawal, bridge_client = asyncio.run(run()) + + assert bridge_client.is_closed + assert isinstance(deposit, FundingAddressSet) + assert isinstance(withdrawal, FundingAddressSet) + _assert_secure_address_requests(captured, wallet=bound_wallet) diff --git a/tests/unit/test_funding_models.py b/tests/unit/test_funding_models.py new file mode 100644 index 0000000..1aaecd0 --- /dev/null +++ b/tests/unit/test_funding_models.py @@ -0,0 +1,272 @@ +from datetime import UTC, datetime, timedelta +from decimal import Decimal +from typing import get_args, get_type_hints + +import pytest + +from polymarket.errors import UnexpectedResponseError +from polymarket.models.funding import ( + FundingAddressSet, + FundingAsset, + FundingAssetCatalog, + FundingQuote, + FundingTransaction, + KnownFundingTransactionStatus, +) + +_EVM_ADDRESS = "0x23566f8b2E82aDfCf01846E54899d110e97AC053" +_SVM_ADDRESS = "CrvTBvzryYxBHbWu2TiQpcqD5M7Le7iBKzVmEj3f36Jb" +_BTC_ADDRESS = "bc1q8eau83qffxcj8ht4hsjdza3lha9r3egfqysj3g" +_TRON_ADDRESS = "TP1mjRAUVe5qXfCnpczdWhSrGpos2Arzir" + + +def _address_set_payload(*, tron_field: str = "tron") -> dict[str, object]: + return { + "address": { + "evm": _EVM_ADDRESS, + "svm": _SVM_ADDRESS, + "btc": _BTC_ADDRESS, + tron_field: _TRON_ADDRESS, + }, + "note": "Only certain chains and tokens are supported.", + "warnings": [ + { + "code": "missing_builder_code", + "message": "Include the X-Builder-Code header for attribution.", + } + ], + } + + +def _quote_payload() -> dict[str, object]: + return { + "estCheckoutTimeMs": "25000", + "estFeeBreakdown": { + "appFeeLabel": "Fun.xyz fee", + "appFeePercent": "0", + "appFeeUsd": 0, + "fillCostPercent": "0.1", + "fillCostUsd": 0.01, + "gasUsd": 0.003854, + "maxSlippage": "0.5", + "minReceived": 14.488305, + "swapImpact": "0.05", + "swapImpactUsd": 0.005, + "totalImpact": "0.6", + "totalImpactUsd": 0.06, + }, + "estInputUsd": "14.488305", + "estOutputUsd": 14.4, + "estToTokenBaseUnit": "14491203", + "quoteId": "0x00c34ba467184b0146406d62b0e60aaa24ed52460bd456222b6155a0d9de0ad5", + } + + +def _transaction_payload(**overrides: object) -> dict[str, object]: + payload: dict[str, object] = { + "fromChainId": "1151111081099710", + "fromTokenAddress": "11111111111111111111111111111111", + "fromAmountBaseUnit": "13566635", + "toChainId": "137", + "toTokenAddress": "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB", + "status": "COMPLETED", + } + payload.update(overrides) + return payload + + +def test_address_set_parses_live_tron_and_warning_shape() -> None: + result = FundingAddressSet.parse_response(_address_set_payload()) + + assert result.addresses.evm == _EVM_ADDRESS + assert result.addresses.svm == _SVM_ADDRESS + assert result.addresses.btc == _BTC_ADDRESS + assert result.addresses.tron == _TRON_ADDRESS + assert result.note == "Only certain chains and tokens are supported." + assert len(result.warnings) == 1 + assert result.warnings[0].code == "missing_builder_code" + + +@pytest.mark.parametrize("wire_field", ["tron", "tvm"]) +def test_address_set_normalizes_tron_and_tvm_wire_fields(wire_field: str) -> None: + result = FundingAddressSet.parse_response(_address_set_payload(tron_field=wire_field)) + + assert result.addresses.tron == _TRON_ADDRESS + dumped = result.model_dump() + assert dumped["addresses"]["tron"] == _TRON_ADDRESS + assert "tvm" not in dumped["addresses"] + + +def test_address_set_prefers_live_tron_field_when_both_variants_are_present() -> None: + payload = _address_set_payload() + addresses = payload["address"] + assert isinstance(addresses, dict) + addresses["tvm"] = "legacy-tvm-address" + + result = FundingAddressSet.parse_response(payload) + + assert result.addresses.tron == _TRON_ADDRESS + + +def test_address_set_defaults_optional_advisories() -> None: + result = FundingAddressSet.parse_response( + {"address": {"evm": _EVM_ADDRESS, "svm": _SVM_ADDRESS, "btc": _BTC_ADDRESS}} + ) + + assert result.addresses.tron is None + assert result.note is None + assert result.warnings == () + + +def test_address_set_rejects_malformed_evm_address() -> None: + payload = _address_set_payload() + addresses = payload["address"] + assert isinstance(addresses, dict) + addresses["evm"] = "0x1234" + + with pytest.raises(UnexpectedResponseError, match="FundingAddressSet response"): + FundingAddressSet.parse_response(payload) + + +def test_asset_catalog_normalizes_chain_minimum_and_note() -> None: + result = FundingAssetCatalog.parse_response( + { + "supportedAssets": [ + { + "chainId": "728126428", + "chainName": "Tron", + "token": { + "name": "Tether USD", + "symbol": "USDT", + "address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + "decimals": 6, + }, + "minCheckoutUsd": "7.25", + } + ], + "note": "These assets support deposits and withdrawals.", + } + ) + + assert len(result.assets) == 1 + assert result.assets[0].chain_id == 728126428 + assert isinstance(result.assets[0].chain_id, int) + assert result.assets[0].minimum_amount_usd == Decimal("7.25") + assert isinstance(result.assets[0].minimum_amount_usd, Decimal) + assert result.assets[0].token.decimals == 6 + assert result.note == "These assets support deposits and withdrawals." + + +@pytest.mark.parametrize("minimum", ["NaN", "Infinity", True, -1, -float("inf")]) +def test_asset_catalog_rejects_non_finite_or_boolean_minimum(minimum: object) -> None: + payload = { + "supportedAssets": [ + { + "chainId": "1", + "chainName": "Ethereum", + "token": {"name": "USD Coin", "symbol": "USDC", "address": "0xUSDC", "decimals": 6}, + "minCheckoutUsd": minimum, + } + ] + } + + with pytest.raises(UnexpectedResponseError, match="FundingAssetCatalog response"): + FundingAssetCatalog.parse_response(payload) + + +def test_quote_normalizes_amounts_and_time_to_canonical_types() -> None: + result = FundingQuote.parse_response(_quote_payload()) + + assert result.estimated_checkout_time == timedelta(seconds=25) + assert isinstance(result.estimated_checkout_time, timedelta) + assert result.estimated_input_usd == Decimal("14.488305") + assert result.estimated_output_usd == Decimal("14.4") + assert isinstance(result.estimated_input_usd, Decimal) + assert result.estimated_destination_amount == 14_491_203 + assert isinstance(result.estimated_destination_amount, int) + assert result.estimated_fees.gas_usd == Decimal("0.003854") + assert result.estimated_fees.minimum_received == Decimal("14.488305") + + +def test_quote_rejects_boolean_decimal_field() -> None: + payload = _quote_payload() + payload["estInputUsd"] = True + + with pytest.raises(UnexpectedResponseError, match="FundingQuote response"): + FundingQuote.parse_response(payload) + + +def test_quote_rejects_negative_checkout_time() -> None: + payload = _quote_payload() + payload["estCheckoutTimeMs"] = -1 + + with pytest.raises(UnexpectedResponseError, match="FundingQuote response"): + FundingQuote.parse_response(payload) + + +def test_transaction_normalizes_known_status_amount_and_timestamp() -> None: + result = FundingTransaction.parse_response( + _transaction_payload( + txHash="3atr19NAiNCYt24RHM1WnzZp47RXskpTDzspJoCBBaMFw", + createdTimeMs="1757531217339", + ) + ) + + assert result.source_chain_id == 1_151_111_081_099_710 + assert isinstance(result.source_chain_id, int) + assert result.source_amount == 13_566_635 + assert isinstance(result.source_amount, int) + assert result.status is KnownFundingTransactionStatus.COMPLETED + assert result.transaction_hash == "3atr19NAiNCYt24RHM1WnzZp47RXskpTDzspJoCBBaMFw" + assert result.created_at == datetime.fromtimestamp(1_757_531_217_339 / 1000, tz=UTC) + assert result.created_at is not None and result.created_at.tzinfo is UTC + + +def test_transaction_maps_wire_origin_confirmation_status() -> None: + result = FundingTransaction.parse_response(_transaction_payload(status="ORIGIN_TX_CONFIRMED")) + + assert result.status is KnownFundingTransactionStatus.ORIGIN_TRANSACTION_CONFIRMED + + +def test_transaction_preserves_unknown_status_for_forward_compatibility() -> None: + result = FundingTransaction.parse_response(_transaction_payload(status="COMPLIANCE_REVIEW")) + + assert result.status == "COMPLIANCE_REVIEW" + assert not isinstance(result.status, KnownFundingTransactionStatus) + + +def test_transaction_allows_status_dependent_fields_to_be_absent() -> None: + result = FundingTransaction.parse_response(_transaction_payload(status="DEPOSIT_DETECTED")) + + assert result.status is KnownFundingTransactionStatus.DEPOSIT_DETECTED + assert result.transaction_hash is None + assert result.created_at is None + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("fromChainId", 0), + ("toChainId", True), + ("fromAmountBaseUnit", -1), + ("status", ""), + ("createdTimeMs", -1), + ], +) +def test_transaction_rejects_malformed_wire_values(field: str, value: object) -> None: + with pytest.raises(UnexpectedResponseError, match="FundingTransaction response"): + FundingTransaction.parse_response(_transaction_payload(**{field: value})) + + +def test_public_funding_annotations_use_canonical_python_types() -> None: + asset_hints = get_type_hints(FundingAsset) + quote_hints = get_type_hints(FundingQuote) + transaction_hints = get_type_hints(FundingTransaction) + + assert asset_hints["chain_id"] is int + assert asset_hints["minimum_amount_usd"] is Decimal + assert quote_hints["estimated_checkout_time"] is timedelta + assert quote_hints["estimated_input_usd"] is Decimal + assert quote_hints["estimated_destination_amount"] is int + assert transaction_hints["source_amount"] is int + assert datetime in get_args(transaction_hints["created_at"]) From bb2d13b85b67445a5771afdb503e962255b5c953 Mon Sep 17 00:00:00 2001 From: kartojal Date: Thu, 13 Aug 2026 09:41:43 +0200 Subject: [PATCH 2/4] fix(client): paginate funding transactions --- src/polymarket/_internal/actions/funding.py | 48 ++- src/polymarket/clients/async_public.py | 24 +- src/polymarket/clients/async_secure.py | 24 +- src/polymarket/clients/public.py | 24 +- src/polymarket/clients/secure.py | 24 +- tests/integration/test_funding.py | 219 ++++++++--- tests/unit/test_funding_actions.py | 405 ++++---------------- tests/unit/test_funding_clients.py | 348 ----------------- tests/unit/test_funding_models.py | 178 ++------- 9 files changed, 405 insertions(+), 889 deletions(-) delete mode 100644 tests/unit/test_funding_clients.py diff --git a/src/polymarket/_internal/actions/funding.py b/src/polymarket/_internal/actions/funding.py index 29c6443..ad346b5 100644 --- a/src/polymarket/_internal/actions/funding.py +++ b/src/polymarket/_internal/actions/funding.py @@ -3,7 +3,9 @@ from urllib.parse import quote from eth_utils.address import to_checksum_address +from pydantic import Field, field_validator +from polymarket._internal.request import QueryParamValue from polymarket._internal.validation import require_nonempty, validate_builder_code from polymarket.errors import UserInputError from polymarket.models.base import BaseModel @@ -13,12 +15,25 @@ FundingQuote, FundingTransaction, ) +from polymarket.pagination import Page _BUILDER_CODE_HEADER = "X-Builder-Code" +_DEFAULT_STATUS_PAGE_SIZE = 50 +_MAX_STATUS_PAGE_SIZE = 100 -class _FundingTransactionsResponse(BaseModel): +class _FundingTransactionsPageResponse(BaseModel): transactions: tuple[FundingTransaction, ...] + # Production may briefly return the pre-pagination shape while the new + # required nextCursor field rolls out. Treat omission as a terminal page. + next_cursor: str | None = Field(default=None, validation_alias="nextCursor") + + @field_validator("next_cursor") + @classmethod + def _validate_next_cursor(cls, value: str | None) -> str | None: + if value == "": + raise ValueError("nextCursor must be non-empty or null") + return value def _validate_evm_address(name: str, value: object) -> str: @@ -114,10 +129,22 @@ def build_funding_quote_request( ) -def build_funding_status_request(*, address: str) -> str: - """Build a status path for an EVM, SVM, Bitcoin, or Tron address.""" +def build_list_funding_transactions_request( + *, + address: str, + page_size: int = _DEFAULT_STATUS_PAGE_SIZE, + cursor: str | None = None, +) -> tuple[str, dict[str, QueryParamValue]]: + """Build a request for one page of funding transactions.""" validated = _require_nonblank("address", address) - return f"/status/{quote(validated, safe='')}" + if type(page_size) is not int: + raise UserInputError("page_size must be an int.") + if page_size < 1 or page_size > _MAX_STATUS_PAGE_SIZE: + raise UserInputError(f"page_size must be between 1 and {_MAX_STATUS_PAGE_SIZE}.") + params: dict[str, QueryParamValue] = {"limit": page_size} + if cursor is not None: + params["cursor"] = require_nonempty("cursor", cursor) + return f"/status/{quote(validated, safe='')}", params def parse_funding_address_set(data: object) -> FundingAddressSet: @@ -132,17 +159,22 @@ def parse_funding_quote(data: object) -> FundingQuote: return FundingQuote.parse_response(data) -def parse_funding_transactions(data: object) -> tuple[FundingTransaction, ...]: - return _FundingTransactionsResponse.parse_response(data).transactions +def parse_funding_transactions_page(data: object) -> Page[FundingTransaction]: + response = _FundingTransactionsPageResponse.parse_response(data) + return Page( + items=response.transactions, + has_more=response.next_cursor is not None, + next_cursor=response.next_cursor, + ) __all__ = [ "build_create_deposit_addresses_request", "build_create_withdrawal_addresses_request", "build_funding_quote_request", - "build_funding_status_request", + "build_list_funding_transactions_request", "parse_funding_address_set", "parse_funding_asset_catalog", "parse_funding_quote", - "parse_funding_transactions", + "parse_funding_transactions_page", ] diff --git a/src/polymarket/clients/async_public.py b/src/polymarket/clients/async_public.py index 2f85356..efbf638 100644 --- a/src/polymarket/clients/async_public.py +++ b/src/polymarket/clients/async_public.py @@ -448,10 +448,26 @@ async def get_funding_quote( await self._ctx.bridge.post_json(path, json=body) ) - async def get_funding_transactions(self, *, address: str) -> tuple[FundingTransaction, ...]: - """Get deposit or withdrawal transactions observed for an address.""" - path = _funding_actions.build_funding_status_request(address=address) - return _funding_actions.parse_funding_transactions(await self._ctx.bridge.get_json(path)) + def list_funding_transactions( + self, *, address: str, page_size: int = 50 + ) -> AsyncPaginator[FundingTransaction]: + """List deposit and withdrawal transactions for a bridge address, newest first. + + Returns: + An async paginator over transaction-status records. + """ + + async def fetch(cursor: str | None) -> Page[FundingTransaction]: + path, params = _funding_actions.build_list_funding_transactions_request( + address=address, + page_size=page_size, + cursor=cursor, + ) + return _funding_actions.parse_funding_transactions_page( + await self._ctx.bridge.get_json(path, params=params) + ) + + return AsyncPaginator(fetch=fetch) @overload async def get_market( diff --git a/src/polymarket/clients/async_secure.py b/src/polymarket/clients/async_secure.py index 6197e03..1f324ad 100644 --- a/src/polymarket/clients/async_secure.py +++ b/src/polymarket/clients/async_secure.py @@ -1041,10 +1041,26 @@ async def get_funding_quote( await self._ctx.bridge.post_json(path, json=body) ) - async def get_funding_transactions(self, *, address: str) -> tuple[FundingTransaction, ...]: - """Get deposit or withdrawal transactions observed for an address.""" - path = _funding_actions.build_funding_status_request(address=address) - return _funding_actions.parse_funding_transactions(await self._ctx.bridge.get_json(path)) + def list_funding_transactions( + self, *, address: str, page_size: int = 50 + ) -> AsyncPaginator[FundingTransaction]: + """List deposit and withdrawal transactions for a bridge address, newest first. + + Returns: + An async paginator over transaction-status records. + """ + + async def fetch(cursor: str | None) -> Page[FundingTransaction]: + path, params = _funding_actions.build_list_funding_transactions_request( + address=address, + page_size=page_size, + cursor=cursor, + ) + return _funding_actions.parse_funding_transactions_page( + await self._ctx.bridge.get_json(path, params=params) + ) + + return AsyncPaginator(fetch=fetch) async def _close_rfq_session(self) -> None: opening = self._rfq_session_opening diff --git a/src/polymarket/clients/public.py b/src/polymarket/clients/public.py index 0010e20..d293928 100644 --- a/src/polymarket/clients/public.py +++ b/src/polymarket/clients/public.py @@ -227,10 +227,26 @@ def get_funding_quote( ) return _funding_actions.parse_funding_quote(self._ctx.bridge.post_json(path, json=body)) - def get_funding_transactions(self, *, address: str) -> tuple[FundingTransaction, ...]: - """Get deposit or withdrawal transactions observed for an address.""" - path = _funding_actions.build_funding_status_request(address=address) - return _funding_actions.parse_funding_transactions(self._ctx.bridge.get_json(path)) + def list_funding_transactions( + self, *, address: str, page_size: int = 50 + ) -> Paginator[FundingTransaction]: + """List deposit and withdrawal transactions for a bridge address, newest first. + + Returns: + A paginator over transaction-status records. + """ + + def fetch(cursor: str | None) -> Page[FundingTransaction]: + path, params = _funding_actions.build_list_funding_transactions_request( + address=address, + page_size=page_size, + cursor=cursor, + ) + return _funding_actions.parse_funding_transactions_page( + self._ctx.bridge.get_json(path, params=params) + ) + + return Paginator(fetch=fetch) @overload def get_market( diff --git a/src/polymarket/clients/secure.py b/src/polymarket/clients/secure.py index 07677a4..47bc951 100644 --- a/src/polymarket/clients/secure.py +++ b/src/polymarket/clients/secure.py @@ -602,10 +602,26 @@ def get_funding_quote( ) return _funding_actions.parse_funding_quote(self._ctx.bridge.post_json(path, json=body)) - def get_funding_transactions(self, *, address: str) -> tuple[FundingTransaction, ...]: - """Get deposit or withdrawal transactions observed for an address.""" - path = _funding_actions.build_funding_status_request(address=address) - return _funding_actions.parse_funding_transactions(self._ctx.bridge.get_json(path)) + def list_funding_transactions( + self, *, address: str, page_size: int = 50 + ) -> Paginator[FundingTransaction]: + """List deposit and withdrawal transactions for a bridge address, newest first. + + Returns: + A paginator over transaction-status records. + """ + + def fetch(cursor: str | None) -> Page[FundingTransaction]: + path, params = _funding_actions.build_list_funding_transactions_request( + address=address, + page_size=page_size, + cursor=cursor, + ) + return _funding_actions.parse_funding_transactions_page( + self._ctx.bridge.get_json(path, params=params) + ) + + return Paginator(fetch=fetch) def _user_or_wallet(self, user: str | None) -> str: return self._ctx.wallet if user is None else user diff --git a/tests/integration/test_funding.py b/tests/integration/test_funding.py index ffcbff8..a9dcedd 100644 --- a/tests/integration/test_funding.py +++ b/tests/integration/test_funding.py @@ -1,80 +1,207 @@ """Live account-funding workflow coverage.""" +import asyncio +from datetime import UTC, datetime, timedelta from decimal import Decimal import pytest -from polymarket import AsyncPublicClient +from polymarket import ( + AsyncPublicClient, + AsyncSecureClient, + FundingTransaction, + KnownFundingTransactionStatus, + Page, +) pytestmark = [pytest.mark.anyio, pytest.mark.integration] -_WALLET = "0x0000000000000000000000000000000000000001" _POLYGON_CHAIN_ID = 137 _POLYGON_USDC = "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359" _POLYGON_POLYMARKET_USDC = "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB" _DOCUMENTED_FUNDING_ADDRESS = "0x23566f8b2E82aDfCf01846E54899d110e97AC053" - - -@pytest.mark.metered -async def test_create_deposit_addresses_live(public_client: AsyncPublicClient) -> None: - """Creates provider-side address records but does not move or spend funds.""" - result = await public_client.create_deposit_addresses(wallet=_WALLET) - - assert result.addresses.evm - assert result.addresses.svm - assert result.addresses.btc - - -@pytest.mark.metered -async def test_create_withdrawal_addresses_live(public_client: AsyncPublicClient) -> None: - """Creates provider-side address records but does not initiate a transfer.""" - result = await public_client.create_withdrawal_addresses( - wallet=_WALLET, - destination_chain_id=_POLYGON_CHAIN_ID, - destination_token_address=_POLYGON_USDC, - recipient_address=_WALLET, - ) - - assert result.addresses.evm - assert result.addresses.svm - assert result.addresses.btc - - -async def test_get_supported_funding_assets_live(public_client: AsyncPublicClient) -> None: +_DEPOSIT_AMOUNT = 2_100_000 +_WITHDRAWAL_AMOUNT = 2_000_000 +_POLL_INTERVAL_SECONDS = 10.0 +_TRANSFER_TIMEOUT_SECONDS = 600.0 + + +async def _wait_for_funding_transfer( + client: AsyncSecureClient, + *, + address: str, + source_token: str, + source_amount: int, + destination_token: str, + not_before: datetime, +) -> FundingTransaction: + """Poll the newest status page until the expected transfer is terminal.""" + deadline = asyncio.get_running_loop().time() + _TRANSFER_TIMEOUT_SECONDS + while True: + page = await client.list_funding_transactions(address=address).first_page() + for transaction in page.items: + if ( + transaction.created_at is None + or transaction.created_at < not_before + or transaction.source_chain_id != _POLYGON_CHAIN_ID + or transaction.source_token_address.lower() != source_token.lower() + or transaction.source_amount != source_amount + or transaction.destination_chain_id != _POLYGON_CHAIN_ID + or transaction.destination_token_address.lower() != destination_token.lower() + ): + continue + if transaction.status is KnownFundingTransactionStatus.FAILED: + pytest.fail(f"bridge transfer failed for {address}") + if transaction.status is KnownFundingTransactionStatus.COMPLETED: + return transaction + + if asyncio.get_running_loop().time() >= deadline: + pytest.fail(f"bridge transfer did not complete within the timeout for {address}") + await asyncio.sleep(_POLL_INTERVAL_SECONDS) + + +async def test_discovery_quote_and_paginated_status_live( + public_client: AsyncPublicClient, +) -> None: catalog = await public_client.get_supported_funding_assets() - assert catalog.assets asset = catalog.assets[0] assert asset.chain_id > 0 assert asset.chain_name assert asset.token.symbol - assert asset.token.decimals >= 0 assert asset.minimum_amount_usd >= Decimal(0) - -async def test_get_funding_quote_live(public_client: AsyncPublicClient) -> None: quote = await public_client.get_funding_quote( amount=10_000_000, source_chain_id=_POLYGON_CHAIN_ID, source_token_address=_POLYGON_USDC, destination_chain_id=_POLYGON_CHAIN_ID, destination_token_address=_POLYGON_POLYMARKET_USDC, - recipient_address=_WALLET, + recipient_address="0x0000000000000000000000000000000000000001", ) - assert quote.quote_id assert quote.estimated_destination_amount > 0 assert quote.estimated_checkout_time.total_seconds() >= 0 + paginator = public_client.list_funding_transactions( + address=_DOCUMENTED_FUNDING_ADDRESS, + page_size=1, + ) + pages: list[Page[FundingTransaction]] = [] + async for page in paginator: + pages.append(page) + if page.has_more: + assert len(page.items) <= 1 + assert page.next_cursor is not None + + assert pages + assert any(page.items for page in pages) + assert pages[-1].has_more is False + assert pages[-1].next_cursor is None + -async def test_get_funding_transactions_live(public_client: AsyncPublicClient) -> None: - transactions = await public_client.get_funding_transactions(address=_DOCUMENTED_FUNDING_ADDRESS) +@pytest.mark.metered +async def test_minimum_usdc_deposit_and_withdrawal_round_trip_live( + deposit_wallet_client: AsyncSecureClient, + builder_code: str, +) -> None: + """Round-trip the minimum withdrawal; irreversibly spends bridge fees and moves funds. + + The configured wallet must hold at least 2.10 native Polygon USDC before the run. + """ + client = deposit_wallet_client + wallet = str(client.wallet) + catalog = await client.get_supported_funding_assets() + native_usdc = next( + ( + asset + for asset in catalog.assets + if asset.chain_id == _POLYGON_CHAIN_ID + and asset.token.address.lower() == _POLYGON_USDC.lower() + ), + None, + ) + pusd = next( + ( + asset + for asset in catalog.assets + if asset.chain_id == _POLYGON_CHAIN_ID + and asset.token.address.lower() == _POLYGON_POLYMARKET_USDC.lower() + ), + None, + ) + if native_usdc is None or pusd is None: + pytest.skip("required Polygon funding assets are unavailable") + if native_usdc.token.decimals != 6 or pusd.token.decimals != 6: + pytest.skip("the metered amounts require six-decimal Polygon funding assets") + if Decimal(_DEPOSIT_AMOUNT) / 1_000_000 < native_usdc.minimum_amount_usd: + pytest.skip("the deposit amount is below the current native USDC minimum") + if Decimal(_WITHDRAWAL_AMOUNT) / 1_000_000 < pusd.minimum_amount_usd: + pytest.skip("the withdrawal amount is below the current pUSD minimum") + + deposit = await client.create_deposit_addresses(builder_code=builder_code) + withdrawal = await client.create_withdrawal_addresses( + destination_chain_id=_POLYGON_CHAIN_ID, + destination_token_address=_POLYGON_USDC, + recipient_address=wallet, + builder_code=builder_code, + ) + quote = await client.get_funding_quote( + amount=_DEPOSIT_AMOUNT, + source_chain_id=_POLYGON_CHAIN_ID, + source_token_address=_POLYGON_USDC, + destination_chain_id=_POLYGON_CHAIN_ID, + destination_token_address=_POLYGON_POLYMARKET_USDC, + recipient_address=wallet, + ) + withdrawal_quote = await client.get_funding_quote( + amount=_WITHDRAWAL_AMOUNT, + source_chain_id=_POLYGON_CHAIN_ID, + source_token_address=_POLYGON_POLYMARKET_USDC, + destination_chain_id=_POLYGON_CHAIN_ID, + destination_token_address=_POLYGON_USDC, + recipient_address=wallet, + ) + if ( + quote.estimated_fees.minimum_received < Decimal("2.05") + or quote.estimated_destination_amount < _WITHDRAWAL_AMOUNT + or withdrawal_quote.estimated_destination_amount < 1_950_000 + or withdrawal_quote.estimated_fees.minimum_received < Decimal("1.95") + ): + pytest.skip("current quotes cannot safely complete the minimum round trip") + + # Fund-moving side effects begin here: this moves at most 2.10 USDC into the bridge and + # irreversibly spends its fees even if a later assertion fails. + deposit_started_at = datetime.now(UTC) - timedelta(minutes=1) + deposit_handle = await client.transfer_erc20( + token_address=_POLYGON_USDC, + recipient_address=str(deposit.addresses.evm), + amount=_DEPOSIT_AMOUNT, + metadata="py-sdk bridge integration test: minimum USDC deposit", + ) + await deposit_handle.wait() + await _wait_for_funding_transfer( + client, + address=str(deposit.addresses.evm), + source_token=_POLYGON_USDC, + source_amount=_DEPOSIT_AMOUNT, + destination_token=_POLYGON_POLYMARKET_USDC, + not_before=deposit_started_at, + ) - assert transactions - transaction = transactions[0] - assert transaction.source_chain_id > 0 - assert transaction.source_token_address - assert transaction.source_amount >= 0 - assert transaction.destination_chain_id > 0 - assert transaction.destination_token_address - assert transaction.status + withdrawal_started_at = datetime.now(UTC) - timedelta(minutes=1) + withdrawal_handle = await client.transfer_erc20( + token_address=_POLYGON_POLYMARKET_USDC, + recipient_address=str(withdrawal.addresses.evm), + amount=_WITHDRAWAL_AMOUNT, + metadata="py-sdk bridge integration test: minimum USDC withdrawal", + ) + await withdrawal_handle.wait() + await _wait_for_funding_transfer( + client, + address=str(withdrawal.addresses.evm), + source_token=_POLYGON_POLYMARKET_USDC, + source_amount=_WITHDRAWAL_AMOUNT, + destination_token=_POLYGON_USDC, + not_before=withdrawal_started_at, + ) diff --git a/tests/unit/test_funding_actions.py b/tests/unit/test_funding_actions.py index 75bb512..8a9aa41 100644 --- a/tests/unit/test_funding_actions.py +++ b/tests/unit/test_funding_actions.py @@ -1,132 +1,75 @@ -from collections.abc import Callable - import pytest from polymarket._internal.actions.funding import ( build_create_deposit_addresses_request, build_create_withdrawal_addresses_request, build_funding_quote_request, - build_funding_status_request, - parse_funding_address_set, - parse_funding_asset_catalog, - parse_funding_quote, - parse_funding_transactions, + build_list_funding_transactions_request, + parse_funding_transactions_page, ) from polymarket.errors import UnexpectedResponseError, UserInputError -from polymarket.models.funding import ( - FundingAddressSet, - FundingAssetCatalog, - FundingQuote, - FundingTransaction, -) _WALLET_LOWER = "0x52908400098527886e0f7030069857d2e4169ee7" _WALLET_CHECKSUM = "0x52908400098527886E0F7030069857D2E4169EE7" _BUILDER_CODE = "0x" + "ab" * 32 -def _address_set_payload() -> dict[str, object]: - return { - "address": { - "evm": _WALLET_CHECKSUM, - "svm": "CrvTBvzryYxBHbWu2TiQpcqD5M7Le7iBKzVmEj3f36Jb", - "btc": "bc1q8eau83qffxcj8ht4hsjdza3lha9r3egfqysj3g", - "tron": "TP1mjRAUVe5qXfCnpczdWhSrGpos2Arzir", - }, - "note": "Only supported assets should be sent.", - "warnings": [ - { - "code": "missing_builder_code", - "message": "Include X-Builder-Code for attribution.", - } - ], - } - - -def _asset_catalog_payload() -> dict[str, object]: - return { - "supportedAssets": [ - { - "chainId": "728126428", - "chainName": "Tron", - "token": { - "name": "Tether USD", - "symbol": "USDT", - "address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", - "decimals": 6, - }, - "minCheckoutUsd": 7, - } - ], - "note": "Assets may be used for deposits and withdrawals.", - } - - -def _quote_payload() -> dict[str, object]: - return { - "estCheckoutTimeMs": 25_000, - "estFeeBreakdown": { - "appFeeLabel": "Fun.xyz fee", - "appFeePercent": 0, - "appFeeUsd": 0, - "fillCostPercent": 0, - "fillCostUsd": 0, - "gasUsd": 0.003854, - "maxSlippage": 0, - "minReceived": 14.488305, - "swapImpact": 0, - "swapImpactUsd": 0, - "totalImpact": 0, - "totalImpactUsd": 0, - }, - "estInputUsd": 14.488305, - "estOutputUsd": 14.488305, - "estToTokenBaseUnit": "14491203", - "quoteId": "0xquote", - } - - -def _transaction_payload() -> dict[str, object]: - return { - "fromChainId": "1151111081099710", - "fromTokenAddress": "11111111111111111111111111111111", - "fromAmountBaseUnit": "13566635", - "toChainId": "137", - "toTokenAddress": "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB", - "status": "COMPLETED", - "txHash": "3atr19NAiNCYt24RHM1WnzZp47RXskpTDzspJoCBBaMFw", - "createdTimeMs": 1_757_531_217_339, - } - - -def test_build_deposit_request_serializes_wallet_and_builder_header() -> None: - path, body, headers = build_create_deposit_addresses_request( +def test_funding_request_builders_serialize_protocol_fields() -> None: + deposit_path, deposit_body, deposit_headers = build_create_deposit_addresses_request( wallet=_WALLET_LOWER, builder_code=_BUILDER_CODE, ) + withdrawal_path, withdrawal_body, withdrawal_headers = ( + build_create_withdrawal_addresses_request( + wallet=_WALLET_LOWER, + destination_chain_id=728126428, + destination_token_address="TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + recipient_address="TP1mjRAUVe5qXfCnpczdWhSrGpos2Arzir", + builder_code=_BUILDER_CODE, + ) + ) + quote_path, quote_body = build_funding_quote_request( + amount=10_000_000, + source_chain_id=137, + source_token_address="source-token", + destination_chain_id=137, + destination_token_address="destination-token", + recipient_address=_WALLET_CHECKSUM, + ) - assert path == "/deposit" - assert body == {"address": _WALLET_CHECKSUM} - assert headers == {"X-Builder-Code": _BUILDER_CODE} - - -def test_build_deposit_request_omits_builder_header_when_unset() -> None: - _, _, headers = build_create_deposit_addresses_request(wallet=_WALLET_LOWER) - - assert headers == {} + assert (deposit_path, deposit_body, deposit_headers) == ( + "/deposit", + {"address": _WALLET_CHECKSUM}, + {"X-Builder-Code": _BUILDER_CODE}, + ) + assert (withdrawal_path, withdrawal_body, withdrawal_headers) == ( + "/withdraw", + { + "address": _WALLET_CHECKSUM, + "toChainId": "728126428", + "toTokenAddress": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + "recipientAddr": "TP1mjRAUVe5qXfCnpczdWhSrGpos2Arzir", + }, + {"X-Builder-Code": _BUILDER_CODE}, + ) + assert (quote_path, quote_body) == ( + "/quote", + { + "fromAmountBaseUnit": "10000000", + "fromChainId": "137", + "fromTokenAddress": "source-token", + "recipientAddress": _WALLET_CHECKSUM, + "toChainId": "137", + "toTokenAddress": "destination-token", + }, + ) @pytest.mark.parametrize( "builder_code", - [ - "", - "ab" * 32, - "0x" + "ab" * 31, - "0x" + "ab" * 33, - "0x" + "zz" * 32, - ], + ["", "ab" * 32, "0x" + "ab" * 31, "0x" + "zz" * 32], ) -def test_build_deposit_request_rejects_malformed_builder_code(builder_code: str) -> None: +def test_deposit_builder_rejects_malformed_builder_codes(builder_code: str) -> None: with pytest.raises(UserInputError, match="builder_code"): build_create_deposit_addresses_request( wallet=_WALLET_LOWER, @@ -134,118 +77,12 @@ def test_build_deposit_request_rejects_malformed_builder_code(builder_code: str) ) -def test_build_deposit_request_rejects_non_string_builder_code() -> None: - with pytest.raises(UserInputError, match="builder_code"): - build_create_deposit_addresses_request( - wallet=_WALLET_LOWER, - builder_code=42, # type: ignore[arg-type] - ) - - -@pytest.mark.parametrize( - "wallet", - ["", "0x1234", "not-an-address", "52908400098527886e0f7030069857d2e4169ee7"], -) -def test_build_deposit_request_rejects_invalid_wallet(wallet: str) -> None: - with pytest.raises(UserInputError, match="wallet"): - build_create_deposit_addresses_request(wallet=wallet) - - -def test_build_deposit_request_rejects_non_string_wallet() -> None: - with pytest.raises(UserInputError, match="wallet"): - build_create_deposit_addresses_request(wallet=42) # type: ignore[arg-type] - - -def test_build_withdrawal_request_uses_wire_names_and_string_chain_id() -> None: - path, body, headers = build_create_withdrawal_addresses_request( - wallet=_WALLET_LOWER, - destination_chain_id=728126428, - destination_token_address="TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", - recipient_address="TP1mjRAUVe5qXfCnpczdWhSrGpos2Arzir", - builder_code=_BUILDER_CODE, - ) - - assert path == "/withdraw" - assert body == { - "address": _WALLET_CHECKSUM, - "toChainId": "728126428", - "toTokenAddress": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", - "recipientAddr": "TP1mjRAUVe5qXfCnpczdWhSrGpos2Arzir", - } - assert headers == {"X-Builder-Code": _BUILDER_CODE} - - -def test_build_withdrawal_request_rejects_invalid_builder_code() -> None: - with pytest.raises(UserInputError, match="builder_code"): - build_create_withdrawal_addresses_request( - wallet=_WALLET_LOWER, - destination_chain_id=1, - destination_token_address="USDC", - recipient_address="recipient", - builder_code="invalid", - ) - - -@pytest.mark.parametrize("destination_chain_id", [0, -1, True, 1.5]) -def test_build_withdrawal_request_rejects_invalid_chain_id( - destination_chain_id: object, -) -> None: - with pytest.raises(UserInputError, match="destination_chain_id"): - build_create_withdrawal_addresses_request( - wallet=_WALLET_LOWER, - destination_chain_id=destination_chain_id, # type: ignore[arg-type] - destination_token_address="USDC", - recipient_address="recipient", - ) - - -def test_build_withdrawal_request_rejects_empty_destination_token() -> None: - with pytest.raises(UserInputError, match="destination_token_address"): - build_create_withdrawal_addresses_request( - wallet=_WALLET_LOWER, - destination_chain_id=1, - destination_token_address="", - recipient_address="recipient", - ) - - -def test_build_withdrawal_request_rejects_empty_recipient() -> None: - with pytest.raises(UserInputError, match="recipient_address"): - build_create_withdrawal_addresses_request( - wallet=_WALLET_LOWER, - destination_chain_id=1, - destination_token_address="USDC", - recipient_address="", - ) - - -def test_build_quote_request_serializes_integers_as_wire_strings() -> None: - path, body = build_funding_quote_request( - amount=10_000_000, - source_chain_id=137, - source_token_address="0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", - destination_chain_id=137, - destination_token_address="0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB", - recipient_address=_WALLET_CHECKSUM, - ) - - assert path == "/quote" - assert body == { - "fromAmountBaseUnit": "10000000", - "fromChainId": "137", - "fromTokenAddress": "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", - "recipientAddress": _WALLET_CHECKSUM, - "toChainId": "137", - "toTokenAddress": "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB", - } - - @pytest.mark.parametrize("amount", [0, -1, True, 1.5]) -def test_build_quote_request_rejects_invalid_amount(amount: object) -> None: +def test_quote_builder_rejects_non_positive_integer_amounts(amount: object) -> None: with pytest.raises(UserInputError, match="amount"): build_funding_quote_request( amount=amount, # type: ignore[arg-type] - source_chain_id=1, + source_chain_id=137, source_token_address="source-token", destination_chain_id=137, destination_token_address="destination-token", @@ -253,126 +90,50 @@ def test_build_quote_request_rejects_invalid_amount(amount: object) -> None: ) -@pytest.mark.parametrize("source_chain_id", [0, -1, True, 1.5]) -def test_build_quote_request_rejects_invalid_source_chain_id(source_chain_id: object) -> None: - with pytest.raises(UserInputError, match="source_chain_id"): - build_funding_quote_request( - amount=1, - source_chain_id=source_chain_id, # type: ignore[arg-type] - source_token_address="source-token", - destination_chain_id=137, - destination_token_address="destination-token", - recipient_address="recipient", - ) - - -def test_build_quote_request_rejects_empty_source_token() -> None: - with pytest.raises(UserInputError, match="source_token_address"): - build_funding_quote_request( - amount=1, - source_chain_id=1, - source_token_address="", - destination_chain_id=137, - destination_token_address="destination-token", - recipient_address="recipient", - ) - - -def test_build_quote_request_rejects_empty_destination_token() -> None: - with pytest.raises(UserInputError, match="destination_token_address"): - build_funding_quote_request( - amount=1, - source_chain_id=1, - source_token_address="source-token", - destination_chain_id=137, - destination_token_address="", - recipient_address="recipient", - ) - - -def test_build_quote_request_rejects_empty_recipient() -> None: - with pytest.raises(UserInputError, match="recipient_address"): - build_funding_quote_request( - amount=1, - source_chain_id=1, - source_token_address="source-token", - destination_chain_id=137, - destination_token_address="destination-token", - recipient_address="", - ) - - -def test_build_quote_request_trims_generic_chain_addresses() -> None: - _, body = build_funding_quote_request( - amount=1, - source_chain_id=1, - source_token_address=" source-token ", - destination_chain_id=137, - destination_token_address=" destination-token ", - recipient_address=" recipient ", +def test_status_builder_preserves_opaque_cursor_and_maps_page_size_to_limit() -> None: + path, params = build_list_funding_transactions_request( + address="tron/address with space", + page_size=100, + cursor="opaque+/=cursor", ) - assert body["fromTokenAddress"] == "source-token" - assert body["toTokenAddress"] == "destination-token" - assert body["recipientAddress"] == "recipient" + assert path == "/status/tron%2Faddress%20with%20space" + assert params == {"limit": 100, "cursor": "opaque+/=cursor"} + assert "paginate" not in params -@pytest.mark.parametrize( - "field", ["source_token_address", "destination_token_address", "recipient_address"] -) -def test_build_quote_request_rejects_whitespace_only_address(field: str) -> None: - values = { - "amount": 1, - "source_chain_id": 1, - "source_token_address": "source-token", - "destination_chain_id": 137, - "destination_token_address": "destination-token", - "recipient_address": "recipient", - } - values[field] = " " - - with pytest.raises(UserInputError, match=field): - build_funding_quote_request(**values) # type: ignore[arg-type] - - -def test_build_status_request_percent_encodes_non_evm_address() -> None: - assert build_funding_status_request(address="tron/address with space") == ( - "/status/tron%2Faddress%20with%20space" - ) - - -@pytest.mark.parametrize("address", ["", " ", 42]) -def test_build_status_request_rejects_invalid_address(address: object) -> None: - with pytest.raises(UserInputError, match="address"): - build_funding_status_request(address=address) # type: ignore[arg-type] +@pytest.mark.parametrize("page_size", [0, 101, True, 1.5]) +def test_status_builder_rejects_invalid_page_sizes(page_size: object) -> None: + with pytest.raises(UserInputError, match="page_size"): + build_list_funding_transactions_request( + address=_WALLET_CHECKSUM, + page_size=page_size, # type: ignore[arg-type] + ) -def test_funding_parsers_return_public_models() -> None: - address_set = parse_funding_address_set(_address_set_payload()) - catalog = parse_funding_asset_catalog(_asset_catalog_payload()) - quote = parse_funding_quote(_quote_payload()) - transactions = parse_funding_transactions({"transactions": [_transaction_payload()]}) +def test_status_page_stops_only_on_absent_or_null_next_cursor() -> None: + continued = parse_funding_transactions_page({"transactions": [], "nextCursor": "LTE="}) + terminal = parse_funding_transactions_page({"transactions": [], "nextCursor": None}) + legacy_terminal = parse_funding_transactions_page({"transactions": []}) - assert isinstance(address_set, FundingAddressSet) - assert isinstance(catalog, FundingAssetCatalog) - assert isinstance(quote, FundingQuote) - assert len(transactions) == 1 - assert isinstance(transactions[0], FundingTransaction) + assert continued.items == () + assert continued.has_more is True + assert continued.next_cursor == "LTE=" + assert terminal.has_more is False + assert terminal.next_cursor is None + assert legacy_terminal.has_more is False + assert legacy_terminal.next_cursor is None @pytest.mark.parametrize( - ("parser", "payload"), + "payload", [ - (parse_funding_address_set, {}), - (parse_funding_asset_catalog, {"supportedAssets": "not-a-list"}), - (parse_funding_quote, {}), - (parse_funding_transactions, {}), - (parse_funding_transactions, {"transactions": {}}), + {"transactions": [], "nextCursor": ""}, + {"transactions": [], "nextCursor": 42}, ], ) -def test_funding_parsers_map_malformed_responses_to_unexpected_response( - parser: Callable[[object], object], - payload: object, +def test_status_page_rejects_malformed_next_cursor( + payload: dict[str, object], ) -> None: with pytest.raises(UnexpectedResponseError): - parser(payload) + parse_funding_transactions_page(payload) diff --git a/tests/unit/test_funding_clients.py b/tests/unit/test_funding_clients.py deleted file mode 100644 index 0979ee4..0000000 --- a/tests/unit/test_funding_clients.py +++ /dev/null @@ -1,348 +0,0 @@ -# pyright: reportPrivateUsage=false -import asyncio -import dataclasses -import inspect -import json -from typing import Any, cast -from urllib.parse import urlparse - -import httpx - -from polymarket import ( - ApiKeyCreds, - AsyncPublicClient, - AsyncSecureClient, - FundingAddressSet, - FundingAssetCatalog, - FundingQuote, - FundingTransaction, - KnownFundingTransactionStatus, - PublicClient, - SecureClient, -) -from polymarket._internal.context import AsyncSecureClientContext, SyncSecureClientContext -from polymarket.clients._transport import AsyncTransport, SyncTransport - -_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" -_SIGNER_ADDRESS = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" -_BOUND_WALLET = "0xBc0fF067b7740Eff76C1ca93c875Ba6B890d6B50" -_PUBLIC_WALLET = "0x52908400098527886e0f7030069857d2e4169ee7" -_PUBLIC_WALLET_CHECKSUM = "0x52908400098527886E0F7030069857D2E4169EE7" -_BUILDER_CODE = "0x" + "ab" * 32 -_SOURCE_TOKEN = "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359" -_DESTINATION_TOKEN = "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB" -_TRON_TOKEN = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t" -_TRON_RECIPIENT = "TP1mjRAUVe5qXfCnpczdWhSrGpos2Arzir" -_FAKE_CREDS = ApiKeyCreds( - key="test-key", - passphrase="test-passphrase", - secret="dGVzdA==", -) - -_ADDRESS_SET_PAYLOAD: dict[str, object] = { - "address": { - "evm": _PUBLIC_WALLET_CHECKSUM, - "svm": "CrvTBvzryYxBHbWu2TiQpcqD5M7Le7iBKzVmEj3f36Jb", - "btc": "bc1q8eau83qffxcj8ht4hsjdza3lha9r3egfqysj3g", - "tron": _TRON_RECIPIENT, - } -} -_ASSET_CATALOG_PAYLOAD: dict[str, object] = { - "supportedAssets": [ - { - "chainId": "137", - "chainName": "Polygon", - "token": { - "name": "USD Coin", - "symbol": "USDC.e", - "address": _DESTINATION_TOKEN, - "decimals": 6, - }, - "minCheckoutUsd": "5", - } - ] -} -_QUOTE_PAYLOAD: dict[str, object] = { - "estCheckoutTimeMs": 25_000, - "estFeeBreakdown": { - "appFeeLabel": "Fun.xyz fee", - "appFeePercent": 0, - "appFeeUsd": 0, - "fillCostPercent": 0, - "fillCostUsd": 0, - "gasUsd": "0.01", - "maxSlippage": "0.5", - "minReceived": "9.9", - "swapImpact": 0, - "swapImpactUsd": 0, - "totalImpact": 0, - "totalImpactUsd": 0, - }, - "estInputUsd": "10", - "estOutputUsd": "9.99", - "estToTokenBaseUnit": "9990000", - "quoteId": "quote-1", -} -_TRANSACTIONS_PAYLOAD: dict[str, object] = { - "transactions": [ - { - "fromChainId": "1", - "fromTokenAddress": _SOURCE_TOKEN, - "fromAmountBaseUnit": "10000000", - "toChainId": "137", - "toTokenAddress": _DESTINATION_TOKEN, - "status": "COMPLETED", - } - ] -} - - -def _bridge_handler(captured: list[httpx.Request]) -> httpx.MockTransport: - responses: dict[tuple[str, str], dict[str, object]] = { - ("POST", "/deposit"): _ADDRESS_SET_PAYLOAD, - ("POST", "/withdraw"): _ADDRESS_SET_PAYLOAD, - ("GET", "/supported-assets"): _ASSET_CATALOG_PAYLOAD, - ("POST", "/quote"): _QUOTE_PAYLOAD, - ("GET", f"/status/{_PUBLIC_WALLET_CHECKSUM}"): _TRANSACTIONS_PAYLOAD, - } - - def handler(request: httpx.Request) -> httpx.Response: - captured.append(request) - route = (request.method, urlparse(str(request.url)).path) - payload = responses.get(route) - if payload is None: - raise AssertionError(f"Unexpected funding request: {route!r}") - return httpx.Response(200, json=payload, request=request) - - return httpx.MockTransport(handler) - - -def _install_sync_bridge( - client: PublicClient | SecureClient, - handler: httpx.MockTransport, -) -> httpx.Client: - http_client = httpx.Client(base_url="https://bridge.test", transport=handler) - bridge = SyncTransport(base_url="https://bridge.test", client=http_client) - bridge._owns_client = True - client._ctx.bridge.close() - client._ctx = cast( - SyncSecureClientContext, - dataclasses.replace(client._ctx, bridge=bridge), - ) - return http_client - - -async def _install_async_bridge( - client: AsyncPublicClient | AsyncSecureClient, - handler: httpx.MockTransport, -) -> httpx.AsyncClient: - http_client = httpx.AsyncClient(base_url="https://bridge.test", transport=handler) - bridge = AsyncTransport(base_url="https://bridge.test", client=http_client) - bridge._owns_client = True - await client._ctx.bridge.close() - client._ctx = cast( - AsyncSecureClientContext, - dataclasses.replace(client._ctx, bridge=bridge), - ) - return http_client - - -def _assert_public_results( - deposit: FundingAddressSet, - withdrawal: FundingAddressSet, - catalog: FundingAssetCatalog, - quote: FundingQuote, - transactions: tuple[FundingTransaction, ...], -) -> None: - assert deposit.addresses.tron == _TRON_RECIPIENT - assert withdrawal.addresses.evm == _PUBLIC_WALLET_CHECKSUM - assert catalog.assets[0].chain_id == 137 - assert quote.quote_id == "quote-1" - assert len(transactions) == 1 - assert transactions[0].status is KnownFundingTransactionStatus.COMPLETED - - -def _assert_public_requests(captured: list[httpx.Request]) -> None: - assert [(request.method, urlparse(str(request.url)).path) for request in captured] == [ - ("POST", "/deposit"), - ("POST", "/withdraw"), - ("GET", "/supported-assets"), - ("POST", "/quote"), - ("GET", f"/status/{_PUBLIC_WALLET_CHECKSUM}"), - ] - assert all(request.url.host == "bridge.test" for request in captured) - assert json.loads(captured[0].content) == {"address": _PUBLIC_WALLET_CHECKSUM} - assert captured[0].headers["X-Builder-Code"] == _BUILDER_CODE - assert json.loads(captured[1].content) == { - "address": _PUBLIC_WALLET_CHECKSUM, - "toChainId": "728126428", - "toTokenAddress": _TRON_TOKEN, - "recipientAddr": _TRON_RECIPIENT, - } - assert captured[1].headers["X-Builder-Code"] == _BUILDER_CODE - assert captured[2].content == b"" - assert json.loads(captured[3].content) == { - "fromAmountBaseUnit": "10000000", - "fromChainId": "137", - "fromTokenAddress": _SOURCE_TOKEN, - "recipientAddress": _PUBLIC_WALLET_CHECKSUM, - "toChainId": "137", - "toTokenAddress": _DESTINATION_TOKEN, - } - assert "X-Builder-Code" not in captured[3].headers - assert captured[4].content == b"" - - -def _public_funding_args() -> dict[str, Any]: - return { - "amount": 10_000_000, - "source_chain_id": 137, - "source_token_address": _SOURCE_TOKEN, - "destination_chain_id": 137, - "destination_token_address": _DESTINATION_TOKEN, - "recipient_address": _PUBLIC_WALLET_CHECKSUM, - } - - -def test_sync_public_funding_calls_use_bridge_transport_and_close_it() -> None: - captured: list[httpx.Request] = [] - - with PublicClient() as client: - bridge_client = _install_sync_bridge(client, _bridge_handler(captured)) - deposit = client.create_deposit_addresses( - wallet=_PUBLIC_WALLET, - builder_code=_BUILDER_CODE, - ) - withdrawal = client.create_withdrawal_addresses( - wallet=_PUBLIC_WALLET, - destination_chain_id=728126428, - destination_token_address=_TRON_TOKEN, - recipient_address=_TRON_RECIPIENT, - builder_code=_BUILDER_CODE, - ) - catalog = client.get_supported_funding_assets() - quote = client.get_funding_quote(**_public_funding_args()) - transactions = client.get_funding_transactions(address=_PUBLIC_WALLET_CHECKSUM) - - assert bridge_client.is_closed - _assert_public_results(deposit, withdrawal, catalog, quote, transactions) - _assert_public_requests(captured) - - -def test_async_public_funding_calls_use_bridge_transport_and_close_it() -> None: - captured: list[httpx.Request] = [] - - async def run() -> tuple[ - FundingAddressSet, - FundingAddressSet, - FundingAssetCatalog, - FundingQuote, - tuple[FundingTransaction, ...], - httpx.AsyncClient, - ]: - async with AsyncPublicClient() as client: - bridge_client = await _install_async_bridge(client, _bridge_handler(captured)) - deposit = await client.create_deposit_addresses( - wallet=_PUBLIC_WALLET, - builder_code=_BUILDER_CODE, - ) - withdrawal = await client.create_withdrawal_addresses( - wallet=_PUBLIC_WALLET, - destination_chain_id=728126428, - destination_token_address=_TRON_TOKEN, - recipient_address=_TRON_RECIPIENT, - builder_code=_BUILDER_CODE, - ) - catalog = await client.get_supported_funding_assets() - quote = await client.get_funding_quote(**_public_funding_args()) - transactions = await client.get_funding_transactions(address=_PUBLIC_WALLET_CHECKSUM) - return deposit, withdrawal, catalog, quote, transactions, bridge_client - - deposit, withdrawal, catalog, quote, transactions, bridge_client = asyncio.run(run()) - - assert bridge_client.is_closed - _assert_public_results(deposit, withdrawal, catalog, quote, transactions) - _assert_public_requests(captured) - - -def _assert_secure_address_requests( - captured: list[httpx.Request], - *, - wallet: str, -) -> None: - assert [(request.method, urlparse(str(request.url)).path) for request in captured] == [ - ("POST", "/deposit"), - ("POST", "/withdraw"), - ] - assert all(request.url.host == "bridge.test" for request in captured) - assert json.loads(captured[0].content) == {"address": wallet} - assert json.loads(captured[1].content) == { - "address": wallet, - "toChainId": "728126428", - "toTokenAddress": _TRON_TOKEN, - "recipientAddr": _TRON_RECIPIENT, - } - assert all(request.headers["X-Builder-Code"] == _BUILDER_CODE for request in captured) - - -def test_sync_secure_address_creation_uses_only_bound_wallet() -> None: - assert "wallet" not in inspect.signature(SecureClient.create_deposit_addresses).parameters - assert "wallet" not in inspect.signature(SecureClient.create_withdrawal_addresses).parameters - captured: list[httpx.Request] = [] - - with SecureClient._create( - private_key=_PRIVATE_KEY, - wallet=_BOUND_WALLET, - credentials=_FAKE_CREDS, - validate_credentials=False, - ) as client: - assert client.wallet != client.signer - bound_wallet = str(client.wallet) - bridge_client = _install_sync_bridge(client, _bridge_handler(captured)) - deposit = client.create_deposit_addresses(builder_code=_BUILDER_CODE) - withdrawal = client.create_withdrawal_addresses( - destination_chain_id=728126428, - destination_token_address=_TRON_TOKEN, - recipient_address=_TRON_RECIPIENT, - builder_code=_BUILDER_CODE, - ) - - assert bridge_client.is_closed - assert isinstance(deposit, FundingAddressSet) - assert isinstance(withdrawal, FundingAddressSet) - _assert_secure_address_requests(captured, wallet=bound_wallet) - - -def test_async_secure_address_creation_uses_only_bound_wallet() -> None: - assert "wallet" not in inspect.signature(AsyncSecureClient.create_deposit_addresses).parameters - assert ( - "wallet" not in inspect.signature(AsyncSecureClient.create_withdrawal_addresses).parameters - ) - captured: list[httpx.Request] = [] - - async def run() -> tuple[str, FundingAddressSet, FundingAddressSet, httpx.AsyncClient]: - client = await AsyncSecureClient._create( - private_key=_PRIVATE_KEY, - wallet=_BOUND_WALLET, - credentials=_FAKE_CREDS, - validate_credentials=False, - ) - async with client: - assert client.wallet != client.signer - bound_wallet = str(client.wallet) - bridge_client = await _install_async_bridge(client, _bridge_handler(captured)) - deposit = await client.create_deposit_addresses(builder_code=_BUILDER_CODE) - withdrawal = await client.create_withdrawal_addresses( - destination_chain_id=728126428, - destination_token_address=_TRON_TOKEN, - recipient_address=_TRON_RECIPIENT, - builder_code=_BUILDER_CODE, - ) - return bound_wallet, deposit, withdrawal, bridge_client - - bound_wallet, deposit, withdrawal, bridge_client = asyncio.run(run()) - - assert bridge_client.is_closed - assert isinstance(deposit, FundingAddressSet) - assert isinstance(withdrawal, FundingAddressSet) - _assert_secure_address_requests(captured, wallet=bound_wallet) diff --git a/tests/unit/test_funding_models.py b/tests/unit/test_funding_models.py index 1aaecd0..5628703 100644 --- a/tests/unit/test_funding_models.py +++ b/tests/unit/test_funding_models.py @@ -1,6 +1,6 @@ from datetime import UTC, datetime, timedelta from decimal import Decimal -from typing import get_args, get_type_hints +from typing import get_type_hints import pytest @@ -20,24 +20,6 @@ _TRON_ADDRESS = "TP1mjRAUVe5qXfCnpczdWhSrGpos2Arzir" -def _address_set_payload(*, tron_field: str = "tron") -> dict[str, object]: - return { - "address": { - "evm": _EVM_ADDRESS, - "svm": _SVM_ADDRESS, - "btc": _BTC_ADDRESS, - tron_field: _TRON_ADDRESS, - }, - "note": "Only certain chains and tokens are supported.", - "warnings": [ - { - "code": "missing_builder_code", - "message": "Include the X-Builder-Code header for attribution.", - } - ], - } - - def _quote_payload() -> dict[str, object]: return { "estCheckoutTimeMs": "25000", @@ -58,7 +40,7 @@ def _quote_payload() -> dict[str, object]: "estInputUsd": "14.488305", "estOutputUsd": 14.4, "estToTokenBaseUnit": "14491203", - "quoteId": "0x00c34ba467184b0146406d62b0e60aaa24ed52460bd456222b6155a0d9de0ad5", + "quoteId": "quote-id", } @@ -75,61 +57,30 @@ def _transaction_payload(**overrides: object) -> dict[str, object]: return payload -def test_address_set_parses_live_tron_and_warning_shape() -> None: - result = FundingAddressSet.parse_response(_address_set_payload()) - - assert result.addresses.evm == _EVM_ADDRESS - assert result.addresses.svm == _SVM_ADDRESS - assert result.addresses.btc == _BTC_ADDRESS - assert result.addresses.tron == _TRON_ADDRESS - assert result.note == "Only certain chains and tokens are supported." - assert len(result.warnings) == 1 - assert result.warnings[0].code == "missing_builder_code" - - -@pytest.mark.parametrize("wire_field", ["tron", "tvm"]) -def test_address_set_normalizes_tron_and_tvm_wire_fields(wire_field: str) -> None: - result = FundingAddressSet.parse_response(_address_set_payload(tron_field=wire_field)) - - assert result.addresses.tron == _TRON_ADDRESS - dumped = result.model_dump() - assert dumped["addresses"]["tron"] == _TRON_ADDRESS - assert "tvm" not in dumped["addresses"] - - -def test_address_set_prefers_live_tron_field_when_both_variants_are_present() -> None: - payload = _address_set_payload() - addresses = payload["address"] - assert isinstance(addresses, dict) - addresses["tvm"] = "legacy-tvm-address" - - result = FundingAddressSet.parse_response(payload) - - assert result.addresses.tron == _TRON_ADDRESS - - -def test_address_set_defaults_optional_advisories() -> None: +@pytest.mark.parametrize("tron_field", ["tron", "tvm"]) +def test_address_set_normalizes_tron_wire_variants_and_advisories( + tron_field: str, +) -> None: result = FundingAddressSet.parse_response( - {"address": {"evm": _EVM_ADDRESS, "svm": _SVM_ADDRESS, "btc": _BTC_ADDRESS}} + { + "address": { + "evm": _EVM_ADDRESS, + "svm": _SVM_ADDRESS, + "btc": _BTC_ADDRESS, + tron_field: _TRON_ADDRESS, + }, + "note": "Only supported assets should be sent.", + "warnings": [{"code": "missing_builder_code", "message": "Add attribution."}], + } ) - assert result.addresses.tron is None - assert result.note is None - assert result.warnings == () - - -def test_address_set_rejects_malformed_evm_address() -> None: - payload = _address_set_payload() - addresses = payload["address"] - assert isinstance(addresses, dict) - addresses["evm"] = "0x1234" - - with pytest.raises(UnexpectedResponseError, match="FundingAddressSet response"): - FundingAddressSet.parse_response(payload) + assert result.addresses.tron == _TRON_ADDRESS + assert result.note == "Only supported assets should be sent." + assert result.warnings[0].code == "missing_builder_code" -def test_asset_catalog_normalizes_chain_minimum_and_note() -> None: - result = FundingAssetCatalog.parse_response( +def test_asset_and_quote_wire_numbers_use_canonical_python_types() -> None: + catalog = FundingAssetCatalog.parse_response( { "supportedAssets": [ { @@ -143,65 +94,16 @@ def test_asset_catalog_normalizes_chain_minimum_and_note() -> None: }, "minCheckoutUsd": "7.25", } - ], - "note": "These assets support deposits and withdrawals.", + ] } ) + quote = FundingQuote.parse_response(_quote_payload()) - assert len(result.assets) == 1 - assert result.assets[0].chain_id == 728126428 - assert isinstance(result.assets[0].chain_id, int) - assert result.assets[0].minimum_amount_usd == Decimal("7.25") - assert isinstance(result.assets[0].minimum_amount_usd, Decimal) - assert result.assets[0].token.decimals == 6 - assert result.note == "These assets support deposits and withdrawals." - - -@pytest.mark.parametrize("minimum", ["NaN", "Infinity", True, -1, -float("inf")]) -def test_asset_catalog_rejects_non_finite_or_boolean_minimum(minimum: object) -> None: - payload = { - "supportedAssets": [ - { - "chainId": "1", - "chainName": "Ethereum", - "token": {"name": "USD Coin", "symbol": "USDC", "address": "0xUSDC", "decimals": 6}, - "minCheckoutUsd": minimum, - } - ] - } - - with pytest.raises(UnexpectedResponseError, match="FundingAssetCatalog response"): - FundingAssetCatalog.parse_response(payload) - - -def test_quote_normalizes_amounts_and_time_to_canonical_types() -> None: - result = FundingQuote.parse_response(_quote_payload()) - - assert result.estimated_checkout_time == timedelta(seconds=25) - assert isinstance(result.estimated_checkout_time, timedelta) - assert result.estimated_input_usd == Decimal("14.488305") - assert result.estimated_output_usd == Decimal("14.4") - assert isinstance(result.estimated_input_usd, Decimal) - assert result.estimated_destination_amount == 14_491_203 - assert isinstance(result.estimated_destination_amount, int) - assert result.estimated_fees.gas_usd == Decimal("0.003854") - assert result.estimated_fees.minimum_received == Decimal("14.488305") - - -def test_quote_rejects_boolean_decimal_field() -> None: - payload = _quote_payload() - payload["estInputUsd"] = True - - with pytest.raises(UnexpectedResponseError, match="FundingQuote response"): - FundingQuote.parse_response(payload) - - -def test_quote_rejects_negative_checkout_time() -> None: - payload = _quote_payload() - payload["estCheckoutTimeMs"] = -1 - - with pytest.raises(UnexpectedResponseError, match="FundingQuote response"): - FundingQuote.parse_response(payload) + assert catalog.assets[0].chain_id == 728126428 + assert catalog.assets[0].minimum_amount_usd == Decimal("7.25") + assert quote.estimated_checkout_time == timedelta(seconds=25) + assert quote.estimated_input_usd == Decimal("14.488305") + assert quote.estimated_destination_amount == 14_491_203 def test_transaction_normalizes_known_status_amount_and_timestamp() -> None: @@ -212,20 +114,9 @@ def test_transaction_normalizes_known_status_amount_and_timestamp() -> None: ) ) - assert result.source_chain_id == 1_151_111_081_099_710 - assert isinstance(result.source_chain_id, int) assert result.source_amount == 13_566_635 - assert isinstance(result.source_amount, int) assert result.status is KnownFundingTransactionStatus.COMPLETED - assert result.transaction_hash == "3atr19NAiNCYt24RHM1WnzZp47RXskpTDzspJoCBBaMFw" assert result.created_at == datetime.fromtimestamp(1_757_531_217_339 / 1000, tz=UTC) - assert result.created_at is not None and result.created_at.tzinfo is UTC - - -def test_transaction_maps_wire_origin_confirmation_status() -> None: - result = FundingTransaction.parse_response(_transaction_payload(status="ORIGIN_TX_CONFIRMED")) - - assert result.status is KnownFundingTransactionStatus.ORIGIN_TRANSACTION_CONFIRMED def test_transaction_preserves_unknown_status_for_forward_compatibility() -> None: @@ -235,14 +126,6 @@ def test_transaction_preserves_unknown_status_for_forward_compatibility() -> Non assert not isinstance(result.status, KnownFundingTransactionStatus) -def test_transaction_allows_status_dependent_fields_to_be_absent() -> None: - result = FundingTransaction.parse_response(_transaction_payload(status="DEPOSIT_DETECTED")) - - assert result.status is KnownFundingTransactionStatus.DEPOSIT_DETECTED - assert result.transaction_hash is None - assert result.created_at is None - - @pytest.mark.parametrize( ("field", "value"), [ @@ -258,7 +141,7 @@ def test_transaction_rejects_malformed_wire_values(field: str, value: object) -> FundingTransaction.parse_response(_transaction_payload(**{field: value})) -def test_public_funding_annotations_use_canonical_python_types() -> None: +def test_public_model_annotations_expose_canonical_python_types() -> None: asset_hints = get_type_hints(FundingAsset) quote_hints = get_type_hints(FundingQuote) transaction_hints = get_type_hints(FundingTransaction) @@ -266,7 +149,4 @@ def test_public_funding_annotations_use_canonical_python_types() -> None: assert asset_hints["chain_id"] is int assert asset_hints["minimum_amount_usd"] is Decimal assert quote_hints["estimated_checkout_time"] is timedelta - assert quote_hints["estimated_input_usd"] is Decimal - assert quote_hints["estimated_destination_amount"] is int assert transaction_hints["source_amount"] is int - assert datetime in get_args(transaction_hints["created_at"]) From c9141ef29a9ca257c3c1703a1ad0cf548724f733 Mon Sep 17 00:00:00 2001 From: kartojal Date: Fri, 14 Aug 2026 11:10:38 +0200 Subject: [PATCH 3/4] fix(client): align funding parity and round-trip guards --- src/polymarket/clients/async_public.py | 6 +- src/polymarket/clients/async_secure.py | 6 +- src/polymarket/clients/public.py | 6 +- src/polymarket/clients/secure.py | 6 +- tests/integration/test_funding.py | 181 +++++++++--- tests/unit/test_funding_clients.py | 368 +++++++++++++++++++++++++ 6 files changed, 516 insertions(+), 57 deletions(-) create mode 100644 tests/unit/test_funding_clients.py diff --git a/src/polymarket/clients/async_public.py b/src/polymarket/clients/async_public.py index efbf638..7efc410 100644 --- a/src/polymarket/clients/async_public.py +++ b/src/polymarket/clients/async_public.py @@ -419,13 +419,13 @@ async def create_withdrawal_addresses( await self._ctx.bridge.post_json(path, json=body, headers=headers) ) - async def get_supported_funding_assets(self) -> FundingAssetCatalog: - """Get the chain and token pairs supported for account funding.""" + async def fetch_supported_funding_assets(self) -> FundingAssetCatalog: + """Fetch the chain and token pairs supported for account funding.""" return _funding_actions.parse_funding_asset_catalog( await self._ctx.bridge.get_json("/supported-assets") ) - async def get_funding_quote( + async def fetch_funding_quote( self, *, amount: int, diff --git a/src/polymarket/clients/async_secure.py b/src/polymarket/clients/async_secure.py index 1f324ad..29ca5d2 100644 --- a/src/polymarket/clients/async_secure.py +++ b/src/polymarket/clients/async_secure.py @@ -1012,13 +1012,13 @@ async def create_withdrawal_addresses( await self._ctx.bridge.post_json(path, json=body, headers=headers) ) - async def get_supported_funding_assets(self) -> FundingAssetCatalog: - """Get the chain and token pairs supported for account funding.""" + async def fetch_supported_funding_assets(self) -> FundingAssetCatalog: + """Fetch the chain and token pairs supported for account funding.""" return _funding_actions.parse_funding_asset_catalog( await self._ctx.bridge.get_json("/supported-assets") ) - async def get_funding_quote( + async def fetch_funding_quote( self, *, amount: int, diff --git a/src/polymarket/clients/public.py b/src/polymarket/clients/public.py index d293928..9405ffc 100644 --- a/src/polymarket/clients/public.py +++ b/src/polymarket/clients/public.py @@ -200,13 +200,13 @@ def create_withdrawal_addresses( self._ctx.bridge.post_json(path, json=body, headers=headers) ) - def get_supported_funding_assets(self) -> FundingAssetCatalog: - """Get the chain and token pairs supported for account funding.""" + def fetch_supported_funding_assets(self) -> FundingAssetCatalog: + """Fetch the chain and token pairs supported for account funding.""" return _funding_actions.parse_funding_asset_catalog( self._ctx.bridge.get_json("/supported-assets") ) - def get_funding_quote( + def fetch_funding_quote( self, *, amount: int, diff --git a/src/polymarket/clients/secure.py b/src/polymarket/clients/secure.py index 47bc951..3aaecd6 100644 --- a/src/polymarket/clients/secure.py +++ b/src/polymarket/clients/secure.py @@ -575,13 +575,13 @@ def create_withdrawal_addresses( self._ctx.bridge.post_json(path, json=body, headers=headers) ) - def get_supported_funding_assets(self) -> FundingAssetCatalog: - """Get the chain and token pairs supported for account funding.""" + def fetch_supported_funding_assets(self) -> FundingAssetCatalog: + """Fetch the chain and token pairs supported for account funding.""" return _funding_actions.parse_funding_asset_catalog( self._ctx.bridge.get_json("/supported-assets") ) - def get_funding_quote( + def fetch_funding_quote( self, *, amount: int, diff --git a/tests/integration/test_funding.py b/tests/integration/test_funding.py index a9dcedd..afcae13 100644 --- a/tests/integration/test_funding.py +++ b/tests/integration/test_funding.py @@ -7,12 +7,16 @@ import pytest from polymarket import ( + PRODUCTION, AsyncPublicClient, AsyncSecureClient, FundingTransaction, KnownFundingTransactionStatus, Page, + RequestRejectedError, + TransactionHandle, ) +from polymarket._internal.environment import get_environment_config pytestmark = [pytest.mark.anyio, pytest.mark.integration] @@ -20,10 +24,13 @@ _POLYGON_USDC = "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359" _POLYGON_POLYMARKET_USDC = "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB" _DOCUMENTED_FUNDING_ADDRESS = "0x23566f8b2E82aDfCf01846E54899d110e97AC053" -_DEPOSIT_AMOUNT = 2_100_000 -_WITHDRAWAL_AMOUNT = 2_000_000 +_WITHDRAWAL_AMOUNT = 2_100_000 +_RETURN_DEPOSIT_AMOUNT = 2_000_000 +_MIN_RETURN_COLLATERAL_AMOUNT = 1_950_000 +_PRODUCTION_BRIDGE_URL = "https://bridge.polymarket.com" _POLL_INTERVAL_SECONDS = 10.0 _TRANSFER_TIMEOUT_SECONDS = 600.0 +_SETTLEMENT_RETRY_TIMEOUT_SECONDS = 120.0 async def _wait_for_funding_transfer( @@ -32,7 +39,6 @@ async def _wait_for_funding_transfer( address: str, source_token: str, source_amount: int, - destination_token: str, not_before: datetime, ) -> FundingTransaction: """Poll the newest status page until the expected transfer is terminal.""" @@ -47,7 +53,6 @@ async def _wait_for_funding_transfer( or transaction.source_token_address.lower() != source_token.lower() or transaction.source_amount != source_amount or transaction.destination_chain_id != _POLYGON_CHAIN_ID - or transaction.destination_token_address.lower() != destination_token.lower() ): continue if transaction.status is KnownFundingTransactionStatus.FAILED: @@ -60,10 +65,60 @@ async def _wait_for_funding_transfer( await asyncio.sleep(_POLL_INTERVAL_SECONDS) +async def _transfer_after_funding_settlement( + client: AsyncSecureClient, + *, + token_address: str, + recipient_address: str, + amount: int, + metadata: str, +) -> TransactionHandle: + """Retry the explicit relayer simulation race after bridge completion.""" + deadline = asyncio.get_running_loop().time() + _SETTLEMENT_RETRY_TIMEOUT_SECONDS + last_error: RequestRejectedError | None = None + while True: + try: + return await client.transfer_erc20( + token_address=token_address, + recipient_address=recipient_address, + amount=amount, + metadata=metadata, + ) + except RequestRejectedError as error: + if error.status != 400 or "batch would revert" not in str(error).lower(): + raise + last_error = error + + if asyncio.get_running_loop().time() >= deadline: + raise AssertionError( + "withdrawn native USDC did not become spendable before the retry deadline" + ) from last_error + await asyncio.sleep(_POLL_INTERVAL_SECONDS) + + +async def _wait_for_collateral_balance( + client: AsyncSecureClient, + *, + minimum_balance: int, +) -> int: + """Wait until the completed return leg is visible in the user's CLOB balance.""" + deadline = asyncio.get_running_loop().time() + _TRANSFER_TIMEOUT_SECONDS + while True: + balance = await client.get_balance_allowance(asset_type="COLLATERAL") + if balance.balance >= minimum_balance: + return balance.balance + if asyncio.get_running_loop().time() >= deadline: + pytest.fail( + "returned pUSD did not appear in the user-visible collateral balance " + f"before timeout (expected at least {minimum_balance}, saw {balance.balance})" + ) + await asyncio.sleep(_POLL_INTERVAL_SECONDS) + + async def test_discovery_quote_and_paginated_status_live( public_client: AsyncPublicClient, ) -> None: - catalog = await public_client.get_supported_funding_assets() + catalog = await public_client.fetch_supported_funding_assets() assert catalog.assets asset = catalog.assets[0] assert asset.chain_id > 0 @@ -71,7 +126,7 @@ async def test_discovery_quote_and_paginated_status_live( assert asset.token.symbol assert asset.minimum_amount_usd >= Decimal(0) - quote = await public_client.get_funding_quote( + quote = await public_client.fetch_funding_quote( amount=10_000_000, source_chain_id=_POLYGON_CHAIN_ID, source_token_address=_POLYGON_USDC, @@ -101,17 +156,27 @@ async def test_discovery_quote_and_paginated_status_live( @pytest.mark.metered -async def test_minimum_usdc_deposit_and_withdrawal_round_trip_live( +async def test_minimum_pusd_withdrawal_and_deposit_round_trip_live( deposit_wallet_client: AsyncSecureClient, builder_code: str, ) -> None: - """Round-trip the minimum withdrawal; irreversibly spends bridge fees and moves funds. + """Withdraw 2.10 pUSD, then return 2.00 USDC; bridge fees are irreversible. - The configured wallet must hold at least 2.10 native Polygon USDC before the run. + The configured wallet must hold at least 2.10 pUSD before the run. A passing + run ends with the returned collateral visible in the user's CLOB balance. """ client = deposit_wallet_client wallet = str(client.wallet) - catalog = await client.get_supported_funding_assets() + config = get_environment_config(client.environment) + if ( + client.environment != PRODUCTION + or config.chain_id != _POLYGON_CHAIN_ID + or config.bridge_url != _PRODUCTION_BRIDGE_URL + or config.collateral_token.lower() != _POLYGON_POLYMARKET_USDC.lower() + ): + pytest.skip("the metered funding round trip is restricted to Polygon production") + + catalog = await client.fetch_supported_funding_assets() native_usdc = next( ( asset @@ -134,74 +199,100 @@ async def test_minimum_usdc_deposit_and_withdrawal_round_trip_live( pytest.skip("required Polygon funding assets are unavailable") if native_usdc.token.decimals != 6 or pusd.token.decimals != 6: pytest.skip("the metered amounts require six-decimal Polygon funding assets") - if Decimal(_DEPOSIT_AMOUNT) / 1_000_000 < native_usdc.minimum_amount_usd: - pytest.skip("the deposit amount is below the current native USDC minimum") if Decimal(_WITHDRAWAL_AMOUNT) / 1_000_000 < pusd.minimum_amount_usd: pytest.skip("the withdrawal amount is below the current pUSD minimum") + if Decimal(_RETURN_DEPOSIT_AMOUNT) / 1_000_000 < native_usdc.minimum_amount_usd: + pytest.skip("the return deposit is below the current native USDC minimum") - deposit = await client.create_deposit_addresses(builder_code=builder_code) - withdrawal = await client.create_withdrawal_addresses( + initial_collateral = await client.get_balance_allowance(asset_type="COLLATERAL") + if initial_collateral.balance < _WITHDRAWAL_AMOUNT: + pytest.skip("the integration wallet has insufficient pUSD for the round trip") + + withdrawal_quote = await client.fetch_funding_quote( + amount=_WITHDRAWAL_AMOUNT, + source_chain_id=_POLYGON_CHAIN_ID, + source_token_address=_POLYGON_POLYMARKET_USDC, destination_chain_id=_POLYGON_CHAIN_ID, destination_token_address=_POLYGON_USDC, recipient_address=wallet, - builder_code=builder_code, ) - quote = await client.get_funding_quote( - amount=_DEPOSIT_AMOUNT, + return_preflight_quote = await client.fetch_funding_quote( + amount=_RETURN_DEPOSIT_AMOUNT, source_chain_id=_POLYGON_CHAIN_ID, source_token_address=_POLYGON_USDC, destination_chain_id=_POLYGON_CHAIN_ID, destination_token_address=_POLYGON_POLYMARKET_USDC, recipient_address=wallet, ) - withdrawal_quote = await client.get_funding_quote( + if ( + withdrawal_quote.estimated_destination_amount < _RETURN_DEPOSIT_AMOUNT + or withdrawal_quote.estimated_fees.minimum_received < Decimal("2") + or return_preflight_quote.estimated_destination_amount < _MIN_RETURN_COLLATERAL_AMOUNT + or return_preflight_quote.estimated_fees.minimum_received < Decimal("1.95") + ): + pytest.skip("current quotes cannot safely complete the minimum round trip") + + # Fund-moving side effects begin here. The hard caps are the two constant + # transfer amounts above; fees remain spent if any later assertion fails. + withdrawal = await client.create_withdrawal_addresses( + destination_chain_id=_POLYGON_CHAIN_ID, + destination_token_address=_POLYGON_USDC, + recipient_address=wallet, + builder_code=builder_code, + ) + withdrawal_started_at = datetime.now(UTC) - timedelta(minutes=1) + withdrawal_handle = await client.transfer_erc20( + token_address=_POLYGON_POLYMARKET_USDC, + recipient_address=str(withdrawal.addresses.evm), amount=_WITHDRAWAL_AMOUNT, + metadata="py-sdk bridge integration test: minimum USDC withdrawal", + ) + await withdrawal_handle.wait() + await _wait_for_funding_transfer( + client, + address=str(withdrawal.addresses.evm), + source_token=_POLYGON_POLYMARKET_USDC, + source_amount=_WITHDRAWAL_AMOUNT, + not_before=withdrawal_started_at, + ) + + return_quote = await client.fetch_funding_quote( + amount=_RETURN_DEPOSIT_AMOUNT, source_chain_id=_POLYGON_CHAIN_ID, - source_token_address=_POLYGON_POLYMARKET_USDC, + source_token_address=_POLYGON_USDC, destination_chain_id=_POLYGON_CHAIN_ID, - destination_token_address=_POLYGON_USDC, + destination_token_address=_POLYGON_POLYMARKET_USDC, recipient_address=wallet, ) if ( - quote.estimated_fees.minimum_received < Decimal("2.05") - or quote.estimated_destination_amount < _WITHDRAWAL_AMOUNT - or withdrawal_quote.estimated_destination_amount < 1_950_000 - or withdrawal_quote.estimated_fees.minimum_received < Decimal("1.95") + return_quote.estimated_destination_amount < _MIN_RETURN_COLLATERAL_AMOUNT + or return_quote.estimated_fees.minimum_received < Decimal("1.95") ): - pytest.skip("current quotes cannot safely complete the minimum round trip") + pytest.fail("the refreshed return quote is unsafe; withdrawn USDC was not deposited") - # Fund-moving side effects begin here: this moves at most 2.10 USDC into the bridge and - # irreversibly spends its fees even if a later assertion fails. + deposit = await client.create_deposit_addresses(builder_code=builder_code) deposit_started_at = datetime.now(UTC) - timedelta(minutes=1) - deposit_handle = await client.transfer_erc20( + deposit_handle = await _transfer_after_funding_settlement( + client, token_address=_POLYGON_USDC, recipient_address=str(deposit.addresses.evm), - amount=_DEPOSIT_AMOUNT, - metadata="py-sdk bridge integration test: minimum USDC deposit", + amount=_RETURN_DEPOSIT_AMOUNT, + metadata="py-sdk bridge integration test: return withdrawn native USDC", ) await deposit_handle.wait() await _wait_for_funding_transfer( client, address=str(deposit.addresses.evm), source_token=_POLYGON_USDC, - source_amount=_DEPOSIT_AMOUNT, - destination_token=_POLYGON_POLYMARKET_USDC, + source_amount=_RETURN_DEPOSIT_AMOUNT, not_before=deposit_started_at, ) - withdrawal_started_at = datetime.now(UTC) - timedelta(minutes=1) - withdrawal_handle = await client.transfer_erc20( - token_address=_POLYGON_POLYMARKET_USDC, - recipient_address=str(withdrawal.addresses.evm), - amount=_WITHDRAWAL_AMOUNT, - metadata="py-sdk bridge integration test: minimum USDC withdrawal", + minimum_final_collateral = ( + initial_collateral.balance - _WITHDRAWAL_AMOUNT + _MIN_RETURN_COLLATERAL_AMOUNT ) - await withdrawal_handle.wait() - await _wait_for_funding_transfer( + final_collateral = await _wait_for_collateral_balance( client, - address=str(withdrawal.addresses.evm), - source_token=_POLYGON_POLYMARKET_USDC, - source_amount=_WITHDRAWAL_AMOUNT, - destination_token=_POLYGON_USDC, - not_before=withdrawal_started_at, + minimum_balance=minimum_final_collateral, ) + assert final_collateral >= minimum_final_collateral diff --git a/tests/unit/test_funding_clients.py b/tests/unit/test_funding_clients.py new file mode 100644 index 0000000..4abec6e --- /dev/null +++ b/tests/unit/test_funding_clients.py @@ -0,0 +1,368 @@ +# pyright: reportPrivateUsage=false +import asyncio +import dataclasses +import inspect +import json +from typing import Any, cast +from urllib.parse import urlparse + +import httpx + +from polymarket import ( + ApiKeyCreds, + AsyncPublicClient, + AsyncSecureClient, + FundingAddressSet, + FundingAssetCatalog, + FundingQuote, + FundingTransaction, + KnownFundingTransactionStatus, + Page, + PublicClient, + SecureClient, +) +from polymarket._internal.context import AsyncSecureClientContext, SyncSecureClientContext +from polymarket.clients._transport import AsyncTransport, SyncTransport + +_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" +_BOUND_WALLET = "0xBc0fF067b7740Eff76C1ca93c875Ba6B890d6B50" +_PUBLIC_WALLET = "0x52908400098527886e0f7030069857d2e4169ee7" +_PUBLIC_WALLET_CHECKSUM = "0x52908400098527886E0F7030069857D2E4169EE7" +_BUILDER_CODE = "0x" + "ab" * 32 +_SOURCE_TOKEN = "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359" +_DESTINATION_TOKEN = "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB" +_TRON_TOKEN = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t" +_TRON_RECIPIENT = "TP1mjRAUVe5qXfCnpczdWhSrGpos2Arzir" +_FAKE_CREDS = ApiKeyCreds( + key="test-key", + passphrase="test-passphrase", + secret="dGVzdA==", +) + +_ADDRESS_SET_PAYLOAD: dict[str, object] = { + "address": { + "evm": _PUBLIC_WALLET_CHECKSUM, + "svm": "CrvTBvzryYxBHbWu2TiQpcqD5M7Le7iBKzVmEj3f36Jb", + "btc": "bc1q8eau83qffxcj8ht4hsjdza3lha9r3egfqysj3g", + "tron": _TRON_RECIPIENT, + } +} +_ASSET_CATALOG_PAYLOAD: dict[str, object] = { + "supportedAssets": [ + { + "chainId": "137", + "chainName": "Polygon", + "token": { + "name": "USD Coin", + "symbol": "USDC.e", + "address": _DESTINATION_TOKEN, + "decimals": 6, + }, + "minCheckoutUsd": "5", + } + ] +} +_QUOTE_PAYLOAD: dict[str, object] = { + "estCheckoutTimeMs": 25_000, + "estFeeBreakdown": { + "appFeeLabel": "Fun.xyz fee", + "appFeePercent": 0, + "appFeeUsd": 0, + "fillCostPercent": 0, + "fillCostUsd": 0, + "gasUsd": "0.01", + "maxSlippage": "0.5", + "minReceived": "9.9", + "swapImpact": 0, + "swapImpactUsd": 0, + "totalImpact": 0, + "totalImpactUsd": 0, + }, + "estInputUsd": "10", + "estOutputUsd": "9.99", + "estToTokenBaseUnit": "9990000", + "quoteId": "quote-1", +} +_TRANSACTIONS_PAYLOAD: dict[str, object] = { + "transactions": [ + { + "fromChainId": "1", + "fromTokenAddress": _SOURCE_TOKEN, + "fromAmountBaseUnit": "10000000", + "toChainId": "137", + "toTokenAddress": _DESTINATION_TOKEN, + "status": "COMPLETED", + } + ], + "nextCursor": None, +} + + +def _bridge_handler(captured: list[httpx.Request]) -> httpx.MockTransport: + responses: dict[tuple[str, str], dict[str, object]] = { + ("POST", "/deposit"): _ADDRESS_SET_PAYLOAD, + ("POST", "/withdraw"): _ADDRESS_SET_PAYLOAD, + ("GET", "/supported-assets"): _ASSET_CATALOG_PAYLOAD, + ("POST", "/quote"): _QUOTE_PAYLOAD, + ("GET", f"/status/{_PUBLIC_WALLET_CHECKSUM}"): _TRANSACTIONS_PAYLOAD, + } + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + route = (request.method, urlparse(str(request.url)).path) + payload = responses.get(route) + if payload is None: + raise AssertionError(f"Unexpected funding request: {route!r}") + return httpx.Response(200, json=payload, request=request) + + return httpx.MockTransport(handler) + + +def _install_sync_bridge( + client: PublicClient | SecureClient, + handler: httpx.MockTransport, +) -> httpx.Client: + http_client = httpx.Client(base_url="https://bridge.test", transport=handler) + bridge = SyncTransport(base_url="https://bridge.test", client=http_client) + bridge._owns_client = True + client._ctx.bridge.close() + client._ctx = cast( + SyncSecureClientContext, + dataclasses.replace(client._ctx, bridge=bridge), + ) + return http_client + + +async def _install_async_bridge( + client: AsyncPublicClient | AsyncSecureClient, + handler: httpx.MockTransport, +) -> httpx.AsyncClient: + http_client = httpx.AsyncClient(base_url="https://bridge.test", transport=handler) + bridge = AsyncTransport(base_url="https://bridge.test", client=http_client) + bridge._owns_client = True + await client._ctx.bridge.close() + client._ctx = cast( + AsyncSecureClientContext, + dataclasses.replace(client._ctx, bridge=bridge), + ) + return http_client + + +def _assert_public_results( + deposit: FundingAddressSet, + withdrawal: FundingAddressSet, + catalog: FundingAssetCatalog, + quote: FundingQuote, + page: Page[FundingTransaction], +) -> None: + assert deposit.addresses.tron == _TRON_RECIPIENT + assert withdrawal.addresses.evm == _PUBLIC_WALLET_CHECKSUM + assert catalog.assets[0].chain_id == 137 + assert quote.quote_id == "quote-1" + assert len(page.items) == 1 + assert page.items[0].status is KnownFundingTransactionStatus.COMPLETED + assert page.has_more is False + assert page.next_cursor is None + + +def _assert_public_requests(captured: list[httpx.Request]) -> None: + assert [(request.method, urlparse(str(request.url)).path) for request in captured] == [ + ("POST", "/deposit"), + ("POST", "/withdraw"), + ("GET", "/supported-assets"), + ("POST", "/quote"), + ("GET", f"/status/{_PUBLIC_WALLET_CHECKSUM}"), + ] + assert all(request.url.host == "bridge.test" for request in captured) + assert all("POLY_SIGNATURE" not in request.headers for request in captured) + assert json.loads(captured[0].content) == {"address": _PUBLIC_WALLET_CHECKSUM} + assert captured[0].headers["X-Builder-Code"] == _BUILDER_CODE + assert json.loads(captured[1].content) == { + "address": _PUBLIC_WALLET_CHECKSUM, + "toChainId": "728126428", + "toTokenAddress": _TRON_TOKEN, + "recipientAddr": _TRON_RECIPIENT, + } + assert captured[1].headers["X-Builder-Code"] == _BUILDER_CODE + assert captured[2].content == b"" + assert json.loads(captured[3].content) == { + "fromAmountBaseUnit": "10000000", + "fromChainId": "137", + "fromTokenAddress": _SOURCE_TOKEN, + "recipientAddress": _PUBLIC_WALLET_CHECKSUM, + "toChainId": "137", + "toTokenAddress": _DESTINATION_TOKEN, + } + assert "X-Builder-Code" not in captured[3].headers + assert captured[4].content == b"" + assert dict(captured[4].url.params) == {"limit": "1"} + + +def _public_funding_args() -> dict[str, Any]: + return { + "amount": 10_000_000, + "source_chain_id": 137, + "source_token_address": _SOURCE_TOKEN, + "destination_chain_id": 137, + "destination_token_address": _DESTINATION_TOKEN, + "recipient_address": _PUBLIC_WALLET_CHECKSUM, + } + + +def test_all_clients_expose_final_funding_fetch_names_only() -> None: + for client_type in (PublicClient, SecureClient, AsyncPublicClient, AsyncSecureClient): + assert hasattr(client_type, "fetch_supported_funding_assets") + assert hasattr(client_type, "fetch_funding_quote") + assert not hasattr(client_type, "get_supported_funding_assets") + assert not hasattr(client_type, "get_funding_quote") + + +def test_sync_public_funding_calls_use_bridge_transport_and_close_it() -> None: + captured: list[httpx.Request] = [] + + with PublicClient() as client: + bridge_client = _install_sync_bridge(client, _bridge_handler(captured)) + deposit = client.create_deposit_addresses( + wallet=_PUBLIC_WALLET, + builder_code=_BUILDER_CODE, + ) + withdrawal = client.create_withdrawal_addresses( + wallet=_PUBLIC_WALLET, + destination_chain_id=728126428, + destination_token_address=_TRON_TOKEN, + recipient_address=_TRON_RECIPIENT, + builder_code=_BUILDER_CODE, + ) + catalog = client.fetch_supported_funding_assets() + quote = client.fetch_funding_quote(**_public_funding_args()) + page = client.list_funding_transactions( + address=_PUBLIC_WALLET_CHECKSUM, + page_size=1, + ).first_page() + + assert bridge_client.is_closed + _assert_public_results(deposit, withdrawal, catalog, quote, page) + _assert_public_requests(captured) + + +def test_async_public_funding_calls_use_bridge_transport_and_close_it() -> None: + captured: list[httpx.Request] = [] + + async def run() -> tuple[ + FundingAddressSet, + FundingAddressSet, + FundingAssetCatalog, + FundingQuote, + Page[FundingTransaction], + httpx.AsyncClient, + ]: + async with AsyncPublicClient() as client: + bridge_client = await _install_async_bridge(client, _bridge_handler(captured)) + deposit = await client.create_deposit_addresses( + wallet=_PUBLIC_WALLET, + builder_code=_BUILDER_CODE, + ) + withdrawal = await client.create_withdrawal_addresses( + wallet=_PUBLIC_WALLET, + destination_chain_id=728126428, + destination_token_address=_TRON_TOKEN, + recipient_address=_TRON_RECIPIENT, + builder_code=_BUILDER_CODE, + ) + catalog = await client.fetch_supported_funding_assets() + quote = await client.fetch_funding_quote(**_public_funding_args()) + page = await client.list_funding_transactions( + address=_PUBLIC_WALLET_CHECKSUM, + page_size=1, + ).first_page() + return deposit, withdrawal, catalog, quote, page, bridge_client + + deposit, withdrawal, catalog, quote, page, bridge_client = asyncio.run(run()) + + assert bridge_client.is_closed + _assert_public_results(deposit, withdrawal, catalog, quote, page) + _assert_public_requests(captured) + + +def _assert_secure_address_requests( + captured: list[httpx.Request], + *, + wallet: str, +) -> None: + assert [(request.method, urlparse(str(request.url)).path) for request in captured] == [ + ("POST", "/deposit"), + ("POST", "/withdraw"), + ] + assert all(request.url.host == "bridge.test" for request in captured) + assert all("POLY_SIGNATURE" not in request.headers for request in captured) + assert json.loads(captured[0].content) == {"address": wallet} + assert json.loads(captured[1].content) == { + "address": wallet, + "toChainId": "728126428", + "toTokenAddress": _TRON_TOKEN, + "recipientAddr": _TRON_RECIPIENT, + } + assert all(request.headers["X-Builder-Code"] == _BUILDER_CODE for request in captured) + + +def test_sync_secure_address_creation_uses_only_bound_wallet() -> None: + assert "wallet" not in inspect.signature(SecureClient.create_deposit_addresses).parameters + assert "wallet" not in inspect.signature(SecureClient.create_withdrawal_addresses).parameters + captured: list[httpx.Request] = [] + + with SecureClient._create( + private_key=_PRIVATE_KEY, + wallet=_BOUND_WALLET, + credentials=_FAKE_CREDS, + validate_credentials=False, + ) as client: + assert client.wallet != client.signer + bound_wallet = str(client.wallet) + bridge_client = _install_sync_bridge(client, _bridge_handler(captured)) + deposit = client.create_deposit_addresses(builder_code=_BUILDER_CODE) + withdrawal = client.create_withdrawal_addresses( + destination_chain_id=728126428, + destination_token_address=_TRON_TOKEN, + recipient_address=_TRON_RECIPIENT, + builder_code=_BUILDER_CODE, + ) + + assert bridge_client.is_closed + assert isinstance(deposit, FundingAddressSet) + assert isinstance(withdrawal, FundingAddressSet) + _assert_secure_address_requests(captured, wallet=bound_wallet) + + +def test_async_secure_address_creation_uses_only_bound_wallet() -> None: + assert "wallet" not in inspect.signature(AsyncSecureClient.create_deposit_addresses).parameters + assert ( + "wallet" not in inspect.signature(AsyncSecureClient.create_withdrawal_addresses).parameters + ) + captured: list[httpx.Request] = [] + + async def run() -> tuple[str, FundingAddressSet, FundingAddressSet, httpx.AsyncClient]: + client = await AsyncSecureClient._create( + private_key=_PRIVATE_KEY, + wallet=_BOUND_WALLET, + credentials=_FAKE_CREDS, + validate_credentials=False, + ) + async with client: + assert client.wallet != client.signer + bound_wallet = str(client.wallet) + bridge_client = await _install_async_bridge(client, _bridge_handler(captured)) + deposit = await client.create_deposit_addresses(builder_code=_BUILDER_CODE) + withdrawal = await client.create_withdrawal_addresses( + destination_chain_id=728126428, + destination_token_address=_TRON_TOKEN, + recipient_address=_TRON_RECIPIENT, + builder_code=_BUILDER_CODE, + ) + return bound_wallet, deposit, withdrawal, bridge_client + + bound_wallet, deposit, withdrawal, bridge_client = asyncio.run(run()) + + assert bridge_client.is_closed + assert isinstance(deposit, FundingAddressSet) + assert isinstance(withdrawal, FundingAddressSet) + _assert_secure_address_requests(captured, wallet=bound_wallet) From 1febc49dd41cf564939b16a0ef6bf59bd2a8cac7 Mon Sep 17 00:00:00 2001 From: kartojal Date: Fri, 14 Aug 2026 12:48:11 +0200 Subject: [PATCH 4/4] fix(client): normalize funding timestamps to UTC --- src/polymarket/models/funding.py | 2 +- tests/unit/test_funding_models.py | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/polymarket/models/funding.py b/src/polymarket/models/funding.py index 8957fc9..c055a74 100644 --- a/src/polymarket/models/funding.py +++ b/src/polymarket/models/funding.py @@ -77,7 +77,7 @@ def _parse_evm_address(value: object) -> EvmAddress: def _parse_epoch_milliseconds(value: object) -> datetime: if isinstance(value, datetime): - return value + return value if value.utcoffset() is not None else value.replace(tzinfo=UTC) milliseconds = _parse_nonnegative_integer(value) try: return datetime.fromtimestamp(milliseconds / 1000, tz=UTC) diff --git a/tests/unit/test_funding_models.py b/tests/unit/test_funding_models.py index 5628703..fd6fe88 100644 --- a/tests/unit/test_funding_models.py +++ b/tests/unit/test_funding_models.py @@ -119,6 +119,14 @@ def test_transaction_normalizes_known_status_amount_and_timestamp() -> None: assert result.created_at == datetime.fromtimestamp(1_757_531_217_339 / 1000, tz=UTC) +def test_transaction_assumes_utc_for_naive_datetime() -> None: + result = FundingTransaction.parse_response( + _transaction_payload(createdTimeMs=datetime(2026, 1, 1)) + ) + + assert result.created_at == datetime(2026, 1, 1, tzinfo=UTC) + + def test_transaction_preserves_unknown_status_for_forward_compatibility() -> None: result = FundingTransaction.parse_response(_transaction_payload(status="COMPLIANCE_REVIEW"))