diff --git a/.env.example b/.env.example index 4b857869..2a3f0ec8 100644 --- a/.env.example +++ b/.env.example @@ -18,3 +18,5 @@ POLYMARKET_BUILDER_PASSPHRASE=YOUR_BUILDER_PASSPHRASE POLYMARKET_RELAYER_API_KEY=YOUR_RELAYER_API_KEY POLYMARKET_RELAYER_API_KEY_ADDRESS=YOUR_RELAYER_API_KEY_ADDRESS POLYMARKET_TEST_CONDITION_ID=0xYOUR_TEST_CONDITION_ID_BYTES32 +# Comma-separated combo leg position IDs for the metered combo RFQ test +POLYMARKET_COMBO_LEG_POSITION_IDS= diff --git a/src/polymarket/__init__.py b/src/polymarket/__init__.py index e857d8b8..d95ec9dd 100644 --- a/src/polymarket/__init__.py +++ b/src/polymarket/__init__.py @@ -175,6 +175,12 @@ ) from polymarket.pagination import AsyncPaginator, Page, Paginator from polymarket.rfq import ( + ComboAcceptFailureReason, + ComboFillResult, + ComboQuote, + ComboQuoteAcceptance, + ComboQuoteResult, + ComboQuoteUnavailableReason, RfqCancelQuoteAck, RfqCancelQuoteRejectedError, RfqConfirmationAck, @@ -183,6 +189,7 @@ RfqConfirmationRequestEvent, RfqDirection, RfqErrorCode, + RfqErrorDetail, RfqEvent, RfqExecutionStatus, RfqExecutionUpdateEvent, @@ -192,11 +199,15 @@ RfqQuoteRejectedError, RfqQuoteRequestEvent, RfqQuoteSource, + RfqRejectionCode, RfqRequestedSize, RfqRequestedSizeUnit, RfqRequestorPublicId, + RfqRequestRejectedError, RfqSession, RfqSide, + RfqStatus, + RfqStatusResult, RfqTradeEvent, ) from polymarket.transactions import ( @@ -244,17 +255,23 @@ "ConnectionLostError", "ClobTrade", "ClosedPosition", + "ComboAcceptFailureReason", "ComboActivity", "ComboActivityId", "ComboActivityType", "ComboCompressActivity", "ComboConvertActivity", + "ComboFillResult", "ComboPosition", "Comment", "ComboPositionLeg", "ComboPositionMarket", "ComboPositionMarketEvent", "ComboPositionOutcome", + "ComboQuote", + "ComboQuoteAcceptance", + "ComboQuoteResult", + "ComboQuoteUnavailableReason", "ComboPositionStatus", "ComboRedeemActivity", "ComboSplitActivity", @@ -375,6 +392,7 @@ "RfqConfirmationRequestEvent", "RfqDirection", "RfqErrorCode", + "RfqErrorDetail", "RfqEvent", "RfqExecutionStatus", "RfqExecutionUpdateEvent", @@ -384,11 +402,15 @@ "RfqQuoteRejectedError", "RfqQuoteRequestEvent", "RfqQuoteSource", + "RfqRejectionCode", + "RfqRequestRejectedError", "RfqRequestedSize", "RfqRequestedSizeUnit", "RfqRequestorPublicId", "RfqSession", "RfqSide", + "RfqStatus", + "RfqStatusResult", "RfqTradeEvent", "SearchResults", "SearchTag", diff --git a/src/polymarket/_internal/actions/combo_rfq.py b/src/polymarket/_internal/actions/combo_rfq.py new file mode 100644 index 00000000..aed69fa1 --- /dev/null +++ b/src/polymarket/_internal/actions/combo_rfq.py @@ -0,0 +1,824 @@ +"""Requester-side combo RFQ actions over the builder gateway.""" + +from __future__ import annotations + +import asyncio +import math +import re +import secrets +import time +from collections.abc import Mapping +from decimal import Decimal, InvalidOperation +from typing import Literal, cast +from urllib.parse import quote as quote_path_segment + +import httpx +from pydantic import ValidationError + +from polymarket._internal.actions.orders.typed_data import ( + build_order_signature, + build_order_typed_data, +) +from polymarket._internal.actions.orders.types import BYTES32_ZERO, UnsignedOrder +from polymarket._internal.context import AsyncSecureClientContext, SyncSecureClientContext +from polymarket._internal.wallet import signature_type_for +from polymarket.auth import BuilderApiKey +from polymarket.errors import ( + RequestRejectedError, + SigningError, + TimeoutError, + TransportError, + UnexpectedResponseError, + UserInputError, +) +from polymarket.models.types import ComboConditionId, PositionId, TokenId, to_combo_condition_id +from polymarket.rfq import ( + ComboAcceptFailureReason, + ComboFillResult, + ComboQuote, + ComboQuoteAcceptance, + ComboQuoteResult, + ComboQuoteUnavailableReason, + RfqDirection, + RfqErrorCode, + RfqErrorDetail, + RfqExecutionStatus, + RfqRejectionCode, + RfqRequestedSizeUnit, + RfqRequestRejectedError, + RfqSide, + RfqStatus, + RfqStatusResult, +) +from polymarket.types import EvmAddress, HexString, TransactionHash + +_E6 = 1_000_000 +_POLY_1271_SIGNATURE_TYPE = 3 +_PROTOCOL_VERSION_V3 = "3" +_MIN_LEGS = 2 +_MAX_LEGS = 50 + +_REQUESTS_PATH = "/v1/builder/rfq/requests" +_ACCEPT_OUTCOME_TIMEOUT_S = 30.0 +_ACCEPT_OUTCOME_POLL_INTERVAL_S = 0.5 +_DEFAULT_FILL_TIMEOUT_S = 30.0 +_DEFAULT_FILL_POLL_INTERVAL_S = 1.0 + +# Create and accept are held through the quote competition and maker last +# look respectively, so allow generous read timeouts. +_HELD_REQUEST_TIMEOUT = httpx.Timeout(connect=5.0, read=30.0, write=10.0, pool=5.0) + +_MISSING_API_KEY_MESSAGE = ( + "Combo RFQ requests require a Builder API Key. Pass api_key= when constructing the client." +) + +_SecureContext = AsyncSecureClientContext | SyncSecureClientContext + + +async def request_combo_quote( + ctx: AsyncSecureClientContext, + *, + leg_position_ids: list[str] | tuple[str, ...], + direction: RfqDirection | str, + amount: Decimal | int | float | str | None = None, + size: Decimal | int | float | str | None = None, + side: RfqSide | str = RfqSide.YES, +) -> ComboQuoteResult: + _, body = build_combo_quote_request_body( + ctx, + leg_position_ids=leg_position_ids, + direction=direction, + amount=amount, + size=size, + side=side, + ) + _require_builder_api_key(ctx) + try: + data = await ctx.builder_gateway.post_json( + _REQUESTS_PATH, json=body, timeout=_HELD_REQUEST_TIMEOUT + ) + except RequestRejectedError as error: + raise _to_rfq_request_rejected(error) from error + return _parse_combo_quote_result(data, request=body) + + +def request_combo_quote_sync( + ctx: SyncSecureClientContext, + *, + leg_position_ids: list[str] | tuple[str, ...], + direction: RfqDirection | str, + amount: Decimal | int | float | str | None = None, + size: Decimal | int | float | str | None = None, + side: RfqSide | str = RfqSide.YES, +) -> ComboQuoteResult: + _, body = build_combo_quote_request_body( + ctx, + leg_position_ids=leg_position_ids, + direction=direction, + amount=amount, + size=size, + side=side, + ) + _require_builder_api_key(ctx) + try: + data = ctx.builder_gateway.post_json( + _REQUESTS_PATH, json=body, timeout=_HELD_REQUEST_TIMEOUT + ) + except RequestRejectedError as error: + raise _to_rfq_request_rejected(error) from error + return _parse_combo_quote_result(data, request=body) + + +async def accept_combo_quote( + ctx: AsyncSecureClientContext, quote: ComboQuote | Mapping[str, object] +) -> ComboQuoteAcceptance: + quote = _parse_combo_quote_input(quote) + _require_builder_api_key(ctx) + body = _build_accept_request_body(ctx, quote) + path = f"{_REQUESTS_PATH}/{_encode_path_segment(quote.rfq_id)}/accept" + try: + try: + data = await ctx.builder_gateway.post_json( + path, json=body, timeout=_HELD_REQUEST_TIMEOUT + ) + status = _parse_rfq_status(data, expected_rfq_id=quote.rfq_id) + except (TransportError, UnexpectedResponseError): + # Acceptance is idempotent server-side. Retry the same signed order + # once when the request or response is interrupted during the maker + # last-look hold. + data = await ctx.builder_gateway.post_json( + path, json=body, timeout=_HELD_REQUEST_TIMEOUT + ) + status = _parse_rfq_status(data, expected_rfq_id=quote.rfq_id) + except RequestRejectedError as error: + expired = _to_expired_acceptance(quote.rfq_id, error) + if expired is not None: + return expired + raise _to_rfq_request_rejected(error) from error + + # Only the accept response carries the taker order hash; status polls do + # not, so capture it before entering the poll loop. + taker_order_hash = status.taker_order_hash + deadline = time.monotonic() + _ACCEPT_OUTCOME_TIMEOUT_S + while status.status == RfqStatus.AWAITING_MAKER_CONFIRMATION: + if time.monotonic() >= deadline: + raise TimeoutError( + f"Timed out waiting for the acceptance outcome of RFQ {quote.rfq_id}." + ) + await asyncio.sleep(_ACCEPT_OUTCOME_POLL_INTERVAL_S) + status = await fetch_rfq_status(ctx, rfq_id=quote.rfq_id) + return _to_acceptance(status, taker_order_hash=taker_order_hash) + + +def accept_combo_quote_sync( + ctx: SyncSecureClientContext, quote: ComboQuote | Mapping[str, object] +) -> ComboQuoteAcceptance: + quote = _parse_combo_quote_input(quote) + _require_builder_api_key(ctx) + body = _build_accept_request_body(ctx, quote) + path = f"{_REQUESTS_PATH}/{_encode_path_segment(quote.rfq_id)}/accept" + try: + try: + data = ctx.builder_gateway.post_json(path, json=body, timeout=_HELD_REQUEST_TIMEOUT) + status = _parse_rfq_status(data, expected_rfq_id=quote.rfq_id) + except (TransportError, UnexpectedResponseError): + # Acceptance is idempotent server-side. Retry the same signed order + # once when the request or response is interrupted during the maker + # last-look hold. + data = ctx.builder_gateway.post_json(path, json=body, timeout=_HELD_REQUEST_TIMEOUT) + status = _parse_rfq_status(data, expected_rfq_id=quote.rfq_id) + except RequestRejectedError as error: + expired = _to_expired_acceptance(quote.rfq_id, error) + if expired is not None: + return expired + raise _to_rfq_request_rejected(error) from error + + # Only the accept response carries the taker order hash; status polls do + # not, so capture it before entering the poll loop. + taker_order_hash = status.taker_order_hash + deadline = time.monotonic() + _ACCEPT_OUTCOME_TIMEOUT_S + while status.status == RfqStatus.AWAITING_MAKER_CONFIRMATION: + if time.monotonic() >= deadline: + raise TimeoutError( + f"Timed out waiting for the acceptance outcome of RFQ {quote.rfq_id}." + ) + time.sleep(_ACCEPT_OUTCOME_POLL_INTERVAL_S) + status = fetch_rfq_status_sync(ctx, rfq_id=quote.rfq_id) + return _to_acceptance(status, taker_order_hash=taker_order_hash) + + +async def wait_for_combo_fill( + ctx: AsyncSecureClientContext, + *, + rfq_id: str, + timeout: float = _DEFAULT_FILL_TIMEOUT_S, + polling_interval: float = _DEFAULT_FILL_POLL_INTERVAL_S, +) -> ComboFillResult: + _validate_wait_params(timeout=timeout, polling_interval=polling_interval) + deadline = time.monotonic() + timeout + while True: + status = await fetch_rfq_status(ctx, rfq_id=rfq_id) + result = _to_fill_result(status) + if result is not None: + return result + if time.monotonic() >= deadline: + raise TimeoutError( + f"Timed out after {timeout}s waiting for RFQ {rfq_id} to reach a terminal state." + ) + await asyncio.sleep(polling_interval) + + +def wait_for_combo_fill_sync( + ctx: SyncSecureClientContext, + *, + rfq_id: str, + timeout: float = _DEFAULT_FILL_TIMEOUT_S, + polling_interval: float = _DEFAULT_FILL_POLL_INTERVAL_S, +) -> ComboFillResult: + _validate_wait_params(timeout=timeout, polling_interval=polling_interval) + deadline = time.monotonic() + timeout + while True: + status = fetch_rfq_status_sync(ctx, rfq_id=rfq_id) + result = _to_fill_result(status) + if result is not None: + return result + if time.monotonic() >= deadline: + raise TimeoutError( + f"Timed out after {timeout}s waiting for RFQ {rfq_id} to reach a terminal state." + ) + time.sleep(polling_interval) + + +async def fetch_rfq_status(ctx: AsyncSecureClientContext, *, rfq_id: str) -> RfqStatusResult: + _require_rfq_id(rfq_id) + try: + data = await ctx.builder_gateway.get_json( + f"{_REQUESTS_PATH}/{_encode_path_segment(rfq_id)}" + ) + except RequestRejectedError as error: + raise _to_rfq_request_rejected(error) from error + return _parse_rfq_status(data, expected_rfq_id=rfq_id) + + +def fetch_rfq_status_sync(ctx: SyncSecureClientContext, *, rfq_id: str) -> RfqStatusResult: + _require_rfq_id(rfq_id) + try: + data = ctx.builder_gateway.get_json(f"{_REQUESTS_PATH}/{_encode_path_segment(rfq_id)}") + except RequestRejectedError as error: + raise _to_rfq_request_rejected(error) from error + return _parse_rfq_status(data, expected_rfq_id=rfq_id) + + +def build_combo_quote_request_body( + ctx: _SecureContext, + *, + leg_position_ids: list[str] | tuple[str, ...], + direction: RfqDirection | str, + amount: Decimal | int | float | str | None, + size: Decimal | int | float | str | None, + side: RfqSide | str, +) -> tuple[RfqDirection, dict[str, object]]: + parsed_direction = _parse_direction(direction) + parsed_side = _parse_side(side) + legs = _validate_legs(leg_position_ids) + + if parsed_direction is RfqDirection.BUY: + if amount is None: + raise UserInputError("BUY combo quote requests are sized in collateral; pass amount=.") + if size is not None: + raise UserInputError("BUY combo quote requests take amount=, not size=.") + requested = {"unit": "notional", "value_e6": str(_decimal_to_e6("amount", amount))} + else: + if size is None: + raise UserInputError( + "SELL combo quote requests are sized in outcome tokens; pass size=." + ) + if amount is not None: + raise UserInputError("SELL combo quote requests take size=, not amount=.") + requested = {"unit": "shares", "value_e6": str(_decimal_to_e6("size", size))} + + body: dict[str, object] = { + "signer_address": _order_signer_address(ctx), + "maker_address": ctx.wallet, + "signature_type": signature_type_for(ctx.wallet_type), + "leg_position_ids": legs, + "direction": parsed_direction.value, + "side": parsed_side.value, + "requested_size": requested, + } + return parsed_direction, body + + +def _build_accept_request_body(ctx: _SecureContext, quote: ComboQuote) -> dict[str, object]: + _require_rfq_id(quote.rfq_id) + if not quote.quote_id: + raise UserInputError("quote.quote_id must be a non-empty string.") + direction = _parse_direction(quote.direction) + position_id = _validate_position_id("quote.position_id", quote.position_id) + builder_code = _validate_builder_code(quote.builder_code) + if quote.expires_at < 0: + raise UserInputError("quote.expires_at must be non-negative.") + + signed_order = _sign_acceptance_order( + ctx, + direction=direction, + position_id=position_id, + builder_code=builder_code, + maker_amount_e6=_decimal_to_e6("quote.maker_amount", quote.maker_amount), + taker_amount_e6=_decimal_to_e6("quote.taker_amount", quote.taker_amount), + ) + return { + "quote_id": quote.quote_id, + "signed_order": signed_order, + } + + +def _sign_acceptance_order( + ctx: _SecureContext, + *, + direction: RfqDirection, + position_id: PositionId, + builder_code: HexString, + maker_amount_e6: int, + taker_amount_e6: int, +) -> dict[str, object]: + unsigned = UnsignedOrder( + builder=builder_code, + chain_id=ctx.environment_config.chain_id, + exchange_address=EvmAddress(ctx.environment_config.exchange_v3), + expiration=0, + maker=ctx.wallet, + maker_amount=maker_amount_e6, + metadata=BYTES32_ZERO, + order_type="GTC", + salt=secrets.randbits(64), + side="BUY" if direction is RfqDirection.BUY else "SELL", + signature_type=signature_type_for(ctx.wallet_type), + signer=_order_signer_address(ctx), + taker_amount=taker_amount_e6, + timestamp=int(time.time()), + token_id=TokenId(position_id), + ) + typed_data = build_order_typed_data(unsigned, protocol_version=_PROTOCOL_VERSION_V3) + try: + signed_message = ctx.signer.sign_typed_data(full_message=typed_data) + except Exception as error: + raise SigningError(f"Could not sign the combo quote acceptance order: {error}") from error + raw_hex = signed_message.signature.hex() + signature = HexString(raw_hex if raw_hex.startswith("0x") else "0x" + raw_hex) + final_signature = build_order_signature( + unsigned, signature, protocol_version=_PROTOCOL_VERSION_V3 + ) + return { + "salt": str(unsigned.salt), + "maker": unsigned.maker, + "signer": unsigned.signer, + "tokenId": unsigned.token_id, + "makerAmount": str(unsigned.maker_amount), + "takerAmount": str(unsigned.taker_amount), + "side": 0 if unsigned.side == "BUY" else 1, + "signatureType": unsigned.signature_type, + "timestamp": str(unsigned.timestamp), + "builder": unsigned.builder, + "metadata": unsigned.metadata, + "signature": final_signature, + } + + +def _order_signer_address(ctx: _SecureContext) -> EvmAddress: + if signature_type_for(ctx.wallet_type) == _POLY_1271_SIGNATURE_TYPE: + return ctx.wallet + return EvmAddress(ctx.signer.address) + + +def _require_builder_api_key(ctx: _SecureContext) -> None: + if not isinstance(ctx.api_key, BuilderApiKey): + raise UserInputError(_MISSING_API_KEY_MESSAGE) + + +def _require_rfq_id(rfq_id: str) -> None: + if not rfq_id: + raise UserInputError("rfq_id must be a non-empty string.") + + +def _parse_combo_quote_input(quote: ComboQuote | Mapping[str, object]) -> ComboQuote: + try: + return ComboQuote.model_validate(quote) + except ValidationError as error: + raise UserInputError("quote must be a valid self-contained combo quote.") from error + + +def _validate_wait_params(*, timeout: float, polling_interval: float) -> None: + _validate_positive_finite_number("timeout", timeout) + _validate_positive_finite_number("polling_interval", polling_interval) + + +def _validate_positive_finite_number(name: str, value: object) -> None: + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value): + raise UserInputError(f"{name} must be a finite number greater than 0.") + if value <= 0: + raise UserInputError(f"{name} must be a finite number greater than 0.") + + +def _parse_direction(direction: RfqDirection | str) -> RfqDirection: + try: + return direction if isinstance(direction, RfqDirection) else RfqDirection(direction) + except ValueError as error: + raise UserInputError("direction must be 'BUY' or 'SELL'.") from error + + +def _parse_side(side: RfqSide | str) -> RfqSide: + try: + return side if isinstance(side, RfqSide) else RfqSide(side) + except ValueError as error: + raise UserInputError("side must be 'YES'.") from error + + +def _validate_legs(leg_position_ids: list[str] | tuple[str, ...]) -> list[str]: + raw_legs = [str(leg) for leg in leg_position_ids] + if len(raw_legs) < _MIN_LEGS or len(raw_legs) > _MAX_LEGS: + raise UserInputError( + f"leg_position_ids must include {_MIN_LEGS} to {_MAX_LEGS} position IDs." + ) + if any(not leg.isdecimal() for leg in raw_legs): + raise UserInputError("leg_position_ids must be numeric position ID strings.") + legs = [str(int(leg)) for leg in raw_legs] + if len(set(legs)) != len(legs): + raise UserInputError("leg_position_ids must not contain duplicates.") + return sorted(legs, key=int) + + +def _validate_position_id(name: str, value: object) -> PositionId: + raw = str(value) + if not raw.isdecimal(): + raise UserInputError(f"{name} must be a numeric position ID string.") + return PositionId(str(int(raw))) + + +def _validate_builder_code(value: object) -> HexString: + raw = str(value) + if re.fullmatch(r"0x[0-9a-fA-F]{64}", raw) is None: + raise UserInputError("quote.builder_code must be a 32-byte hex string.") + return HexString(raw) + + +def _decimal_to_e6(name: str, value: Decimal | int | float | str) -> int: + try: + decimal = Decimal(str(value)) + except (InvalidOperation, ValueError) as error: + raise UserInputError(f"{name} must be a valid decimal.") from error + if not decimal.is_finite(): + raise UserInputError(f"{name} must be a valid decimal.") + if decimal <= 0: + raise UserInputError(f"{name} must be greater than 0.") + scaled = decimal * _E6 + if scaled != scaled.to_integral_value(): + raise UserInputError(f"{name} must have at most 6 decimal places.") + return int(scaled) + + +def _e6_to_decimal(value: object) -> Decimal: + if not isinstance(value, str) or not value.isdecimal(): + raise UnexpectedResponseError("RFQ decimal values must be unsigned base-unit strings.") + return Decimal(value) / _E6 + + +def _encode_path_segment(value: str) -> str: + return quote_path_segment(value, safe="") + + +def _to_rfq_request_rejected(error: RequestRejectedError) -> RfqRequestRejectedError: + return RfqRequestRejectedError( + str(error), + status=error.status, + code=_parse_rejection_code(error.code), + retry_after=error.retry_after, + ) + + +def _parse_rejection_code(code: str | None) -> RfqRejectionCode | str | None: + if code is None: + return None + try: + return RfqRejectionCode(code) + except ValueError: + return code + + +def _to_expired_acceptance(rfq_id: str, error: RequestRejectedError) -> ComboQuoteAcceptance | None: + if error.code != RfqErrorCode.EXPIRED_RFQ: + return None + return ComboQuoteAcceptance( + rfq_id=rfq_id, + status="failed", + reason=ComboAcceptFailureReason.ACCEPTANCE_WINDOW_EXPIRED, + error=RfqErrorDetail(code=RfqErrorCode.EXPIRED_RFQ, message=str(error)), + ) + + +def _to_acceptance( + status: RfqStatusResult, *, taker_order_hash: HexString | None +) -> ComboQuoteAcceptance: + if status.status in (RfqStatus.FAILED, RfqStatus.EXPIRED, RfqStatus.CANCELED): + return ComboQuoteAcceptance( + rfq_id=status.rfq_id, + status="failed", + reason=_to_accept_failure_reason(status), + error=status.error, + ) + return ComboQuoteAcceptance( + rfq_id=status.rfq_id, + status="executing", + taker_order_hash=taker_order_hash, + ) + + +def _to_accept_failure_reason(status: RfqStatusResult) -> ComboAcceptFailureReason: + code = status.error.code if status.error is not None else None + if status.status == RfqStatus.EXPIRED or code == RfqErrorCode.EXPIRED_RFQ: + return ComboAcceptFailureReason.ACCEPTANCE_WINDOW_EXPIRED + if code == RfqErrorCode.MAKER_DECLINED: + return ComboAcceptFailureReason.MAKER_DECLINED + return ComboAcceptFailureReason.EXECUTION_FAILED + + +def _to_fill_result(status: RfqStatusResult) -> ComboFillResult | None: + if status.status in (RfqStatus.FILLED, RfqExecutionStatus.CONFIRMED): + if status.tx_hash is None: + raise UnexpectedResponseError( + f"RFQ {status.rfq_id} reached {status.status} without a transaction hash." + ) + return ComboFillResult( + rfq_id=status.rfq_id, status=RfqStatus.FILLED, tx_hash=status.tx_hash + ) + if status.status in (RfqStatus.FAILED, RfqStatus.EXPIRED, RfqStatus.CANCELED): + return ComboFillResult( + rfq_id=status.rfq_id, + status=cast( + Literal[RfqStatus.FAILED, RfqStatus.EXPIRED, RfqStatus.CANCELED], + status.status, + ), + error=status.error, + ) + return None + + +def _parse_combo_quote_result(data: object, *, request: Mapping[str, object]) -> ComboQuoteResult: + payload = _expect_object(data) + rfq_id = _expect_str(payload, "rfq_id") + status = _parse_status(_expect_str(payload, "status")) + quote_payload = payload.get("quote") + if quote_payload is None and status not in ( + RfqStatus.FAILED, + RfqStatus.EXPIRED, + RfqStatus.CANCELED, + ): + raise UnexpectedResponseError( + f"RFQ {rfq_id} response reported status {status} without a quote." + ) + if quote_payload is not None: + if status is not RfqStatus.AWAITING_REQUESTER_ACCEPTANCE: + raise UnexpectedResponseError( + f"RFQ {rfq_id} response included a quote while reporting status {status}." + ) + request_payload = _expect_object(payload.get("request")) + quote_object = _expect_object(quote_payload) + direction = _validate_quote_response_request( + request_payload, + expected_rfq_id=rfq_id, + submitted=request, + ) + _parse_condition_id(_expect_str(request_payload, "condition_id")) + _parse_response_position_id(_expect_str(request_payload, "no_position_id")) + net_receive: Decimal | None = None + if "net_receive_e6" in quote_object: + parsed_net_receive = _e6_to_decimal(quote_object.get("net_receive_e6")) + if direction is RfqDirection.SELL: + net_receive = parsed_net_receive + elif direction is RfqDirection.SELL: + raise UnexpectedResponseError(f"SELL quote for RFQ {rfq_id} omitted net sell proceeds.") + quote = ComboQuote( + rfq_id=rfq_id, + quote_id=_expect_str(quote_object, "quote_id"), + builder_code=_parse_builder_code(_expect_str(payload, "builder_code")), + direction=direction, + position_id=_parse_response_position_id( + _expect_str(request_payload, "yes_position_id") + ), + blended_price=_e6_to_decimal(quote_object.get("blended_price_e6")), + maker_amount=_e6_to_decimal(quote_object.get("maker_amount_e6")), + taker_amount=_e6_to_decimal(quote_object.get("taker_amount_e6")), + total_required=_e6_to_decimal(quote_object.get("total_required_e6")), + net_receive=net_receive, + expires_at=_expect_int(payload, "expires_at"), + ) + return ComboQuoteResult( + rfq_id=rfq_id, + quote=quote, + ) + + reason = _parse_quote_unavailable_reason(payload) + return ComboQuoteResult(rfq_id=rfq_id, quote=None, reason=reason) + + +def _validate_quote_response_request( + payload: dict[str, object], + *, + expected_rfq_id: str, + submitted: Mapping[str, object], +) -> RfqDirection: + echoed_rfq_id = _expect_str(payload, "rfq_id") + echoed_direction = _parse_response_direction(_expect_str(payload, "direction")) + echoed_side = _parse_response_side(_expect_str(payload, "side")) + echoed_legs = _expect_position_id_list(payload, "leg_position_ids") + echoed_size = _expect_object(payload.get("requested_size")) + echoed_size_unit = _parse_response_requested_size_unit(_expect_str(echoed_size, "unit")) + echoed_size_e6 = _parse_response_base_units(_expect_str(echoed_size, "value_e6")) + + submitted_legs = cast(list[str], submitted["leg_position_ids"]) + submitted_size = cast(dict[str, object], submitted["requested_size"]) + mismatched_fields: list[str] = [] + if echoed_rfq_id != expected_rfq_id: + mismatched_fields.append("rfq_id") + if echoed_direction.value != submitted["direction"]: + mismatched_fields.append("direction") + if echoed_side.value != submitted["side"]: + mismatched_fields.append("side") + if echoed_legs != tuple(submitted_legs): + mismatched_fields.append("leg_position_ids") + if ( + echoed_size_unit.value != submitted_size["unit"] + or echoed_size_e6 != submitted_size["value_e6"] + ): + mismatched_fields.append("requested_size") + + if mismatched_fields: + raise UnexpectedResponseError( + "Builder RFQ response did not echo the submitted " + ", ".join(mismatched_fields) + "." + ) + return echoed_direction + + +def _parse_quote_unavailable_reason(payload: dict[str, object]) -> ComboQuoteUnavailableReason: + error = payload.get("error") + code = _expect_str(_expect_object(error), "code") if error is not None else None + message = _expect_str(_expect_object(error), "message") if error is not None else None + try: + if code is not None: + return ComboQuoteUnavailableReason(code) + except ValueError: + pass + raise RfqRequestRejectedError( + message or "The combo quote request failed.", + status=200, + code=_parse_rejection_code(code), + ) + + +def _parse_rfq_status(data: object, *, expected_rfq_id: str) -> RfqStatusResult: + payload = _expect_object(data) + rfq_id = _expect_matching_rfq_id(payload, expected_rfq_id=expected_rfq_id) + error_payload = payload.get("error") + error: RfqErrorDetail | None = None + if error_payload is not None: + error_object = _expect_object(error_payload) + error = RfqErrorDetail( + code=_parse_error_code(_expect_str(error_object, "code")), + message=_expect_str(error_object, "message"), + ) + tx_hash = _expect_optional_str(payload, "tx_hash") + taker_order_hash = _expect_optional_str(payload, "taker_order_hash") + return RfqStatusResult( + rfq_id=rfq_id, + status=_parse_status(_expect_str(payload, "status")), + taker_order_hash=HexString(taker_order_hash) if taker_order_hash is not None else None, + tx_hash=TransactionHash(tx_hash) if tx_hash is not None else None, + error=error, + ) + + +def _expect_matching_rfq_id(payload: dict[str, object], *, expected_rfq_id: str) -> str: + rfq_id = _expect_str(payload, "rfq_id") + if rfq_id != expected_rfq_id: + raise UnexpectedResponseError( + f"RFQ response ID {rfq_id!r} did not match requested ID {expected_rfq_id!r}." + ) + return rfq_id + + +def _expect_optional_str(payload: dict[str, object], key: str) -> str | None: + if key not in payload: + return None + return _expect_str(payload, key) + + +def _parse_status(value: str) -> RfqStatus | RfqExecutionStatus: + try: + return RfqStatus(value) + except ValueError: + pass + try: + return RfqExecutionStatus(value) + except ValueError as error: + raise UnexpectedResponseError(f"Unknown RFQ status: {value}") from error + + +def _parse_error_code(value: str) -> RfqErrorCode | str: + # Error codes evolve independently of released clients; unknown codes + # flow through as plain strings. + try: + return RfqErrorCode(value) + except ValueError: + return value + + +def _parse_response_direction(value: str) -> RfqDirection: + try: + return RfqDirection(value) + except ValueError as error: + raise UnexpectedResponseError(f"Invalid RFQ direction: {value}") from error + + +def _parse_response_side(value: str) -> RfqSide: + try: + return RfqSide(value) + except ValueError as error: + raise UnexpectedResponseError(f"Invalid RFQ side: {value}") from error + + +def _parse_response_requested_size_unit(value: str) -> RfqRequestedSizeUnit: + try: + return RfqRequestedSizeUnit(value) + except ValueError as error: + raise UnexpectedResponseError(f"Invalid RFQ requested-size unit: {value}") from error + + +def _parse_response_base_units(value: str) -> str: + if not value.isdecimal(): + raise UnexpectedResponseError(f"Invalid RFQ requested-size value: {value}") + return str(int(value)) + + +def _parse_condition_id(value: str) -> ComboConditionId: + try: + return to_combo_condition_id(value) + except TypeError as error: + raise UnexpectedResponseError(f"Invalid combo condition ID: {value}") from error + + +def _parse_response_position_id(value: str) -> PositionId: + if not value.isdecimal(): + raise UnexpectedResponseError(f"Invalid combo position ID: {value}") + return PositionId(str(int(value))) + + +def _parse_builder_code(value: str) -> HexString: + if re.fullmatch(r"0x[0-9a-fA-F]{64}", value) is None: + raise UnexpectedResponseError(f"Invalid combo builder code: {value}") + return HexString(value) + + +def _expect_position_id_list(payload: dict[str, object], key: str) -> tuple[str, ...]: + value = payload.get(key) + if not isinstance(value, list): + raise UnexpectedResponseError(f"RFQ response is missing a valid '{key}' field.") + items = cast(list[object], value) + if any(not isinstance(item, str) for item in items): + raise UnexpectedResponseError(f"RFQ response is missing a valid '{key}' field.") + position_ids = cast(list[str], items) + for position_id in position_ids: + _parse_response_position_id(position_id) + return tuple(position_ids) + + +def _expect_object(value: object) -> dict[str, object]: + if not isinstance(value, dict): + raise UnexpectedResponseError("RFQ response did not match expected shape.") + return cast(dict[str, object], value) + + +def _expect_str(payload: dict[str, object], key: str) -> str: + value = payload.get(key) + if not isinstance(value, str) or not value: + raise UnexpectedResponseError(f"RFQ response is missing a valid '{key}' field.") + return value + + +def _expect_int(payload: dict[str, object], key: str) -> int: + value = payload.get(key) + if isinstance(value, bool) or not isinstance(value, int): + raise UnexpectedResponseError(f"RFQ response is missing a valid '{key}' field.") + return value + + +__all__ = [ + "accept_combo_quote", + "accept_combo_quote_sync", + "build_combo_quote_request_body", + "fetch_rfq_status", + "fetch_rfq_status_sync", + "request_combo_quote", + "request_combo_quote_sync", + "wait_for_combo_fill", + "wait_for_combo_fill_sync", +] diff --git a/src/polymarket/_internal/context.py b/src/polymarket/_internal/context.py index 288c1fa1..88930cb9 100644 --- a/src/polymarket/_internal/context.py +++ b/src/polymarket/_internal/context.py @@ -42,6 +42,7 @@ class SyncSecureClientContext(SyncClientContext): wallet_type: WalletType relayer: SyncTransport combos: SyncTransport + builder_gateway: SyncTransport api_key: ApiKey | None rpc: SyncJsonRpcClient order_metadata: SyncOrderMetadataCache @@ -72,6 +73,7 @@ class AsyncSecureClientContext(AsyncClientContext): wallet_type: WalletType relayer: AsyncTransport combos: AsyncTransport + builder_gateway: AsyncTransport api_key: ApiKey | None rpc: JsonRpcClient order_metadata: AsyncOrderMetadataCache diff --git a/src/polymarket/clients/_transport.py b/src/polymarket/clients/_transport.py index a1d0ce80..0253733e 100644 --- a/src/polymarket/clients/_transport.py +++ b/src/polymarket/clients/_transport.py @@ -341,6 +341,7 @@ def _raise_for_response_status(response: httpx.Response) -> None: raise RequestRejectedError( _extract_response_error_message(response), status=response.status_code, + code=_extract_response_error_code(response), retry_after=_extract_retry_after(response), ) @@ -387,6 +388,18 @@ def _read_json(response: httpx.Response) -> Any: raise UnexpectedResponseError(f"Received non-JSON response from {response.url}") from error +def _extract_response_error_code(response: httpx.Response) -> str | None: + if "application/json" not in response.headers.get("content-type", "").lower(): + return None + try: + code = response.json().get("code") + except (AttributeError, ValueError): + return None + if isinstance(code, str) and code: + return code + return None + + def _extract_response_error_message(response: httpx.Response) -> str: content_type = response.headers.get("content-type", "").lower() diff --git a/src/polymarket/clients/async_secure.py b/src/polymarket/clients/async_secure.py index 01efbccd..fea0ab43 100644 --- a/src/polymarket/clients/async_secure.py +++ b/src/polymarket/clients/async_secure.py @@ -25,6 +25,7 @@ from polymarket._internal.actions import auth as _auth_actions from polymarket._internal.actions import builders as _builders_actions from polymarket._internal.actions import clob as _clob_actions +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 gamma as _gamma_actions @@ -85,7 +86,10 @@ from polymarket._internal.actions.relayer.approvals import ( resolve_missing_trading_approval_calls, ) -from polymarket._internal.actions.relayer.auth import make_relayer_header_resolver +from polymarket._internal.actions.relayer.auth import ( + build_builder_key_headers, + make_relayer_header_resolver, +) from polymarket._internal.actions.relayer.calls import ( MAX_UINT256, TransactionCall, @@ -243,6 +247,15 @@ from polymarket.models.sports_events import SportsEvent from polymarket.models.types import CtfConditionId, TokenId from polymarket.pagination import AsyncPaginator, Page +from polymarket.rfq import ( + ComboFillResult, + ComboQuote, + ComboQuoteAcceptance, + ComboQuoteResult, + RfqDirection, + RfqSide, + RfqStatusResult, +) from polymarket.streams._specs import ( CommentsSpec, CryptoPricesChainlinkTwapSpec, @@ -472,6 +485,11 @@ def _construct_for_wallet( logger=logger, header_resolver=relayer_resolver, ) + builder_gateway = AsyncTransport( + base_url=config.builder_gateway_url, + logger=logger, + header_resolver=_make_builder_gateway_header_resolver(api_key, signer, credentials), + ) secure_clob = AsyncTransport( base_url=config.clob_url, logger=logger, @@ -495,6 +513,7 @@ def _construct_for_wallet( wallet_type=wallet_type, relayer=relayer, combos=combos, + builder_gateway=builder_gateway, api_key=api_key, rpc=rpc, order_metadata=AsyncOrderMetadataCache(), @@ -930,6 +949,7 @@ async def close(self) -> None: ctx.secure_clob, ctx.relayer, ctx.combos, + ctx.builder_gateway, ctx.rpc, ) @@ -2860,6 +2880,124 @@ async def execute_collateral_return_plan( """ return await _combos_actions.execute_collateral_return_plan(self._ctx, plan=plan) + async def request_combo_quote( + self, + *, + leg_position_ids: list[str] | tuple[str, ...], + direction: RfqDirection | str, + amount: Decimal | int | float | str | None = None, + size: Decimal | int | float | str | None = None, + side: RfqSide | str = RfqSide.YES, + ) -> ComboQuoteResult: + """Request a quote for a combo of positions. + + BUY requests are sized in collateral via ``amount``; SELL requests + are sized in outcome tokens via ``size``. Amounts are human-readable: + ``1`` means one dollar or one full share, not one 6-decimal base + unit. Requires a Builder API Key passed as ``api_key=`` when + constructing the client. + + The call resolves when the quote competition window closes. A request + that attracts no usable quotes is a normal outcome, returned with + ``quote=None`` and a ``reason`` rather than raised. + + Returns: + The quote result. Pass ``result.quote`` to + :meth:`accept_combo_quote` to + execute the winning quote before ``quote.expires_at``. + + Raises: + UserInputError: If the legs, direction/sizing pair, or side are + invalid, or the client has no Builder API Key. + RfqRequestRejectedError: If the request is rejected; inspect + ``code`` to distinguish permanent input problems from + transient conditions. + """ + return await _combo_rfq_actions.request_combo_quote( + self._ctx, + leg_position_ids=leg_position_ids, + direction=direction, + amount=amount, + size=size, + side=side, + ) + + async def accept_combo_quote( + self, quote: ComboQuote | Mapping[str, object] + ) -> ComboQuoteAcceptance: + """Accept a self-contained combo quote and sign its order automatically. + + The call resolves at the maker last-look outcome. A maker declining + or the acceptance window expiring is a normal outcome returned with + ``status="failed"`` and a ``reason``. ``status="executing"`` means + the trade was handed off for onchain execution; follow it with + :meth:`wait_for_combo_fill`. + + A retry after an interrupted request or invalid response is safe: an + already-accepted RFQ reports its current status instead of executing + twice. In that case ``taker_order_hash`` is ``None`` because the + retry's order was not the one recorded. + + Quotes can be persisted with ``quote.model_dump_json()`` and restored + with ``ComboQuote.model_validate_json(...)``. A JSON-decoded mapping is + also accepted directly. The accepting client must represent the same + account and builder identity used to request the quote. Treat restored + quote fields as signing-sensitive data and do not accept values modified + by an untrusted client. + + Returns: + The acceptance outcome. + + Raises: + UserInputError: If the quote is invalid or the client has no + Builder API Key. + RfqRequestRejectedError: If the acceptance is rejected. + TimeoutError: If the outcome is still pending after the wait + window; resume with :meth:`fetch_rfq_status`. + """ + return await _combo_rfq_actions.accept_combo_quote(self._ctx, quote) + + async def wait_for_combo_fill( + self, + *, + rfq_id: str, + timeout: float = 30.0, + polling_interval: float = 1.0, + ) -> ComboFillResult: + """Wait for an accepted RFQ to reach a terminal state. + + Polls the RFQ status until it is filled (or confirmed onchain), + failed, expired, or canceled. Terminal failure is a normal outcome + and is returned, not raised. + + Returns: + The terminal state. ``tx_hash`` is set when the RFQ filled. + + Raises: + TimeoutError: If the RFQ stays non-terminal past ``timeout`` + seconds. This does not mean the trade failed; resume with + :meth:`fetch_rfq_status`. + RfqRequestRejectedError: If the RFQ is unknown or not accepted. + """ + return await _combo_rfq_actions.wait_for_combo_fill( + self._ctx, rfq_id=rfq_id, timeout=timeout, polling_interval=polling_interval + ) + + async def fetch_rfq_status(self, *, rfq_id: str) -> RfqStatusResult: + """Fetch the status of an accepted RFQ. + + Status is available once an acceptance has been recorded; earlier + reads are rejected. + + Returns: + The RFQ status. Onchain execution progress is merged into + ``status``. + + Raises: + RfqRequestRejectedError: If the RFQ is unknown or not accepted. + """ + return await _combo_rfq_actions.fetch_rfq_status(self._ctx, rfq_id=rfq_id) + async def _resolve_market_position_context( self, *, @@ -3358,6 +3496,24 @@ async def _credentials_are_active( return credentials.key in keys +def _make_builder_gateway_header_resolver( + api_key: ApiKey | None, signer: LocalAccount, credentials: ApiKeyCreds +) -> _L2HeaderResolver: + l2_resolver = _make_l2_header_resolver(signer, credentials) + + async def resolver(method: str, path: str, body: str | None) -> Mapping[str, str]: + headers = dict(await l2_resolver(method, path, body)) + # Status reads authenticate with account headers only; builder + # headers are required on the mutating requests. + if method != "GET" and isinstance(api_key, BuilderApiKey): + headers.update( + build_builder_key_headers(creds=api_key, method=method, path=path, body=body) + ) + return headers + + return resolver + + def _make_l2_header_resolver(signer: LocalAccount, credentials: ApiKeyCreds) -> _L2HeaderResolver: async def resolver(method: str, path: str, body: str | None) -> Mapping[str, str]: timestamp = int(time.time()) diff --git a/src/polymarket/clients/secure.py b/src/polymarket/clients/secure.py index 35520e20..3c51e321 100644 --- a/src/polymarket/clients/secure.py +++ b/src/polymarket/clients/secure.py @@ -15,6 +15,7 @@ from polymarket._internal.actions import auth as _auth_actions from polymarket._internal.actions import builders as _builders_actions from polymarket._internal.actions import clob as _clob_actions +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 gamma as _gamma_actions @@ -72,7 +73,10 @@ from polymarket._internal.actions.relayer.approvals import ( resolve_missing_trading_approval_calls_sync, ) -from polymarket._internal.actions.relayer.auth import make_relayer_header_resolver_sync +from polymarket._internal.actions.relayer.auth import ( + build_builder_key_headers, + make_relayer_header_resolver_sync, +) from polymarket._internal.actions.relayer.calls import ( MAX_UINT256, TransactionCall, @@ -200,6 +204,15 @@ ) from polymarket.models.types import CtfConditionId, TokenId from polymarket.pagination import Page, Paginator +from polymarket.rfq import ( + ComboFillResult, + ComboQuote, + ComboQuoteAcceptance, + ComboQuoteResult, + RfqDirection, + RfqSide, + RfqStatusResult, +) from polymarket.transactions import ( MergePositionRequest, SyncDeprecatedTransactionHandle, @@ -391,6 +404,13 @@ def _construct_for_wallet( logger=logger, header_resolver=relayer_resolver, ) + builder_gateway = SyncTransport( + base_url=config.builder_gateway_url, + logger=logger, + header_resolver=_make_builder_gateway_header_resolver_sync( + api_key, signer, credentials + ), + ) try: secure_clob = SyncTransport( base_url=config.clob_url, @@ -406,6 +426,7 @@ def _construct_for_wallet( clob.close() relayer.close() combos.close() + builder_gateway.close() raise ctx = SyncSecureClientContext( @@ -422,6 +443,7 @@ def _construct_for_wallet( wallet_type=wallet_type, relayer=relayer, combos=combos, + builder_gateway=builder_gateway, api_key=api_key, rpc=rpc, order_metadata=SyncOrderMetadataCache(), @@ -488,7 +510,10 @@ def close(self) -> None: try: ctx.combos.close() finally: - ctx.rpc.close() + try: + ctx.builder_gateway.close() + finally: + ctx.rpc.close() def _user_or_wallet(self, user: str | None) -> str: return self._ctx.wallet if user is None else user @@ -2572,6 +2597,122 @@ def execute_collateral_return_plan( """ return _combos_actions.execute_collateral_return_plan_sync(self._ctx, plan=plan) + def request_combo_quote( + self, + *, + leg_position_ids: list[str] | tuple[str, ...], + direction: RfqDirection | str, + amount: Decimal | int | float | str | None = None, + size: Decimal | int | float | str | None = None, + side: RfqSide | str = RfqSide.YES, + ) -> ComboQuoteResult: + """Request a quote for a combo of positions. + + BUY requests are sized in collateral via ``amount``; SELL requests + are sized in outcome tokens via ``size``. Amounts are human-readable: + ``1`` means one dollar or one full share, not one 6-decimal base + unit. Requires a Builder API Key passed as ``api_key=`` when + constructing the client. + + The call resolves when the quote competition window closes. A request + that attracts no usable quotes is a normal outcome, returned with + ``quote=None`` and a ``reason`` rather than raised. + + Returns: + The quote result. Pass ``result.quote`` to + :meth:`accept_combo_quote` to + execute the winning quote before ``quote.expires_at``. + + Raises: + UserInputError: If the legs, direction/sizing pair, or side are + invalid, or the client has no Builder API Key. + RfqRequestRejectedError: If the request is rejected; inspect + ``code`` to distinguish permanent input problems from + transient conditions. + """ + return _combo_rfq_actions.request_combo_quote_sync( + self._ctx, + leg_position_ids=leg_position_ids, + direction=direction, + amount=amount, + size=size, + side=side, + ) + + def accept_combo_quote(self, quote: ComboQuote | Mapping[str, object]) -> ComboQuoteAcceptance: + """Accept a self-contained combo quote and sign its order automatically. + + The call resolves at the maker last-look outcome. A maker declining + or the acceptance window expiring is a normal outcome returned with + ``status="failed"`` and a ``reason``. ``status="executing"`` means + the trade was handed off for onchain execution; follow it with + :meth:`wait_for_combo_fill`. + + A retry after an interrupted request or invalid response is safe: an + already-accepted RFQ reports its current status instead of executing + twice. In that case ``taker_order_hash`` is ``None`` because the + retry's order was not the one recorded. + + Quotes can be persisted with ``quote.model_dump_json()`` and restored + with ``ComboQuote.model_validate_json(...)``. A JSON-decoded mapping is + also accepted directly. The accepting client must represent the same + account and builder identity used to request the quote. Treat restored + quote fields as signing-sensitive data and do not accept values modified + by an untrusted client. + + Returns: + The acceptance outcome. + + Raises: + UserInputError: If the quote is invalid or the client has no + Builder API Key. + RfqRequestRejectedError: If the acceptance is rejected. + TimeoutError: If the outcome is still pending after the wait + window; resume with :meth:`fetch_rfq_status`. + """ + return _combo_rfq_actions.accept_combo_quote_sync(self._ctx, quote) + + def wait_for_combo_fill( + self, + *, + rfq_id: str, + timeout: float = 30.0, + polling_interval: float = 1.0, + ) -> ComboFillResult: + """Wait for an accepted RFQ to reach a terminal state. + + Polls the RFQ status until it is filled (or confirmed onchain), + failed, expired, or canceled. Terminal failure is a normal outcome + and is returned, not raised. + + Returns: + The terminal state. ``tx_hash`` is set when the RFQ filled. + + Raises: + TimeoutError: If the RFQ stays non-terminal past ``timeout`` + seconds. This does not mean the trade failed; resume with + :meth:`fetch_rfq_status`. + RfqRequestRejectedError: If the RFQ is unknown or not accepted. + """ + return _combo_rfq_actions.wait_for_combo_fill_sync( + self._ctx, rfq_id=rfq_id, timeout=timeout, polling_interval=polling_interval + ) + + def fetch_rfq_status(self, *, rfq_id: str) -> RfqStatusResult: + """Fetch the status of an accepted RFQ. + + Status is available once an acceptance has been recorded; earlier + reads are rejected. + + Returns: + The RFQ status. Onchain execution progress is merged into + ``status``. + + Raises: + RfqRequestRejectedError: If the RFQ is unknown or not accepted. + """ + return _combo_rfq_actions.fetch_rfq_status_sync(self._ctx, rfq_id=rfq_id) + def _broadcast_eoa_call(self, call: TransactionCall) -> SyncEoaTransactionHandle: return broadcast_eoa_call_sync( rpc=self._ctx.rpc, @@ -2765,6 +2906,24 @@ def _credentials_are_active_sync( return credentials.key in keys +def _make_builder_gateway_header_resolver_sync( + api_key: ApiKey | None, signer: LocalAccount, credentials: ApiKeyCreds +) -> SyncHeaderResolver: + l2_resolver = _make_l2_header_resolver_sync(signer, credentials) + + def resolver(method: str, path: str, body: str | None) -> Mapping[str, str]: + headers = dict(l2_resolver(method, path, body)) + # Status reads authenticate with account headers only; builder + # headers are required on the mutating requests. + if method != "GET" and isinstance(api_key, BuilderApiKey): + headers.update( + build_builder_key_headers(creds=api_key, method=method, path=path, body=body) + ) + return headers + + return resolver + + def _make_l2_header_resolver_sync( signer: LocalAccount, credentials: ApiKeyCreds ) -> SyncHeaderResolver: diff --git a/src/polymarket/environments.py b/src/polymarket/environments.py index 29d7ae89..b0307b90 100644 --- a/src/polymarket/environments.py +++ b/src/polymarket/environments.py @@ -44,6 +44,7 @@ class _EnvironmentConfig: position_manager: str = "0x006F54F7f9A22e0000CC2AB60031000000ae9fEF" rfq_quoter_ws_url: str = "wss://combos-rfq-gateway-quoter.polymarket.com/ws/rfq" rfq_quoter_ws_headers: dict[str, str] | None = None + builder_gateway_url: str = "https://combos-rfq-gateway-builder.polymarket.com" collateral_return_url: str = "https://combos-rfq-collateral-return.polymarket.com" perps_url: str = "https://api.perpetuals.polymarket.com" perps_ws_url: str = "wss://ws.perpetuals.polymarket.com/v1/ws" diff --git a/src/polymarket/errors.py b/src/polymarket/errors.py index b45de11c..742fb8dd 100644 --- a/src/polymarket/errors.py +++ b/src/polymarket/errors.py @@ -34,14 +34,25 @@ def __init__(self, message: str, *, code: int, reason: str) -> None: class RequestRejectedError(PolymarketError): """Error raised when a request receives a non-success status. + ``code`` is the machine-readable error code from the response body; + ``None`` when the response does not provide one. + ``retry_after`` is the server-suggested delay in seconds before retrying, taken from the ``Retry-After`` response header or a ``retry_after_seconds`` field in the response body; ``None`` when the response provides neither. """ - def __init__(self, message: str, *, status: int, retry_after: float | None = None) -> None: + def __init__( + self, + message: str, + *, + status: int, + code: str | None = None, + retry_after: float | None = None, + ) -> None: super().__init__(message) self.status = status + self.code = code self.retry_after = retry_after diff --git a/src/polymarket/rfq.py b/src/polymarket/rfq.py index 43590056..d62d37a6 100644 --- a/src/polymarket/rfq.py +++ b/src/polymarket/rfq.py @@ -5,11 +5,14 @@ from decimal import Decimal from enum import StrEnum from types import TracebackType -from typing import Any, Literal, Protocol, TypeAlias, runtime_checkable +from typing import Any, Literal, Protocol, Self, TypeAlias, runtime_checkable -from polymarket.errors import PolymarketError +from pydantic import model_validator + +from polymarket.errors import PolymarketError, RequestRejectedError +from polymarket.models.base import BaseModel from polymarket.models.types import ComboConditionId, PositionId -from polymarket.types import EvmAddress, TransactionHash +from polymarket.types import EvmAddress, HexString, TransactionHash RfqId: TypeAlias = str RfqQuoteId: TypeAlias = str @@ -48,6 +51,67 @@ class RfqExecutionStatus(StrEnum): FAILED = "FAILED" +class RfqStatus(StrEnum): + """Lifecycle status of an RFQ.""" + + AWAITING_REQUESTER_ACCEPTANCE = "AWAITING_REQUESTER_ACCEPTANCE" + AWAITING_MAKER_CONFIRMATION = "AWAITING_MAKER_CONFIRMATION" + EXECUTING = "EXECUTING" + FILLED = "FILLED" + FAILED = "FAILED" + EXPIRED = "EXPIRED" + CANCELED = "CANCELED" + + +class RfqRejectionCode(StrEnum): + """Known reasons an RFQ request or acceptance is rejected. + + The rejection vocabulary evolves independently of released clients; codes + not yet enumerated here are carried on ``RfqRequestRejectedError.code`` as + plain strings. + """ + + INVALID_JSON = "INVALID_JSON" + INVALID_MESSAGE = "INVALID_MESSAGE" + INVALID_ROLE = "INVALID_ROLE" + UNAUTHORIZED_ROLE = "UNAUTHORIZED_ROLE" + UNAUTHENTICATED = "UNAUTHENTICATED" + ADDRESS_MISMATCH = "ADDRESS_MISMATCH" + INVALID_RFQ = "INVALID_RFQ" + CONTRADICTORY_LEGS = "CONTRADICTORY_LEGS" + LEG_METADATA_UNAVAILABLE = "LEG_METADATA_UNAVAILABLE" + INVALID_ACCEPTANCE = "INVALID_ACCEPTANCE" + INVALID_QUOTE = "INVALID_QUOTE" + INVALID_SIGNATURE = "INVALID_SIGNATURE" + INVALID_IDENTITY = "INVALID_IDENTITY" + UNKNOWN_RFQ = "UNKNOWN_RFQ" + EXPIRED_RFQ = "EXPIRED_RFQ" + INVALID_RFQ_STATE = "INVALID_RFQ_STATE" + QUOTE_MISMATCH = "QUOTE_MISMATCH" + SUBMISSION_WINDOW_CLOSED = "SUBMISSION_WINDOW_CLOSED" + SERVICE_UNAVAILABLE = "SERVICE_UNAVAILABLE" + PRE_EXECUTION_BALANCE_RESERVATION_FAILED = "PRE_EXECUTION_BALANCE_RESERVATION_FAILED" + BALANCE_VALIDATION_FAILED = "BALANCE_VALIDATION_FAILED" + ALLOWANCE_VALIDATION_FAILED = "ALLOWANCE_VALIDATION_FAILED" + TRADE_SUBMISSION_FAILED = "TRADE_SUBMISSION_FAILED" + REQUEST_FAILED = "REQUEST_FAILED" + + +class ComboQuoteUnavailableReason(StrEnum): + """Reason no quote was returned for a combo quote request.""" + + NO_QUOTES = "NO_QUOTES" + SIZE_TOO_LARGE = "SIZE_TOO_LARGE" + + +class ComboAcceptFailureReason(StrEnum): + """Reason an accepted combo quote did not proceed to a fill.""" + + MAKER_DECLINED = "MAKER_DECLINED" + ACCEPTANCE_WINDOW_EXPIRED = "ACCEPTANCE_WINDOW_EXPIRED" + EXECUTION_FAILED = "EXECUTION_FAILED" + + class RfqErrorCode(StrEnum): """Known RFQ error codes. @@ -75,6 +139,7 @@ class RfqErrorCode(StrEnum): INTERNAL_ERROR = "INTERNAL_ERROR" LEG_METADATA_UNAVAILABLE = "LEG_METADATA_UNAVAILABLE" MAKER_ALREADY_RESPONDED = "MAKER_ALREADY_RESPONDED" + MAKER_DECLINED = "MAKER_DECLINED" MAKER_NOT_REQUIRED = "MAKER_NOT_REQUIRED" MAKER_QUOTE_LIMITED = "MAKER_QUOTE_LIMITED" MISSING_MAKER_ADDRESS_IN_QUOTE = "MISSING_MAKER_ADDRESS_IN_QUOTE" @@ -89,6 +154,7 @@ class RfqErrorCode(StrEnum): MISSING_TAKER_AMOUNT_IN_SIGNED_ORDER = "MISSING_TAKER_AMOUNT_IN_SIGNED_ORDER" MISSING_TIMESTAMP_IN_SIGNED_ORDER = "MISSING_TIMESTAMP_IN_SIGNED_ORDER" MISSING_TOKEN_ID_IN_SIGNED_ORDER = "MISSING_TOKEN_ID_IN_SIGNED_ORDER" + NO_QUOTES = "NO_QUOTES" ORDER_SIDE_OR_TOKEN_DOES_NOT_MATCH_REQUEST = "ORDER_SIDE_OR_TOKEN_DOES_NOT_MATCH_REQUEST" PRE_EXECUTION_BALANCE_RESERVATION_FAILED = "PRE_EXECUTION_BALANCE_RESERVATION_FAILED" PRICE_E6_NOT_POSITIVE = "PRICE_E6_NOT_POSITIVE" @@ -109,6 +175,7 @@ class RfqErrorCode(StrEnum): SIGNED_ORDER_SIZE_DOES_NOT_COVER_QUOTE = "SIGNED_ORDER_SIZE_DOES_NOT_COVER_QUOTE" SIGNED_ORDER_TAKER_AMOUNT_NOT_POSITIVE = "SIGNED_ORDER_TAKER_AMOUNT_NOT_POSITIVE" SIZE_E6_NOT_POSITIVE = "SIZE_E6_NOT_POSITIVE" + SIZE_TOO_LARGE = "SIZE_TOO_LARGE" SUBMISSION_WINDOW_CLOSED = "SUBMISSION_WINDOW_CLOSED" TRADE_SUBMISSION_FAILED = "TRADE_SUBMISSION_FAILED" UNAUTHENTICATED = "UNAUTHENTICATED" @@ -140,6 +207,114 @@ class RfqConfirmationAck: quote_id: RfqQuoteId +@dataclass(frozen=True, slots=True, kw_only=True) +class RfqErrorDetail: + """Structured error reported for an RFQ.""" + + code: RfqErrorCode | str + message: str + + +class ComboQuote(BaseModel): + """A self-contained winning combo quote. + + ``maker_amount`` and ``taker_amount`` are the amounts of the acceptance + order: for a BUY, collateral spent and outcome tokens received; for a + SELL, outcome tokens sold and the gross collateral limit. ``net_receive`` + is the exact post-fee collateral proceeds for a SELL and is ``None`` for a + BUY. ``total_required`` is the total collateral (BUY) or position-share + (SELL) balance required to accept. ``expires_at`` is the acceptance + deadline in Unix milliseconds. + + The model contains every input needed for acceptance. It can be persisted + with :meth:`model_dump_json` and restored with + :meth:`model_validate_json` before being passed to ``accept_combo_quote``. + """ + + rfq_id: RfqId + quote_id: RfqQuoteId + builder_code: HexString + direction: RfqDirection + position_id: PositionId + blended_price: Decimal + maker_amount: Decimal + taker_amount: Decimal + total_required: Decimal + net_receive: Decimal | None = None + expires_at: int + + @model_validator(mode="after") + def _require_sell_net_receive(self) -> Self: + if self.direction is RfqDirection.SELL and self.net_receive is None: + raise ValueError("net_receive is required for SELL combo quotes") + return self + + +@dataclass(frozen=True, slots=True, kw_only=True) +class ComboQuoteResult: + """Outcome of a combo quote request. + + ``quote`` is ``None`` when the request attracted no usable quotes; then + ``reason`` explains why. A winning ``quote`` is self-contained and can be + accepted by another client instance representing the same account and + builder identity. + """ + + rfq_id: RfqId + quote: ComboQuote | None + reason: ComboQuoteUnavailableReason | None = None + + +@dataclass(frozen=True, slots=True, kw_only=True) +class ComboQuoteAcceptance: + """Outcome of accepting a combo quote. + + ``executing`` means the trade was handed off for onchain execution; + follow it with ``wait_for_combo_fill``. A maker declining or the + acceptance window expiring is a normal outcome reported as ``failed`` + with a ``reason``. + + ``taker_order_hash`` identifies the recorded acceptance order. It is + ``None`` when a retry attached to an acceptance recorded by an earlier + attempt; the retried order was not the one recorded. + """ + + rfq_id: RfqId + status: Literal["executing", "failed"] + taker_order_hash: HexString | None = None + reason: ComboAcceptFailureReason | None = None + error: RfqErrorDetail | None = None + + +@dataclass(frozen=True, slots=True, kw_only=True) +class RfqStatusResult: + """Status of an accepted RFQ. + + Onchain execution progress is merged into ``status``: execution statuses + surface alongside the RFQ lifecycle values. + """ + + rfq_id: RfqId + status: RfqStatus | RfqExecutionStatus + taker_order_hash: HexString | None = None + tx_hash: TransactionHash | None = None + error: RfqErrorDetail | None = None + + +@dataclass(frozen=True, slots=True, kw_only=True) +class ComboFillResult: + """Terminal state of an accepted RFQ. + + ``tx_hash`` is set when the RFQ filled. Terminal failure is a normal + outcome reported through ``status`` and ``error``, not raised. + """ + + rfq_id: RfqId + status: Literal[RfqStatus.FILLED, RfqStatus.FAILED, RfqStatus.EXPIRED, RfqStatus.CANCELED] + tx_hash: TransactionHash | None = None + error: RfqErrorDetail | None = None + + @dataclass(frozen=True, slots=True, kw_only=True) class RfqExecutionUpdateEvent: type: Literal["execution_update"] @@ -222,6 +397,26 @@ async def decline(self) -> RfqConfirmationAck: ) +class RfqRequestRejectedError(RequestRejectedError): + """Error raised when an RFQ request or acceptance is rejected. + + ``code`` distinguishes permanent input problems (``INVALID_RFQ``, + ``CONTRADICTORY_LEGS``) from transient conditions + (``LEG_METADATA_UNAVAILABLE``) that may be retried. Codes not enumerated + in ``RfqRejectionCode`` are carried as plain strings. + """ + + def __init__( + self, + message: str, + *, + status: int, + code: RfqRejectionCode | str | None = None, + retry_after: float | None = None, + ) -> None: + super().__init__(message, status=status, code=code, retry_after=retry_after) + + class RfqQuoteRejectedError(PolymarketError): def __init__( self, @@ -302,6 +497,12 @@ async def __aexit__( __all__ = [ + "ComboAcceptFailureReason", + "ComboFillResult", + "ComboQuote", + "ComboQuoteAcceptance", + "ComboQuoteResult", + "ComboQuoteUnavailableReason", "RfqCancelQuoteAck", "RfqCancelQuoteRejectedError", "RfqConfirmationAck", @@ -310,6 +511,7 @@ async def __aexit__( "RfqConfirmationRequestEvent", "RfqDirection", "RfqErrorCode", + "RfqErrorDetail", "RfqEvent", "RfqExecutionStatus", "RfqExecutionUpdateEvent", @@ -319,10 +521,14 @@ async def __aexit__( "RfqQuoteRejectedError", "RfqQuoteRequestEvent", "RfqQuoteSource", + "RfqRejectionCode", + "RfqRequestRejectedError", "RfqRequestedSize", "RfqRequestedSizeUnit", "RfqRequestorPublicId", "RfqSession", "RfqSide", + "RfqStatus", + "RfqStatusResult", "RfqTradeEvent", ] diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 5b7a3e2f..eb9f9433 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -49,6 +49,16 @@ def get(name: str) -> str: return get +@pytest.fixture +def combo_leg_position_ids() -> list[str] | None: + _load_dotenv() + value = os.environ.get("POLYMARKET_COMBO_LEG_POSITION_IDS") + if value is None: + return None + legs = [leg.strip() for leg in value.split(",") if leg.strip()] + return legs if len(legs) >= 2 else None + + @pytest.fixture def builder_code(require_env: Callable[[str], str]) -> str: return require_env("POLYMARKET_BUILDER_CODE") diff --git a/tests/integration/test_combo_rfq_live.py b/tests/integration/test_combo_rfq_live.py new file mode 100644 index 00000000..fafcd9ee --- /dev/null +++ b/tests/integration/test_combo_rfq_live.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from decimal import Decimal + +import pytest + +from polymarket import ( + AsyncSecureClient, + BuilderApiKey, + ComboAcceptFailureReason, + ComboMarket, + RfqDirection, + RfqRequestRejectedError, + RfqStatus, +) + +pytestmark = [pytest.mark.integration, pytest.mark.anyio] + + +@pytest.fixture +async def builder_client( + deposit_wallet_private_key: str, + deposit_wallet_address: str, + builder_api_key: BuilderApiKey, +): + client = await AsyncSecureClient.create( + private_key=deposit_wallet_private_key, + wallet=deposit_wallet_address, + api_key=builder_api_key, + ) + try: + yield client + finally: + await client.close() + + +async def test_fetch_rfq_status_rejects_unknown_rfq( + builder_client: AsyncSecureClient, +) -> None: + with pytest.raises(RfqRequestRejectedError): + await builder_client.fetch_rfq_status(rfq_id="rfq-00000000-0000-0000-0000-000000000000") + + +# Combo-enabled markets churn as games resolve, so fixed legs go stale. Pick +# two unrelated, liquid, mid-priced markets from the live catalog unless the +# operator provides an explicit override. +async def _discover_combo_leg_position_ids(client: AsyncSecureClient) -> list[str] | None: + page = await client.list_combo_markets(page_size=100).first_page() + picked: list[ComboMarket] = [] + for market in page.items: + price = market.outcomes.yes.price + if price < Decimal("0.05") or price > Decimal("0.95") or market.volume <= 0: + continue + if any( + market.slug.startswith(other.slug) or other.slug.startswith(market.slug) + for other in picked + ): + continue + picked.append(market) + if len(picked) == 2: + return [str(item.outcomes.yes.position_id) for item in picked] + return None + + +# Metered: creates a live combo RFQ, but does not accept it or execute an order. +@pytest.mark.metered +async def test_combo_sell_quote_returns_exact_net_proceeds( + builder_client: AsyncSecureClient, + combo_leg_position_ids: list[str] | None, +) -> None: + legs = combo_leg_position_ids or await _discover_combo_leg_position_ids(builder_client) + if legs is None: + pytest.skip( + "No combo legs discoverable; set POLYMARKET_COMBO_LEG_POSITION_IDS to override." + ) + + result = await builder_client.request_combo_quote( + leg_position_ids=legs, direction="SELL", size=1 + ) + + if result.quote is None: + pytest.skip(f"No SELL combo quote available: {result.reason}") + + assert result.quote.direction is RfqDirection.SELL + assert result.quote.net_receive is not None + assert result.quote.net_receive > 0 + + +# Metered: an accepted combo quote executes a live trade with real funds. +@pytest.mark.metered +async def test_combo_quote_request_accept_and_fill( + builder_client: AsyncSecureClient, + combo_leg_position_ids: list[str] | None, +) -> None: + legs = combo_leg_position_ids or await _discover_combo_leg_position_ids(builder_client) + if legs is None: + pytest.skip( + "No combo legs discoverable; set POLYMARKET_COMBO_LEG_POSITION_IDS to override." + ) + + result = await builder_client.request_combo_quote( + leg_position_ids=legs, direction="BUY", amount=1 + ) + + if result.quote is None: + pytest.skip(f"No combo quote available: {result.reason}") + + acceptance = await builder_client.accept_combo_quote(result.quote) + + if acceptance.status == "failed": + if acceptance.reason is ComboAcceptFailureReason.EXECUTION_FAILED: + pytest.fail( + f"Acceptance of RFQ {acceptance.rfq_id} failed to execute: {acceptance.error}" + ) + pytest.skip(f"Combo acceptance did not execute: {acceptance.reason}") + + fill = await builder_client.wait_for_combo_fill(rfq_id=acceptance.rfq_id, timeout=120.0) + + assert fill.rfq_id == acceptance.rfq_id + if fill.status is not RfqStatus.FILLED: + pytest.fail( + f"RFQ {acceptance.rfq_id} was handed off for execution but ended " + f"{fill.status}: {fill.error}" + ) + assert fill.tx_hash is not None diff --git a/tests/unit/test_combo_rfq.py b/tests/unit/test_combo_rfq.py new file mode 100644 index 00000000..3e7ed6ae --- /dev/null +++ b/tests/unit/test_combo_rfq.py @@ -0,0 +1,356 @@ +# pyright: reportPrivateUsage=false +from __future__ import annotations + +import asyncio +import copy +import dataclasses +import json +from collections.abc import Callable +from decimal import Decimal +from typing import Any + +import httpx +import pytest +from _relayer_helpers import BUILDER_AUTH, FAKE_CREDS, PK_DEPLOY_WALLET, make_eoa_client + +from polymarket import ( + AsyncSecureClient, + ComboAcceptFailureReason, + ComboQuote, + ComboQuoteUnavailableReason, + RfqDirection, + RfqStatus, + SecureClient, +) +from polymarket.clients._transport import AsyncTransport, SyncTransport +from polymarket.errors import TimeoutError as SdkTimeoutError +from polymarket.errors import UnexpectedResponseError +from polymarket.models.types import PositionId +from polymarket.types import HexString + +BUILDER_CODE = "0x" + "ab" * 32 +LEGS = ["123", "456"] +CONDITION_ID = "0x03" + "0" * 60 + +QUOTE_READY: dict[str, Any] = { + "rfq_id": "rfq-1", + "status": "AWAITING_REQUESTER_ACCEPTANCE", + "expires_at": 1_773_890_765_500, + "builder_code": BUILDER_CODE, + "request": { + "rfq_id": "rfq-1", + "leg_position_ids": LEGS, + "condition_id": CONDITION_ID, + "yes_position_id": "789", + "no_position_id": "790", + "direction": "BUY", + "side": "YES", + "requested_size": {"unit": "notional", "value_e6": "100000000"}, + "created_at": 1_773_890_758_000, + }, + "quote": { + "quote_id": "quote-1", + "blended_price_e6": "450000", + "maker_amount_e6": "966191", + "taker_amount_e6": "1932381", + "total_required_e6": "1000000", + "net_receive_e6": "1932381", + }, +} + +QUOTE = ComboQuote( + rfq_id="rfq-1", + quote_id="quote-1", + builder_code=HexString(BUILDER_CODE), + direction=RfqDirection.BUY, + position_id=PositionId("789"), + blended_price=Decimal("0.45"), + maker_amount=Decimal("0.966191"), + taker_amount=Decimal("1.932381"), + total_required=Decimal("1"), + expires_at=1_773_890_765_500, +) + + +def install_builder_gateway_handler( + client: AsyncSecureClient, + handler: Callable[[httpx.Request], httpx.Response], +) -> None: + transport = AsyncTransport( + base_url="https://builder-gateway.test", + client=httpx.AsyncClient( + base_url="https://builder-gateway.test", transport=httpx.MockTransport(handler) + ), + header_resolver=client._ctx.builder_gateway._header_resolver, + ) + client._ctx = dataclasses.replace(client._ctx, builder_gateway=transport) + + +def make_sync_eoa_client() -> SecureClient: + from eth_account import Account + + signer = Account.from_key(PK_DEPLOY_WALLET) + return SecureClient._create( + private_key=PK_DEPLOY_WALLET, + wallet=signer.address, + credentials=FAKE_CREDS, + api_key=BUILDER_AUTH, + validate_credentials=False, + ) + + +def install_sync_builder_gateway_handler( + client: SecureClient, + handler: Callable[[httpx.Request], httpx.Response], +) -> None: + transport = SyncTransport( + base_url="https://builder-gateway.test", + client=httpx.Client( + base_url="https://builder-gateway.test", transport=httpx.MockTransport(handler) + ), + header_resolver=client._ctx.builder_gateway._header_resolver, + ) + client._ctx = dataclasses.replace(client._ctx, builder_gateway=transport) + + +def json_handler(*responses: httpx.Response) -> Callable[[httpx.Request], httpx.Response]: + queue = list(responses) + + def handler(request: httpx.Request) -> httpx.Response: + if not queue: + raise AssertionError("Unexpected builder gateway request") + return queue.pop(0) + + return handler + + +def accept_retry_handler( + failure: str, captured: list[httpx.Request] +) -> Callable[[httpx.Request], httpx.Response]: + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + if len(captured) == 1: + if failure == "transport": + raise httpx.ReadTimeout("connection dropped", request=request) + return httpx.Response( + 200, + json={"rfq_id": "rfq-1", "status": "UNKNOWN"}, + request=request, + ) + return httpx.Response( + 200, + json={"rfq_id": "rfq-1", "status": "EXECUTING"}, + request=request, + ) + + return handler + + +def test_request_combo_quote_handles_sell_net_proceeds() -> None: + response = copy.deepcopy(QUOTE_READY) + response["request"]["direction"] = "SELL" + response["request"]["requested_size"] = {"unit": "shares", "value_e6": "2500000"} + response["quote"].update( + { + "maker_amount_e6": "2500000", + "taker_amount_e6": "1125000", + "total_required_e6": "2500000", + "net_receive_e6": "1090000", + } + ) + missing = copy.deepcopy(response) + del missing["quote"]["net_receive_e6"] + client = make_sync_eoa_client() + install_sync_builder_gateway_handler( + client, + json_handler( + httpx.Response(200, json=response), + httpx.Response(200, json=missing), + ), + ) + + result = client.request_combo_quote(leg_position_ids=LEGS, direction="SELL", size="2.5") + assert result.quote is not None + assert result.quote.direction is RfqDirection.SELL + assert result.quote.net_receive == Decimal("1.09") + + with pytest.raises(UnexpectedResponseError, match="omitted net sell proceeds"): + client.request_combo_quote(leg_position_ids=LEGS, direction="SELL", size="2.5") + + +def test_request_combo_quote_returns_no_quote_outcome() -> None: + client = make_sync_eoa_client() + install_sync_builder_gateway_handler( + client, + json_handler( + httpx.Response( + 200, + json={ + "rfq_id": "rfq-2", + "status": "FAILED", + "error": {"code": "NO_QUOTES", "message": "no quotes"}, + }, + ) + ), + ) + + result = client.request_combo_quote(leg_position_ids=LEGS, direction="BUY", amount=100) + + assert result.rfq_id == "rfq-2" + assert result.quote is None + assert result.reason is ComboQuoteUnavailableReason.NO_QUOTES + + +def test_request_combo_quote_canonicalizes_numeric_leg_ids() -> None: + captured: list[httpx.Request] = [] + response = copy.deepcopy(QUOTE_READY) + response["request"]["leg_position_ids"] = ["2", "10"] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response(200, json=response, request=request) + + client = make_sync_eoa_client() + install_sync_builder_gateway_handler(client, handler) + + client.request_combo_quote(leg_position_ids=["0010", "02"], direction="BUY", amount=100) + + body = json.loads(captured[0].content) + assert body["leg_position_ids"] == ["2", "10"] + + +@pytest.mark.parametrize( + ("status_code", "payload", "reason"), + [ + ( + 200, + { + "rfq_id": "rfq-1", + "status": "FAILED", + "error": {"code": "MAKER_DECLINED", "message": "maker declined"}, + }, + ComboAcceptFailureReason.MAKER_DECLINED, + ), + ( + 409, + {"error": "expired rfq", "code": "EXPIRED_RFQ"}, + ComboAcceptFailureReason.ACCEPTANCE_WINDOW_EXPIRED, + ), + ], +) +def test_accept_combo_quote_maps_failure_outcomes( + status_code: int, + payload: dict[str, object], + reason: ComboAcceptFailureReason, +) -> None: + client = make_sync_eoa_client() + install_sync_builder_gateway_handler( + client, json_handler(httpx.Response(status_code, json=payload)) + ) + + acceptance = client.accept_combo_quote(QUOTE) + + assert acceptance.status == "failed" + assert acceptance.reason is reason + + +@pytest.mark.parametrize("failure", ["transport", "unexpected_response"]) +def test_accept_combo_quote_retries_ambiguous_async_failure(failure: str) -> None: + async def run() -> None: + captured: list[httpx.Request] = [] + client = await make_eoa_client() + install_builder_gateway_handler(client, accept_retry_handler(failure, captured)) + + acceptance = await client.accept_combo_quote(QUOTE) + + assert len(captured) == 2 + assert captured[0].content == captured[1].content + assert acceptance.status == "executing" + + asyncio.run(run()) + + +@pytest.mark.parametrize("failure", ["transport", "unexpected_response"]) +def test_accept_combo_quote_retries_ambiguous_sync_failure(failure: str) -> None: + captured: list[httpx.Request] = [] + client = make_sync_eoa_client() + install_sync_builder_gateway_handler(client, accept_retry_handler(failure, captured)) + + acceptance = client.accept_combo_quote(QUOTE) + + assert len(captured) == 2 + assert captured[0].content == captured[1].content + assert acceptance.status == "executing" + + +def test_wait_for_combo_fill_returns_terminal_failure() -> None: + client = make_sync_eoa_client() + install_sync_builder_gateway_handler( + client, + json_handler( + httpx.Response( + 200, + json={ + "rfq_id": "rfq-1", + "status": "FAILED", + "error": { + "code": "TRADE_SUBMISSION_FAILED", + "message": "trade submission failed", + }, + }, + ) + ), + ) + + fill = client.wait_for_combo_fill(rfq_id="rfq-1") + + assert fill.status is RfqStatus.FAILED + assert fill.error is not None + assert fill.error.code == "TRADE_SUBMISSION_FAILED" + + +def test_wait_for_combo_fill_times_out_while_non_terminal() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={"rfq_id": "rfq-1", "status": "EXECUTING"}, + request=request, + ) + + client = make_sync_eoa_client() + install_sync_builder_gateway_handler(client, handler) + + with pytest.raises(SdkTimeoutError): + client.wait_for_combo_fill(rfq_id="rfq-1", timeout=0.01, polling_interval=0.001) + + +def test_fetch_rfq_status_rejects_mismatched_response_id() -> None: + client = make_sync_eoa_client() + install_sync_builder_gateway_handler( + client, + json_handler(httpx.Response(200, json={"rfq_id": "rfq-2", "status": "EXECUTING"})), + ) + + with pytest.raises(UnexpectedResponseError, match="did not match requested ID"): + client.fetch_rfq_status(rfq_id="rfq-1") + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("rfq_id", "rfq-2"), + ("direction", "SELL"), + ("side", "NO"), + ("leg_position_ids", list(reversed(LEGS))), + ("requested_size", {"unit": "notional", "value_e6": "99999999"}), + ("requested_size", {"unit": "shares", "value_e6": "100000000"}), + ], +) +def test_request_combo_quote_rejects_mismatched_request_echo(field: str, value: object) -> None: + response = copy.deepcopy(QUOTE_READY) + response["request"][field] = value + client = make_sync_eoa_client() + install_sync_builder_gateway_handler(client, json_handler(httpx.Response(200, json=response))) + + with pytest.raises(UnexpectedResponseError, match=field): + client.request_combo_quote(leg_position_ids=LEGS, direction="BUY", amount=100)