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..ad346b5 --- /dev/null +++ b/src/polymarket/_internal/actions/funding.py @@ -0,0 +1,180 @@ +"""Shared request construction and response parsing for account funding.""" + +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 +from polymarket.models.funding import ( + FundingAddressSet, + FundingAssetCatalog, + FundingQuote, + FundingTransaction, +) +from polymarket.pagination import Page + +_BUILDER_CODE_HEADER = "X-Builder-Code" +_DEFAULT_STATUS_PAGE_SIZE = 50 +_MAX_STATUS_PAGE_SIZE = 100 + + +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: + 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_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) + 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: + 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_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_list_funding_transactions_request", + "parse_funding_address_set", + "parse_funding_asset_catalog", + "parse_funding_quote", + "parse_funding_transactions_page", +] 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..7efc410 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,104 @@ 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 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 fetch_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) + ) + + 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 5cd117a..29ca5d2 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,91 @@ 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 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 fetch_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) + ) + + 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 connecting = self._rfq_session_connecting diff --git a/src/polymarket/clients/public.py b/src/polymarket/clients/public.py index ad7ff32..9405ffc 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,99 @@ 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 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 fetch_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 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 73d4a1e..3aaecd6 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,108 @@ 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 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 fetch_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 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/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..c055a74 --- /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 if value.utcoffset() is not None else value.replace(tzinfo=UTC) + 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..afcae13 --- /dev/null +++ b/tests/integration/test_funding.py @@ -0,0 +1,298 @@ +"""Live account-funding workflow coverage.""" + +import asyncio +from datetime import UTC, datetime, timedelta +from decimal import Decimal + +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] + +_POLYGON_CHAIN_ID = 137 +_POLYGON_USDC = "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359" +_POLYGON_POLYMARKET_USDC = "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB" +_DOCUMENTED_FUNDING_ADDRESS = "0x23566f8b2E82aDfCf01846E54899d110e97AC053" +_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( + client: AsyncSecureClient, + *, + address: str, + source_token: str, + source_amount: int, + 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 + ): + 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 _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.fetch_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.minimum_amount_usd >= Decimal(0) + + quote = await public_client.fetch_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="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 + + +@pytest.mark.metered +async def test_minimum_pusd_withdrawal_and_deposit_round_trip_live( + deposit_wallet_client: AsyncSecureClient, + builder_code: str, +) -> None: + """Withdraw 2.10 pUSD, then return 2.00 USDC; bridge fees are irreversible. + + 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) + 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 + 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(_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") + + 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, + ) + 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, + ) + 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_USDC, + destination_chain_id=_POLYGON_CHAIN_ID, + destination_token_address=_POLYGON_POLYMARKET_USDC, + recipient_address=wallet, + ) + if ( + return_quote.estimated_destination_amount < _MIN_RETURN_COLLATERAL_AMOUNT + or return_quote.estimated_fees.minimum_received < Decimal("1.95") + ): + pytest.fail("the refreshed return quote is unsafe; withdrawn USDC was not deposited") + + deposit = await client.create_deposit_addresses(builder_code=builder_code) + deposit_started_at = datetime.now(UTC) - timedelta(minutes=1) + deposit_handle = await _transfer_after_funding_settlement( + client, + token_address=_POLYGON_USDC, + recipient_address=str(deposit.addresses.evm), + 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=_RETURN_DEPOSIT_AMOUNT, + not_before=deposit_started_at, + ) + + minimum_final_collateral = ( + initial_collateral.balance - _WITHDRAWAL_AMOUNT + _MIN_RETURN_COLLATERAL_AMOUNT + ) + final_collateral = await _wait_for_collateral_balance( + client, + minimum_balance=minimum_final_collateral, + ) + assert final_collateral >= minimum_final_collateral 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..8a9aa41 --- /dev/null +++ b/tests/unit/test_funding_actions.py @@ -0,0 +1,139 @@ +import pytest + +from polymarket._internal.actions.funding import ( + build_create_deposit_addresses_request, + build_create_withdrawal_addresses_request, + build_funding_quote_request, + build_list_funding_transactions_request, + parse_funding_transactions_page, +) +from polymarket.errors import UnexpectedResponseError, UserInputError + +_WALLET_LOWER = "0x52908400098527886e0f7030069857d2e4169ee7" +_WALLET_CHECKSUM = "0x52908400098527886E0F7030069857D2E4169EE7" +_BUILDER_CODE = "0x" + "ab" * 32 + + +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 (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" + "zz" * 32], +) +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, + builder_code=builder_code, + ) + + +@pytest.mark.parametrize("amount", [0, -1, True, 1.5]) +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=137, + 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 path == "/status/tron%2Faddress%20with%20space" + assert params == {"limit": 100, "cursor": "opaque+/=cursor"} + assert "paginate" not in params + + +@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_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 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( + "payload", + [ + {"transactions": [], "nextCursor": ""}, + {"transactions": [], "nextCursor": 42}, + ], +) +def test_status_page_rejects_malformed_next_cursor( + payload: dict[str, object], +) -> None: + with pytest.raises(UnexpectedResponseError): + parse_funding_transactions_page(payload) 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) diff --git a/tests/unit/test_funding_models.py b/tests/unit/test_funding_models.py new file mode 100644 index 0000000..fd6fe88 --- /dev/null +++ b/tests/unit/test_funding_models.py @@ -0,0 +1,160 @@ +from datetime import UTC, datetime, timedelta +from decimal import Decimal +from typing import 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 _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": "quote-id", + } + + +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 + + +@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, + tron_field: _TRON_ADDRESS, + }, + "note": "Only supported assets should be sent.", + "warnings": [{"code": "missing_builder_code", "message": "Add attribution."}], + } + ) + + 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_and_quote_wire_numbers_use_canonical_python_types() -> None: + catalog = FundingAssetCatalog.parse_response( + { + "supportedAssets": [ + { + "chainId": "728126428", + "chainName": "Tron", + "token": { + "name": "Tether USD", + "symbol": "USDT", + "address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + "decimals": 6, + }, + "minCheckoutUsd": "7.25", + } + ] + } + ) + quote = FundingQuote.parse_response(_quote_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: + result = FundingTransaction.parse_response( + _transaction_payload( + txHash="3atr19NAiNCYt24RHM1WnzZp47RXskpTDzspJoCBBaMFw", + createdTimeMs="1757531217339", + ) + ) + + assert result.source_amount == 13_566_635 + assert result.status is KnownFundingTransactionStatus.COMPLETED + 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")) + + assert result.status == "COMPLIANCE_REVIEW" + assert not isinstance(result.status, KnownFundingTransactionStatus) + + +@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_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) + + assert asset_hints["chain_id"] is int + assert asset_hints["minimum_amount_usd"] is Decimal + assert quote_hints["estimated_checkout_time"] is timedelta + assert transaction_hints["source_amount"] is int