From d69ddaabfa23c07b95461b2e9137299618636424 Mon Sep 17 00:00:00 2001 From: kartojal Date: Wed, 29 Jul 2026 20:23:54 +0200 Subject: [PATCH 1/5] feat: add requester-side combo RFQ support over the builder gateway --- .env.example | 2 + src/polymarket/__init__.py | 22 + src/polymarket/_internal/actions/combo_rfq.py | 644 ++++++++++++++++++ src/polymarket/_internal/context.py | 2 + src/polymarket/clients/_transport.py | 13 + src/polymarket/clients/async_secure.py | 147 +++- src/polymarket/clients/secure.py | 154 ++++- src/polymarket/environments.py | 1 + src/polymarket/errors.py | 13 +- src/polymarket/rfq.py | 176 ++++- tests/integration/test_combo_rfq_live.py | 71 ++ tests/unit/test_combo_rfq.py | 492 +++++++++++++ 12 files changed, 1732 insertions(+), 5 deletions(-) create mode 100644 src/polymarket/_internal/actions/combo_rfq.py create mode 100644 tests/integration/test_combo_rfq_live.py create mode 100644 tests/unit/test_combo_rfq.py 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 f0c81cd2..5a4c70bb 100644 --- a/src/polymarket/__init__.py +++ b/src/polymarket/__init__.py @@ -171,6 +171,12 @@ ) from polymarket.pagination import AsyncPaginator, Page, Paginator from polymarket.rfq import ( + ComboAcceptFailureReason, + ComboFillResult, + ComboQuote, + ComboQuoteAcceptance, + ComboQuoteResult, + ComboQuoteUnavailableReason, RfqCancelQuoteAck, RfqCancelQuoteRejectedError, RfqConfirmationAck, @@ -179,6 +185,7 @@ RfqConfirmationRequestEvent, RfqDirection, RfqErrorCode, + RfqErrorDetail, RfqEvent, RfqExecutionStatus, RfqExecutionUpdateEvent, @@ -188,11 +195,15 @@ RfqQuoteRejectedError, RfqQuoteRequestEvent, RfqQuoteSource, + RfqRejectionCode, RfqRequestedSize, RfqRequestedSizeUnit, RfqRequestorPublicId, + RfqRequestRejectedError, RfqSession, RfqSide, + RfqStatus, + RfqStatusResult, RfqTradeEvent, ) from polymarket.transactions import ( @@ -239,17 +250,23 @@ "ConnectionLostError", "ClobTrade", "ClosedPosition", + "ComboAcceptFailureReason", "ComboActivity", "ComboActivityId", "ComboActivityType", "ComboCompressActivity", "ComboConvertActivity", + "ComboFillResult", "ComboPosition", "Comment", "ComboPositionLeg", "ComboPositionMarket", "ComboPositionMarketEvent", "ComboPositionOutcome", + "ComboQuote", + "ComboQuoteAcceptance", + "ComboQuoteResult", + "ComboQuoteUnavailableReason", "ComboPositionStatus", "ComboRedeemActivity", "ComboSplitActivity", @@ -369,6 +386,7 @@ "RfqConfirmationRequestEvent", "RfqDirection", "RfqErrorCode", + "RfqErrorDetail", "RfqEvent", "RfqExecutionStatus", "RfqExecutionUpdateEvent", @@ -378,11 +396,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..f11f4305 --- /dev/null +++ b/src/polymarket/_internal/actions/combo_rfq.py @@ -0,0 +1,644 @@ +"""Requester-side combo RFQ actions over the builder gateway.""" + +from __future__ import annotations + +import asyncio +import secrets +import time +from decimal import Decimal, InvalidOperation +from typing import Literal, cast +from urllib.parse import quote as quote_path_segment + +import httpx + +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, + 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, + 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: + parsed_direction, 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, direction=parsed_direction) + + +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: + parsed_direction, 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, direction=parsed_direction) + + +async def accept_combo_quote( + ctx: AsyncSecureClientContext, quote: ComboQuoteResult +) -> ComboQuoteAcceptance: + _require_builder_api_key(ctx) + body = _build_accept_request_body(ctx, quote) + try: + data = await ctx.builder_gateway.post_json( + f"{_REQUESTS_PATH}/{_encode_path_segment(quote.rfq_id)}/accept", + json=body, + timeout=_HELD_REQUEST_TIMEOUT, + ) + 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 + + status = _parse_rfq_status(data) + # 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: ComboQuoteResult +) -> ComboQuoteAcceptance: + _require_builder_api_key(ctx) + body = _build_accept_request_body(ctx, quote) + try: + data = ctx.builder_gateway.post_json( + f"{_REQUESTS_PATH}/{_encode_path_segment(quote.rfq_id)}/accept", + json=body, + timeout=_HELD_REQUEST_TIMEOUT, + ) + 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 + + status = _parse_rfq_status(data) + # 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) + + +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) + + +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: ComboQuoteResult) -> dict[str, object]: + if quote.quote is None: + raise UserInputError("Cannot accept a combo quote result without a quote.") + if quote.position_id is None or quote.builder_code is None: + raise UserInputError( + "Cannot accept a combo quote result without its position and builder attribution." + ) + _require_rfq_id(quote.rfq_id) + + signed_order = _sign_acceptance_order( + ctx, + direction=quote.direction, + position_id=quote.position_id, + builder_code=quote.builder_code, + maker_amount_e6=_decimal_to_e6("quote.maker_amount", quote.quote.maker_amount), + taker_amount_e6=_decimal_to_e6("quote.taker_amount", quote.quote.taker_amount), + ) + return { + "quote_id": quote.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.chain_id, + exchange_address=EvmAddress(ctx.environment.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 _validate_wait_params(*, timeout: float, polling_interval: float) -> None: + if timeout <= 0: + raise UserInputError("timeout must be greater than 0.") + if polling_interval <= 0: + raise UserInputError("polling_interval must be 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]: + legs = [str(leg) for leg in leg_position_ids] + if len(legs) < _MIN_LEGS or len(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 legs): + raise UserInputError("leg_position_ids must be numeric position ID strings.") + if len(set(legs)) != len(legs): + raise UserInputError("leg_position_ids must not contain duplicates.") + return legs + + +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 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) + ) + + +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, *, direction: RfqDirection) -> 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: + request_payload = _expect_object(payload.get("request")) + quote_object = _expect_object(quote_payload) + quote = ComboQuote( + quote_id=_expect_str(quote_object, "quote_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")), + expires_at=_expect_int(payload, "expires_at"), + ) + return ComboQuoteResult( + rfq_id=rfq_id, + direction=direction, + quote=quote, + position_id=PositionId(_expect_str(request_payload, "yes_position_id")), + condition_id=_parse_condition_id(_expect_str(request_payload, "condition_id")), + builder_code=HexString(_expect_str(payload, "builder_code")), + ) + + reason = _parse_quote_unavailable_reason(payload) + return ComboQuoteResult(rfq_id=rfq_id, direction=direction, quote=None, reason=reason) + + +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) -> RfqStatusResult: + payload = _expect_object(data) + 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 = payload.get("tx_hash") + taker_order_hash = payload.get("taker_order_hash") + return RfqStatusResult( + rfq_id=_expect_str(payload, "rfq_id"), + status=_parse_status(_expect_str(payload, "status")), + taker_order_hash=HexString(taker_order_hash) if isinstance(taker_order_hash, str) else None, + tx_hash=TransactionHash(tx_hash) if isinstance(tx_hash, str) else None, + error=error, + ) + + +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_condition_id(value: str) -> ComboConditionId: + try: + return to_combo_condition_id(value) + except ValueError as error: + raise UnexpectedResponseError(f"Invalid combo condition ID: {value}") from error + + +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 27de2747..fa70fae1 100644 --- a/src/polymarket/_internal/context.py +++ b/src/polymarket/_internal/context.py @@ -31,6 +31,7 @@ class SyncSecureClientContext(SyncClientContext): wallet_type: WalletType relayer: SyncTransport combos: SyncTransport + builder_gateway: SyncTransport api_key: ApiKey | None rpc: SyncJsonRpcClient @@ -54,6 +55,7 @@ class AsyncSecureClientContext(AsyncClientContext): wallet_type: WalletType relayer: AsyncTransport combos: AsyncTransport + builder_gateway: AsyncTransport api_key: ApiKey | None rpc: JsonRpcClient diff --git a/src/polymarket/clients/_transport.py b/src/polymarket/clients/_transport.py index 6b8be2f9..d08d7e9c 100644 --- a/src/polymarket/clients/_transport.py +++ b/src/polymarket/clients/_transport.py @@ -330,6 +330,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), ) @@ -376,6 +377,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 f7e110e0..6b5abca1 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 @@ -84,7 +85,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, @@ -241,6 +245,14 @@ 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, + ComboQuoteAcceptance, + ComboQuoteResult, + RfqDirection, + RfqSide, + RfqStatusResult, +) from polymarket.streams._specs import ( CommentsSpec, CryptoPricesChainlinkTwapSpec, @@ -467,6 +479,11 @@ def _construct_for_wallet( logger=logger, header_resolver=relayer_resolver, ) + builder_gateway = AsyncTransport( + base_url=environment.builder_gateway_url, + logger=logger, + header_resolver=_make_builder_gateway_header_resolver(api_key, signer, credentials), + ) secure_clob = AsyncTransport( base_url=environment.clob_url, logger=logger, @@ -489,6 +506,7 @@ def _construct_for_wallet( wallet_type=wallet_type, relayer=relayer, combos=combos, + builder_gateway=builder_gateway, api_key=api_key, rpc=rpc, ) @@ -920,6 +938,7 @@ async def close(self) -> None: ctx.secure_clob, ctx.relayer, ctx.combos, + ctx.builder_gateway, ctx.rpc, ) @@ -2843,6 +2862,114 @@ 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 it 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: ComboQuoteResult) -> ComboQuoteAcceptance: + """Accept a combo quote, signing the acceptance 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 a dropped connection 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. + + Returns: + The acceptance outcome. + + Raises: + UserInputError: If the quote result carries no quote 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, *, @@ -3337,6 +3464,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 22cd20fc..c25ce240 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 @@ -71,7 +72,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, @@ -198,6 +202,14 @@ ) from polymarket.models.types import CtfConditionId, TokenId from polymarket.pagination import Page, Paginator +from polymarket.rfq import ( + ComboFillResult, + ComboQuoteAcceptance, + ComboQuoteResult, + RfqDirection, + RfqSide, + RfqStatusResult, +) from polymarket.transactions import ( MergePositionRequest, SyncDeprecatedTransactionHandle, @@ -386,6 +398,13 @@ def _construct_for_wallet( logger=logger, header_resolver=relayer_resolver, ) + builder_gateway = SyncTransport( + base_url=environment.builder_gateway_url, + logger=logger, + header_resolver=_make_builder_gateway_header_resolver_sync( + api_key, signer, credentials + ), + ) try: secure_clob = SyncTransport( base_url=environment.clob_url, @@ -401,6 +420,7 @@ def _construct_for_wallet( clob.close() relayer.close() combos.close() + builder_gateway.close() raise ctx = SyncSecureClientContext( @@ -416,6 +436,7 @@ def _construct_for_wallet( wallet_type=wallet_type, relayer=relayer, combos=combos, + builder_gateway=builder_gateway, api_key=api_key, rpc=rpc, ) @@ -481,7 +502,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 @@ -2557,6 +2581,114 @@ 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 it 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: ComboQuoteResult) -> ComboQuoteAcceptance: + """Accept a combo quote, signing the acceptance 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 a dropped connection 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. + + Returns: + The acceptance outcome. + + Raises: + UserInputError: If the quote result carries no quote 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: env = self._ctx.environment return broadcast_eoa_call_sync( @@ -2747,6 +2879,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 c9cd8736..59a6bfe4 100644 --- a/src/polymarket/environments.py +++ b/src/polymarket/environments.py @@ -45,6 +45,7 @@ class Environment: 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 c6c2d2bf..ec159724 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..9bb9576b 100644 --- a/src/polymarket/rfq.py +++ b/src/polymarket/rfq.py @@ -9,7 +9,7 @@ from polymarket.errors import PolymarketError 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 +48,49 @@ 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_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" + + +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 +118,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 +133,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 +154,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 +186,102 @@ 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 + + +@dataclass(frozen=True, slots=True, kw_only=True) +class ComboQuote: + """The winning quote for a combo quote request. + + ``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 collateral received. ``total_required`` is + the total collateral (BUY) or position-share (SELL) balance required to + accept. ``expires_at`` is the acceptance deadline in Unix milliseconds. + """ + + quote_id: RfqQuoteId + blended_price: Decimal + maker_amount: Decimal + taker_amount: Decimal + total_required: Decimal + expires_at: int + + +@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. When a quote is present, ``position_id``, + ``condition_id``, and ``builder_code`` carry the combo position and + builder attribution the acceptance order trades with. + """ + + rfq_id: RfqId + direction: RfqDirection + quote: ComboQuote | None + reason: ComboQuoteUnavailableReason | None = None + position_id: PositionId | None = None + condition_id: ComboConditionId | None = None + builder_code: HexString | 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 +364,27 @@ async def decline(self) -> RfqConfirmationAck: ) +class RfqRequestRejectedError(PolymarketError): + """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, + ) -> None: + super().__init__(message) + self.status = status + self.code = code + + class RfqQuoteRejectedError(PolymarketError): def __init__( self, @@ -302,6 +465,12 @@ async def __aexit__( __all__ = [ + "ComboAcceptFailureReason", + "ComboFillResult", + "ComboQuote", + "ComboQuoteAcceptance", + "ComboQuoteResult", + "ComboQuoteUnavailableReason", "RfqCancelQuoteAck", "RfqCancelQuoteRejectedError", "RfqConfirmationAck", @@ -310,6 +479,7 @@ async def __aexit__( "RfqConfirmationRequestEvent", "RfqDirection", "RfqErrorCode", + "RfqErrorDetail", "RfqEvent", "RfqExecutionStatus", "RfqExecutionUpdateEvent", @@ -319,10 +489,14 @@ async def __aexit__( "RfqQuoteRejectedError", "RfqQuoteRequestEvent", "RfqQuoteSource", + "RfqRejectionCode", + "RfqRequestRejectedError", "RfqRequestedSize", "RfqRequestedSizeUnit", "RfqRequestorPublicId", "RfqSession", "RfqSide", + "RfqStatus", + "RfqStatusResult", "RfqTradeEvent", ] diff --git a/tests/integration/test_combo_rfq_live.py b/tests/integration/test_combo_rfq_live.py new file mode 100644 index 00000000..7a6d1b43 --- /dev/null +++ b/tests/integration/test_combo_rfq_live.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from collections.abc import Callable + +import pytest + +from polymarket import ( + AsyncSecureClient, + BuilderApiKey, + 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") + + +# 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, + require_env: Callable[[str], str], +) -> None: + legs = [ + leg.strip() + for leg in require_env("POLYMARKET_COMBO_LEG_POSITION_IDS").split(",") + if leg.strip() + ] + if len(legs) < 2: + pytest.skip("POLYMARKET_COMBO_LEG_POSITION_IDS must list at least 2 position IDs") + + 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) + + if acceptance.status == "failed": + 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 RfqStatus.FILLED: + 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..0ade2005 --- /dev/null +++ b/tests/unit/test_combo_rfq.py @@ -0,0 +1,492 @@ +# pyright: reportPrivateUsage=false +from __future__ import annotations + +import asyncio +import dataclasses +import json +from collections.abc import Callable +from decimal import Decimal + +import httpx +import pytest +from _relayer_helpers import ( + BUILDER_AUTH, + FAKE_CREDS, + PK_DEPLOY_WALLET, + make_eoa_client, +) + +from polymarket import ( + AsyncSecureClient, + ComboAcceptFailureReason, + ComboQuote, + ComboQuoteResult, + ComboQuoteUnavailableReason, + RfqDirection, + RfqRejectionCode, + RfqRequestRejectedError, + RfqStatus, + SecureClient, + UserInputError, +) +from polymarket.clients._transport import AsyncTransport, SyncTransport +from polymarket.errors import TimeoutError as SdkTimeoutError +from polymarket.models.types import PositionId, to_combo_condition_id +from polymarket.types import HexString + +BUILDER_CODE = "0x" + "ab" * 32 +TX_HASH = "0x" + "cd" * 32 +TAKER_ORDER_HASH = "0x" + "ef" * 32 +LEGS = ["123", "456"] +CONDITION_ID = "0x03" + "0" * 60 + +QUOTE_READY = { + "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", + "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", + }, +} + +QUOTE_RESULT = ComboQuoteResult( + rfq_id="rfq-1", + direction=RfqDirection.BUY, + quote=ComboQuote( + quote_id="quote-1", + 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, + ), + position_id=PositionId("789"), + condition_id=to_combo_condition_id(CONDITION_ID), + builder_code=HexString(BUILDER_CODE), +) + + +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(*, with_api_key: bool = True) -> 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 if with_api_key else None, + 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 test_request_combo_quote_builds_buy_request_and_parses_quote() -> None: + async def run() -> None: + captured: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response(200, json=QUOTE_READY, request=request) + + client = await make_eoa_client() + install_builder_gateway_handler(client, handler) + + result = await client.request_combo_quote( + leg_position_ids=LEGS, direction="BUY", amount=100 + ) + + request = captured[0] + assert request.url.path == "/v1/builder/rfq/requests" + body = json.loads(request.content.decode("utf-8")) + assert body["direction"] == "BUY" + assert body["side"] == "YES" + assert body["leg_position_ids"] == LEGS + assert body["requested_size"] == {"unit": "notional", "value_e6": "100000000"} + assert body["signature_type"] == 0 + assert body["signer_address"] == body["maker_address"] + assert request.headers["POLY_API_KEY"] == FAKE_CREDS.key + assert request.headers["POLY_BUILDER_API_KEY"] == BUILDER_AUTH.key + + assert result.rfq_id == "rfq-1" + assert result.direction is RfqDirection.BUY + assert result.position_id == "789" + assert result.condition_id == CONDITION_ID + assert result.builder_code == BUILDER_CODE + assert result.quote is not None + assert result.quote.blended_price == Decimal("0.45") + assert result.quote.maker_amount == Decimal("0.966191") + assert result.quote.taker_amount == Decimal("1.932381") + assert result.quote.total_required == Decimal("1") + assert result.quote.expires_at == 1_773_890_765_500 + + asyncio.run(run()) + + +def test_request_combo_quote_sell_is_sized_in_shares() -> None: + async def run() -> None: + captured: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response(200, json=QUOTE_READY, request=request) + + client = await make_eoa_client() + install_builder_gateway_handler(client, handler) + + await client.request_combo_quote(leg_position_ids=LEGS, direction="SELL", size="2.5") + + body = json.loads(captured[0].content.decode("utf-8")) + assert body["direction"] == "SELL" + assert body["requested_size"] == {"unit": "shares", "value_e6": "2500000"} + + asyncio.run(run()) + + +def test_request_combo_quote_returns_no_quote_outcome() -> None: + async def run() -> None: + client = await make_eoa_client() + install_builder_gateway_handler( + client, + json_handler( + httpx.Response( + 200, + json={ + "rfq_id": "rfq-2", + "status": "FAILED", + "builder_code": BUILDER_CODE, + "error": {"code": "NO_QUOTES", "message": "no quotes"}, + }, + ) + ), + ) + + result = await client.request_combo_quote( + leg_position_ids=LEGS, direction="BUY", amount=100 + ) + + assert result.quote is None + assert result.reason is ComboQuoteUnavailableReason.NO_QUOTES + assert result.rfq_id == "rfq-2" + + asyncio.run(run()) + + +def test_request_combo_quote_validates_input_before_sending() -> None: + client = make_sync_eoa_client() + + def unexpected(request: httpx.Request) -> httpx.Response: + raise AssertionError("No request expected") + + install_sync_builder_gateway_handler(client, unexpected) + + invalid_calls = [ + {"leg_position_ids": ["123"], "direction": "BUY", "amount": 100}, + {"leg_position_ids": ["123", "123"], "direction": "BUY", "amount": 100}, + {"leg_position_ids": ["123", "0x2"], "direction": "BUY", "amount": 100}, + {"leg_position_ids": LEGS, "direction": "BUY", "amount": "0.0000001"}, + {"leg_position_ids": LEGS, "direction": "BUY", "amount": 0}, + {"leg_position_ids": LEGS, "direction": "BUY", "size": 1}, + {"leg_position_ids": LEGS, "direction": "SELL", "amount": 1}, + {"leg_position_ids": LEGS, "direction": "SELL", "size": -1}, + {"leg_position_ids": LEGS, "direction": "HOLD", "amount": 1}, + {"leg_position_ids": LEGS, "direction": "BUY", "amount": 1, "side": "NO"}, + ] + + for kwargs in invalid_calls: + with pytest.raises(UserInputError): + client.request_combo_quote(**kwargs) # type: ignore[arg-type] + + +def test_request_combo_quote_requires_builder_api_key() -> None: + async def run() -> None: + client = await make_eoa_client(with_api_key=False) + + with pytest.raises(UserInputError, match="Builder API Key"): + await client.request_combo_quote(leg_position_ids=LEGS, direction="BUY", amount=100) + + asyncio.run(run()) + + +def test_request_combo_quote_classifies_rejections() -> None: + async def run() -> None: + client = await make_eoa_client() + install_builder_gateway_handler( + client, + json_handler( + httpx.Response( + 400, json={"error": "contradictory legs", "code": "CONTRADICTORY_LEGS"} + ), + httpx.Response(400, json={"error": "something new", "code": "SOMETHING_NEW"}), + ), + ) + + with pytest.raises(RfqRequestRejectedError) as known: + await client.request_combo_quote(leg_position_ids=LEGS, direction="BUY", amount=100) + assert known.value.code is RfqRejectionCode.CONTRADICTORY_LEGS + assert known.value.status == 400 + + with pytest.raises(RfqRequestRejectedError) as unknown: + await client.request_combo_quote(leg_position_ids=LEGS, direction="BUY", amount=100) + assert unknown.value.code == "SOMETHING_NEW" + + asyncio.run(run()) + + +def test_accept_combo_quote_signs_and_submits_the_acceptance_order() -> None: + async def run() -> None: + captured: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response( + 200, + json={ + "rfq_id": "rfq-1", + "status": "EXECUTING", + "taker_order_hash": TAKER_ORDER_HASH, + }, + request=request, + ) + + client = await make_eoa_client() + install_builder_gateway_handler(client, handler) + + acceptance = await client.accept_combo_quote(QUOTE_RESULT) + + request = captured[0] + assert request.url.path == "/v1/builder/rfq/requests/rfq-1/accept" + assert request.headers["POLY_BUILDER_API_KEY"] == BUILDER_AUTH.key + body = json.loads(request.content.decode("utf-8")) + assert body["quote_id"] == "quote-1" + order = body["signed_order"] + assert order["builder"] == BUILDER_CODE + assert order["tokenId"] == "789" + assert order["side"] == 0 + assert order["signatureType"] == 0 + assert order["makerAmount"] == "966191" + assert order["takerAmount"] == "1932381" + assert order["maker"] == order["signer"] + assert order["metadata"] == "0x" + "0" * 64 + assert order["signature"].startswith("0x") + + assert acceptance.status == "executing" + assert acceptance.taker_order_hash == TAKER_ORDER_HASH + + asyncio.run(run()) + + +def test_accept_combo_quote_polls_until_the_outcome_lands() -> None: + async def run() -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST": + return httpx.Response( + 200, + json={ + "rfq_id": "rfq-1", + "status": "AWAITING_MAKER_CONFIRMATION", + "taker_order_hash": TAKER_ORDER_HASH, + }, + request=request, + ) + assert "POLY_BUILDER_API_KEY" not in request.headers + assert request.headers["POLY_API_KEY"] == FAKE_CREDS.key + return httpx.Response( + 200, json={"rfq_id": "rfq-1", "status": "EXECUTING"}, request=request + ) + + client = await make_eoa_client() + install_builder_gateway_handler(client, handler) + + acceptance = await client.accept_combo_quote(QUOTE_RESULT) + + assert acceptance.status == "executing" + assert acceptance.taker_order_hash == TAKER_ORDER_HASH + + asyncio.run(run()) + + +def test_accept_combo_quote_reports_maker_decline_as_failed() -> None: + async def run() -> None: + client = await make_eoa_client() + install_builder_gateway_handler( + client, + json_handler( + httpx.Response( + 200, + json={ + "rfq_id": "rfq-1", + "status": "FAILED", + "taker_order_hash": TAKER_ORDER_HASH, + "error": {"code": "MAKER_DECLINED", "message": "maker declined"}, + }, + ) + ), + ) + + acceptance = await client.accept_combo_quote(QUOTE_RESULT) + + assert acceptance.status == "failed" + assert acceptance.reason is ComboAcceptFailureReason.MAKER_DECLINED + assert acceptance.error is not None + assert acceptance.error.message == "maker declined" + + asyncio.run(run()) + + +def test_accept_combo_quote_reports_expired_window_as_failed() -> None: + async def run() -> None: + client = await make_eoa_client() + install_builder_gateway_handler( + client, + json_handler(httpx.Response(409, json={"error": "expired rfq", "code": "EXPIRED_RFQ"})), + ) + + acceptance = await client.accept_combo_quote(QUOTE_RESULT) + + assert acceptance.status == "failed" + assert acceptance.reason is ComboAcceptFailureReason.ACCEPTANCE_WINDOW_EXPIRED + + asyncio.run(run()) + + +def test_accept_combo_quote_rejects_result_without_quote() -> None: + async def run() -> None: + client = await make_eoa_client() + + with pytest.raises(UserInputError, match="without a quote"): + await client.accept_combo_quote( + ComboQuoteResult( + rfq_id="rfq-2", + direction=RfqDirection.BUY, + quote=None, + reason=ComboQuoteUnavailableReason.NO_QUOTES, + ) + ) + + asyncio.run(run()) + + +def test_wait_for_combo_fill_normalizes_confirmed_to_filled() -> None: + client = make_sync_eoa_client() + install_sync_builder_gateway_handler( + client, + json_handler( + httpx.Response(200, json={"rfq_id": "rfq-1", "status": "EXECUTING"}), + httpx.Response(200, json={"rfq_id": "rfq-1", "status": "MINED", "tx_hash": TX_HASH}), + httpx.Response( + 200, json={"rfq_id": "rfq-1", "status": "CONFIRMED", "tx_hash": TX_HASH} + ), + ), + ) + + fill = client.wait_for_combo_fill(rfq_id="rfq-1", polling_interval=0.001) + + assert fill.status is RfqStatus.FILLED + assert fill.tx_hash == TX_HASH + + +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.tx_hash is None + 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_maps_rejections() -> None: + client = make_sync_eoa_client() + install_sync_builder_gateway_handler( + client, + json_handler( + httpx.Response(409, json={"error": "rfq not accepted", "code": "RFQ_NOT_ACCEPTED"}) + ), + ) + + with pytest.raises(RfqRequestRejectedError) as rejected: + client.fetch_rfq_status(rfq_id="rfq-1") + + assert rejected.value.status == 409 + assert rejected.value.code == "RFQ_NOT_ACCEPTED" From b8d2f736b78f5abee19225f1aacd1fc3e62f6a06 Mon Sep 17 00:00:00 2001 From: kartojal Date: Wed, 29 Jul 2026 20:35:20 +0200 Subject: [PATCH 2/5] fix: address combo RFQ review findings --- src/polymarket/_internal/actions/combo_rfq.py | 4 +- tests/unit/test_combo_rfq.py | 52 ++++++++++++++++++- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/polymarket/_internal/actions/combo_rfq.py b/src/polymarket/_internal/actions/combo_rfq.py index f11f4305..39fd6c13 100644 --- a/src/polymarket/_internal/actions/combo_rfq.py +++ b/src/polymarket/_internal/actions/combo_rfq.py @@ -419,6 +419,8 @@ def _decimal_to_e6(name: str, value: Decimal | int | float | str) -> int: 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 @@ -607,7 +609,7 @@ def _parse_error_code(value: str) -> RfqErrorCode | str: def _parse_condition_id(value: str) -> ComboConditionId: try: return to_combo_condition_id(value) - except ValueError as error: + except TypeError as error: raise UnexpectedResponseError(f"Invalid combo condition ID: {value}") from error diff --git a/tests/unit/test_combo_rfq.py b/tests/unit/test_combo_rfq.py index 0ade2005..30c3d2f3 100644 --- a/tests/unit/test_combo_rfq.py +++ b/tests/unit/test_combo_rfq.py @@ -2,10 +2,12 @@ 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 @@ -40,7 +42,7 @@ LEGS = ["123", "456"] CONDITION_ID = "0x03" + "0" * 60 -QUOTE_READY = { +QUOTE_READY: dict[str, Any] = { "rfq_id": "rfq-1", "status": "AWAITING_REQUESTER_ACCEPTANCE", "expires_at": 1_773_890_765_500, @@ -243,6 +245,8 @@ def unexpected(request: httpx.Request) -> httpx.Response: {"leg_position_ids": LEGS, "direction": "SELL", "size": -1}, {"leg_position_ids": LEGS, "direction": "HOLD", "amount": 1}, {"leg_position_ids": LEGS, "direction": "BUY", "amount": 1, "side": "NO"}, + {"leg_position_ids": LEGS, "direction": "BUY", "amount": float("nan")}, + {"leg_position_ids": LEGS, "direction": "BUY", "amount": float("inf")}, ] for kwargs in invalid_calls: @@ -490,3 +494,49 @@ def test_fetch_rfq_status_maps_rejections() -> None: assert rejected.value.status == 409 assert rejected.value.code == "RFQ_NOT_ACCEPTED" + + +def test_sync_client_requests_and_accepts_a_combo_quote() -> None: + captured: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + if request.url.path.endswith("/accept"): + return httpx.Response( + 200, + json={ + "rfq_id": "rfq-1", + "status": "EXECUTING", + "taker_order_hash": TAKER_ORDER_HASH, + }, + request=request, + ) + return httpx.Response(200, json=QUOTE_READY, request=request) + + client = make_sync_eoa_client() + install_sync_builder_gateway_handler(client, handler) + + result = client.request_combo_quote(leg_position_ids=LEGS, direction="BUY", amount=100) + assert result.quote is not None + + acceptance = client.accept_combo_quote(result) + + accept_request = captured[1] + assert accept_request.url.path == "/v1/builder/rfq/requests/rfq-1/accept" + body = json.loads(accept_request.content.decode("utf-8")) + assert body["quote_id"] == "quote-1" + assert body["signed_order"]["makerAmount"] == "966191" + assert acceptance.status == "executing" + assert acceptance.taker_order_hash == TAKER_ORDER_HASH + + +def test_request_combo_quote_rejects_malformed_condition_id() -> None: + from polymarket.errors import UnexpectedResponseError + + malformed = copy.deepcopy(QUOTE_READY) + malformed["request"]["condition_id"] = "0x04" + "0" * 60 + client = make_sync_eoa_client() + install_sync_builder_gateway_handler(client, json_handler(httpx.Response(200, json=malformed))) + + with pytest.raises(UnexpectedResponseError): + client.request_combo_quote(leg_position_ids=LEGS, direction="BUY", amount=100) From a5b5c757a777fd0e8dc3ed65f353f94f9c569a90 Mon Sep 17 00:00:00 2001 From: kartojal Date: Mon, 10 Aug 2026 17:31:32 +0200 Subject: [PATCH 3/5] fix: align combo RFQ requester behavior --- src/polymarket/_internal/actions/combo_rfq.py | 179 ++++++++---- src/polymarket/clients/async_secure.py | 21 +- src/polymarket/clients/secure.py | 19 +- src/polymarket/rfq.py | 51 +++- tests/integration/test_combo_rfq_live.py | 61 +++- tests/unit/test_combo_rfq.py | 269 +++++++++++++++--- 6 files changed, 481 insertions(+), 119 deletions(-) diff --git a/src/polymarket/_internal/actions/combo_rfq.py b/src/polymarket/_internal/actions/combo_rfq.py index 39fd6c13..44fd35a2 100644 --- a/src/polymarket/_internal/actions/combo_rfq.py +++ b/src/polymarket/_internal/actions/combo_rfq.py @@ -3,13 +3,17 @@ 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, @@ -23,6 +27,7 @@ RequestRejectedError, SigningError, TimeoutError, + TransportError, UnexpectedResponseError, UserInputError, ) @@ -124,23 +129,30 @@ def request_combo_quote_sync( async def accept_combo_quote( - ctx: AsyncSecureClientContext, quote: ComboQuoteResult + 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: - data = await ctx.builder_gateway.post_json( - f"{_REQUESTS_PATH}/{_encode_path_segment(quote.rfq_id)}/accept", - json=body, - timeout=_HELD_REQUEST_TIMEOUT, - ) + try: + data = await ctx.builder_gateway.post_json( + path, json=body, timeout=_HELD_REQUEST_TIMEOUT + ) + except TransportError: + # Acceptance is idempotent server-side. Retry the same signed order + # once when the connection drops during the maker last-look hold. + data = await ctx.builder_gateway.post_json( + path, json=body, timeout=_HELD_REQUEST_TIMEOUT + ) 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 - status = _parse_rfq_status(data) + status = _parse_rfq_status(data, expected_rfq_id=quote.rfq_id) # 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 @@ -156,23 +168,26 @@ async def accept_combo_quote( def accept_combo_quote_sync( - ctx: SyncSecureClientContext, quote: ComboQuoteResult + 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: - data = ctx.builder_gateway.post_json( - f"{_REQUESTS_PATH}/{_encode_path_segment(quote.rfq_id)}/accept", - json=body, - timeout=_HELD_REQUEST_TIMEOUT, - ) + try: + data = ctx.builder_gateway.post_json(path, json=body, timeout=_HELD_REQUEST_TIMEOUT) + except TransportError: + # Acceptance is idempotent server-side. Retry the same signed order + # once when the connection drops during the maker last-look hold. + data = ctx.builder_gateway.post_json(path, json=body, timeout=_HELD_REQUEST_TIMEOUT) 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 - status = _parse_rfq_status(data) + status = _parse_rfq_status(data, expected_rfq_id=quote.rfq_id) # 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 @@ -237,7 +252,7 @@ async def fetch_rfq_status(ctx: AsyncSecureClientContext, *, rfq_id: str) -> Rfq ) except RequestRejectedError as error: raise _to_rfq_request_rejected(error) from error - return _parse_rfq_status(data) + return _parse_rfq_status(data, expected_rfq_id=rfq_id) def fetch_rfq_status_sync(ctx: SyncSecureClientContext, *, rfq_id: str) -> RfqStatusResult: @@ -246,7 +261,7 @@ def fetch_rfq_status_sync(ctx: SyncSecureClientContext, *, rfq_id: str) -> RfqSt 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) + return _parse_rfq_status(data, expected_rfq_id=rfq_id) def build_combo_quote_request_body( @@ -289,25 +304,26 @@ def build_combo_quote_request_body( return parsed_direction, body -def _build_accept_request_body(ctx: _SecureContext, quote: ComboQuoteResult) -> dict[str, object]: - if quote.quote is None: - raise UserInputError("Cannot accept a combo quote result without a quote.") - if quote.position_id is None or quote.builder_code is None: - raise UserInputError( - "Cannot accept a combo quote result without its position and builder attribution." - ) +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=quote.direction, - position_id=quote.position_id, - builder_code=quote.builder_code, - maker_amount_e6=_decimal_to_e6("quote.maker_amount", quote.quote.maker_amount), - taker_amount_e6=_decimal_to_e6("quote.taker_amount", quote.quote.taker_amount), + 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.quote_id, + "quote_id": quote.quote_id, "signed_order": signed_order, } @@ -380,11 +396,23 @@ def _require_rfq_id(rfq_id: str) -> None: 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: - if timeout <= 0: - raise UserInputError("timeout must be greater than 0.") - if polling_interval <= 0: - raise UserInputError("polling_interval must be greater than 0.") + _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: @@ -402,18 +430,33 @@ def _parse_side(side: RfqSide | str) -> RfqSide: def _validate_legs(leg_position_ids: list[str] | tuple[str, ...]) -> list[str]: - legs = [str(leg) for leg in leg_position_ids] - if len(legs) < _MIN_LEGS or len(legs) > _MAX_LEGS: + 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 legs): + 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 legs +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)) @@ -441,7 +484,10 @@ def _encode_path_segment(value: str) -> str: def _to_rfq_request_rejected(error: RequestRejectedError) -> RfqRequestRejectedError: return RfqRequestRejectedError( - str(error), status=error.status, code=_parse_rejection_code(error.code) + str(error), + status=error.status, + code=_parse_rejection_code(error.code), + retry_after=error.retry_after, ) @@ -526,10 +572,23 @@ def _parse_combo_quote_result(data: object, *, direction: RfqDirection) -> Combo 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) + _expect_matching_rfq_id(request_payload, expected_rfq_id=rfq_id) + _parse_condition_id(_expect_str(request_payload, "condition_id")) + _parse_response_position_id(_expect_str(request_payload, "no_position_id")) 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")), @@ -538,15 +597,11 @@ def _parse_combo_quote_result(data: object, *, direction: RfqDirection) -> Combo ) return ComboQuoteResult( rfq_id=rfq_id, - direction=direction, quote=quote, - position_id=PositionId(_expect_str(request_payload, "yes_position_id")), - condition_id=_parse_condition_id(_expect_str(request_payload, "condition_id")), - builder_code=HexString(_expect_str(payload, "builder_code")), ) reason = _parse_quote_unavailable_reason(payload) - return ComboQuoteResult(rfq_id=rfq_id, direction=direction, quote=None, reason=reason) + return ComboQuoteResult(rfq_id=rfq_id, quote=None, reason=reason) def _parse_quote_unavailable_reason(payload: dict[str, object]) -> ComboQuoteUnavailableReason: @@ -565,8 +620,9 @@ def _parse_quote_unavailable_reason(payload: dict[str, object]) -> ComboQuoteUna ) -def _parse_rfq_status(data: object) -> RfqStatusResult: +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: @@ -575,17 +631,32 @@ def _parse_rfq_status(data: object) -> RfqStatusResult: code=_parse_error_code(_expect_str(error_object, "code")), message=_expect_str(error_object, "message"), ) - tx_hash = payload.get("tx_hash") - taker_order_hash = payload.get("taker_order_hash") + tx_hash = _expect_optional_str(payload, "tx_hash") + taker_order_hash = _expect_optional_str(payload, "taker_order_hash") return RfqStatusResult( - rfq_id=_expect_str(payload, "rfq_id"), + rfq_id=rfq_id, status=_parse_status(_expect_str(payload, "status")), - taker_order_hash=HexString(taker_order_hash) if isinstance(taker_order_hash, str) else None, - tx_hash=TransactionHash(tx_hash) if isinstance(tx_hash, str) else None, + 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) @@ -613,6 +684,18 @@ def _parse_condition_id(value: str) -> ComboConditionId: 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_object(value: object) -> dict[str, object]: if not isinstance(value, dict): raise UnexpectedResponseError("RFQ response did not match expected shape.") diff --git a/src/polymarket/clients/async_secure.py b/src/polymarket/clients/async_secure.py index 6b5abca1..7d242fa4 100644 --- a/src/polymarket/clients/async_secure.py +++ b/src/polymarket/clients/async_secure.py @@ -247,6 +247,7 @@ from polymarket.pagination import AsyncPaginator, Page from polymarket.rfq import ( ComboFillResult, + ComboQuote, ComboQuoteAcceptance, ComboQuoteResult, RfqDirection, @@ -2884,7 +2885,8 @@ async def request_combo_quote( ``quote=None`` and a ``reason`` rather than raised. Returns: - The quote result. Pass it to :meth:`accept_combo_quote` to + The quote result. Pass ``result.quote`` to + :meth:`accept_combo_quote` to execute the winning quote before ``quote.expires_at``. Raises: @@ -2903,8 +2905,10 @@ async def request_combo_quote( side=side, ) - async def accept_combo_quote(self, quote: ComboQuoteResult) -> ComboQuoteAcceptance: - """Accept a combo quote, signing the acceptance order automatically. + 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 @@ -2917,12 +2921,19 @@ async def accept_combo_quote(self, quote: ComboQuoteResult) -> ComboQuoteAccepta ``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 result carries no quote or the - client has no Builder API Key. + 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`. diff --git a/src/polymarket/clients/secure.py b/src/polymarket/clients/secure.py index c25ce240..9c355721 100644 --- a/src/polymarket/clients/secure.py +++ b/src/polymarket/clients/secure.py @@ -204,6 +204,7 @@ from polymarket.pagination import Page, Paginator from polymarket.rfq import ( ComboFillResult, + ComboQuote, ComboQuoteAcceptance, ComboQuoteResult, RfqDirection, @@ -2603,7 +2604,8 @@ def request_combo_quote( ``quote=None`` and a ``reason`` rather than raised. Returns: - The quote result. Pass it to :meth:`accept_combo_quote` to + The quote result. Pass ``result.quote`` to + :meth:`accept_combo_quote` to execute the winning quote before ``quote.expires_at``. Raises: @@ -2622,8 +2624,8 @@ def request_combo_quote( side=side, ) - def accept_combo_quote(self, quote: ComboQuoteResult) -> ComboQuoteAcceptance: - """Accept a combo quote, signing the acceptance order automatically. + 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 @@ -2636,12 +2638,19 @@ def accept_combo_quote(self, quote: ComboQuoteResult) -> ComboQuoteAcceptance: ``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 result carries no quote or the - client has no Builder API Key. + 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`. diff --git a/src/polymarket/rfq.py b/src/polymarket/rfq.py index 9bb9576b..9ca64daf 100644 --- a/src/polymarket/rfq.py +++ b/src/polymarket/rfq.py @@ -7,7 +7,8 @@ from types import TracebackType from typing import Any, Literal, Protocol, TypeAlias, runtime_checkable -from polymarket.errors import PolymarketError +from polymarket.errors import PolymarketError, RequestRejectedError +from polymarket.models.base import BaseModel from polymarket.models.types import ComboConditionId, PositionId from polymarket.types import EvmAddress, HexString, TransactionHash @@ -68,12 +69,30 @@ class RfqRejectionCode(StrEnum): 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): @@ -194,18 +213,25 @@ class RfqErrorDetail: message: str -@dataclass(frozen=True, slots=True, kw_only=True) -class ComboQuote: - """The winning quote for a combo quote request. +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 collateral received. ``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 @@ -218,18 +244,14 @@ class ComboQuoteResult: """Outcome of a combo quote request. ``quote`` is ``None`` when the request attracted no usable quotes; then - ``reason`` explains why. When a quote is present, ``position_id``, - ``condition_id``, and ``builder_code`` carry the combo position and - builder attribution the acceptance order trades with. + ``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 - direction: RfqDirection quote: ComboQuote | None reason: ComboQuoteUnavailableReason | None = None - position_id: PositionId | None = None - condition_id: ComboConditionId | None = None - builder_code: HexString | None = None @dataclass(frozen=True, slots=True, kw_only=True) @@ -364,7 +386,7 @@ async def decline(self) -> RfqConfirmationAck: ) -class RfqRequestRejectedError(PolymarketError): +class RfqRequestRejectedError(RequestRejectedError): """Error raised when an RFQ request or acceptance is rejected. ``code`` distinguishes permanent input problems (``INVALID_RFQ``, @@ -379,10 +401,9 @@ def __init__( *, status: int, code: RfqRejectionCode | str | None = None, + retry_after: float | None = None, ) -> None: - super().__init__(message) - self.status = status - self.code = code + super().__init__(message, status=status, code=code, retry_after=retry_after) class RfqQuoteRejectedError(PolymarketError): diff --git a/tests/integration/test_combo_rfq_live.py b/tests/integration/test_combo_rfq_live.py index 7a6d1b43..d242b1f2 100644 --- a/tests/integration/test_combo_rfq_live.py +++ b/tests/integration/test_combo_rfq_live.py @@ -1,12 +1,15 @@ from __future__ import annotations -from collections.abc import Callable +import os +from decimal import Decimal import pytest from polymarket import ( AsyncSecureClient, BuilderApiKey, + ComboAcceptFailureReason, + ComboMarket, RfqRequestRejectedError, RfqStatus, ) @@ -38,19 +41,45 @@ async def test_fetch_rfq_status_rejects_unknown_rfq( await builder_client.fetch_rfq_status(rfq_id="rfq-00000000-0000-0000-0000-000000000000") +def _load_combo_leg_position_ids() -> list[str] | None: + 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 + + +# 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: 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, - require_env: Callable[[str], str], ) -> None: - legs = [ - leg.strip() - for leg in require_env("POLYMARKET_COMBO_LEG_POSITION_IDS").split(",") - if leg.strip() - ] - if len(legs) < 2: - pytest.skip("POLYMARKET_COMBO_LEG_POSITION_IDS must list at least 2 position IDs") + legs = _load_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 @@ -59,13 +88,21 @@ async def test_combo_quote_request_accept_and_fill( if result.quote is None: pytest.skip(f"No combo quote available: {result.reason}") - acceptance = await builder_client.accept_combo_quote(result) + 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 RfqStatus.FILLED: - assert fill.tx_hash is not None + 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 index 30c3d2f3..b16f1384 100644 --- a/tests/unit/test_combo_rfq.py +++ b/tests/unit/test_combo_rfq.py @@ -22,7 +22,6 @@ AsyncSecureClient, ComboAcceptFailureReason, ComboQuote, - ComboQuoteResult, ComboQuoteUnavailableReason, RfqDirection, RfqRejectionCode, @@ -32,8 +31,14 @@ UserInputError, ) from polymarket.clients._transport import AsyncTransport, SyncTransport -from polymarket.errors import TimeoutError as SdkTimeoutError -from polymarket.models.types import PositionId, to_combo_condition_id +from polymarket.errors import ( + RequestRejectedError, + UnexpectedResponseError, +) +from polymarket.errors import ( + TimeoutError as SdkTimeoutError, +) +from polymarket.models.types import PositionId from polymarket.types import HexString BUILDER_CODE = "0x" + "ab" * 32 @@ -66,20 +71,17 @@ }, } -QUOTE_RESULT = ComboQuoteResult( +QUOTE = ComboQuote( rfq_id="rfq-1", + quote_id="quote-1", + builder_code=HexString(BUILDER_CODE), direction=RfqDirection.BUY, - quote=ComboQuote( - quote_id="quote-1", - 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, - ), position_id=PositionId("789"), - condition_id=to_combo_condition_id(CONDITION_ID), - builder_code=HexString(BUILDER_CODE), + 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, ) @@ -163,11 +165,11 @@ def handler(request: httpx.Request) -> httpx.Response: assert request.headers["POLY_BUILDER_API_KEY"] == BUILDER_AUTH.key assert result.rfq_id == "rfq-1" - assert result.direction is RfqDirection.BUY - assert result.position_id == "789" - assert result.condition_id == CONDITION_ID - assert result.builder_code == BUILDER_CODE assert result.quote is not None + assert result.quote.rfq_id == "rfq-1" + assert result.quote.direction is RfqDirection.BUY + assert result.quote.position_id == "789" + assert result.quote.builder_code == BUILDER_CODE assert result.quote.blended_price == Decimal("0.45") assert result.quote.maker_amount == Decimal("0.966191") assert result.quote.taker_amount == Decimal("1.932381") @@ -237,6 +239,7 @@ def unexpected(request: httpx.Request) -> httpx.Response: invalid_calls = [ {"leg_position_ids": ["123"], "direction": "BUY", "amount": 100}, {"leg_position_ids": ["123", "123"], "direction": "BUY", "amount": 100}, + {"leg_position_ids": ["123", "0123"], "direction": "BUY", "amount": 100}, {"leg_position_ids": ["123", "0x2"], "direction": "BUY", "amount": 100}, {"leg_position_ids": LEGS, "direction": "BUY", "amount": "0.0000001"}, {"leg_position_ids": LEGS, "direction": "BUY", "amount": 0}, @@ -254,6 +257,22 @@ def unexpected(request: httpx.Request) -> httpx.Response: client.request_combo_quote(**kwargs) # type: ignore[arg-type] +def test_request_combo_quote_canonicalizes_numeric_leg_ids() -> None: + captured: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response(200, json=QUOTE_READY, request=request) + + client = make_sync_eoa_client() + install_sync_builder_gateway_handler(client, handler) + + client.request_combo_quote(leg_position_ids=["000123", "0456"], direction="BUY", amount=100) + + body = json.loads(captured[0].content.decode("utf-8")) + assert body["leg_position_ids"] == ["123", "456"] + + def test_request_combo_quote_requires_builder_api_key() -> None: async def run() -> None: client = await make_eoa_client(with_api_key=False) @@ -273,6 +292,11 @@ async def run() -> None: httpx.Response( 400, json={"error": "contradictory legs", "code": "CONTRADICTORY_LEGS"} ), + httpx.Response( + 503, + json={"error": "temporarily unavailable", "code": "SERVICE_UNAVAILABLE"}, + headers={"Retry-After": "2"}, + ), httpx.Response(400, json={"error": "something new", "code": "SOMETHING_NEW"}), ), ) @@ -282,6 +306,13 @@ async def run() -> None: assert known.value.code is RfqRejectionCode.CONTRADICTORY_LEGS assert known.value.status == 400 + with pytest.raises(RfqRequestRejectedError) as transient: + await client.request_combo_quote(leg_position_ids=LEGS, direction="BUY", amount=100) + assert transient.value.code is RfqRejectionCode.SERVICE_UNAVAILABLE + assert transient.value.status == 503 + assert transient.value.retry_after == 2.0 + assert isinstance(transient.value, RequestRejectedError) + with pytest.raises(RfqRequestRejectedError) as unknown: await client.request_combo_quote(leg_position_ids=LEGS, direction="BUY", amount=100) assert unknown.value.code == "SOMETHING_NEW" @@ -308,7 +339,7 @@ def handler(request: httpx.Request) -> httpx.Response: client = await make_eoa_client() install_builder_gateway_handler(client, handler) - acceptance = await client.accept_combo_quote(QUOTE_RESULT) + acceptance = await client.accept_combo_quote(QUOTE) request = captured[0] assert request.url.path == "/v1/builder/rfq/requests/rfq-1/accept" @@ -354,7 +385,7 @@ def handler(request: httpx.Request) -> httpx.Response: client = await make_eoa_client() install_builder_gateway_handler(client, handler) - acceptance = await client.accept_combo_quote(QUOTE_RESULT) + acceptance = await client.accept_combo_quote(QUOTE) assert acceptance.status == "executing" assert acceptance.taker_order_hash == TAKER_ORDER_HASH @@ -380,7 +411,7 @@ async def run() -> None: ), ) - acceptance = await client.accept_combo_quote(QUOTE_RESULT) + acceptance = await client.accept_combo_quote(QUOTE) assert acceptance.status == "failed" assert acceptance.reason is ComboAcceptFailureReason.MAKER_DECLINED @@ -398,7 +429,7 @@ async def run() -> None: json_handler(httpx.Response(409, json={"error": "expired rfq", "code": "EXPIRED_RFQ"})), ) - acceptance = await client.accept_combo_quote(QUOTE_RESULT) + acceptance = await client.accept_combo_quote(QUOTE) assert acceptance.status == "failed" assert acceptance.reason is ComboAcceptFailureReason.ACCEPTANCE_WINDOW_EXPIRED @@ -406,20 +437,91 @@ async def run() -> None: asyncio.run(run()) -def test_accept_combo_quote_rejects_result_without_quote() -> None: +def test_accept_combo_quote_rejects_invalid_portable_quote() -> None: async def run() -> None: client = await make_eoa_client() + portable = json.loads(QUOTE.model_dump_json()) + portable["builder_code"] = "not-a-builder-code" - with pytest.raises(UserInputError, match="without a quote"): - await client.accept_combo_quote( - ComboQuoteResult( - rfq_id="rfq-2", - direction=RfqDirection.BUY, - quote=None, - reason=ComboQuoteUnavailableReason.NO_QUOTES, - ) + with pytest.raises(UserInputError, match="builder_code"): + await client.accept_combo_quote(portable) + + asyncio.run(run()) + + +def test_accept_combo_quote_retries_once_after_transport_drop() -> None: + async def run() -> None: + captured: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + if len(captured) == 1: + raise httpx.ReadTimeout("connection dropped", request=request) + return httpx.Response( + 200, + json={"rfq_id": "rfq-1", "status": "EXECUTING"}, + request=request, ) + client = await make_eoa_client() + install_builder_gateway_handler(client, handler) + + acceptance = await client.accept_combo_quote(QUOTE) + + assert len(captured) == 2 + assert captured[0].content == captured[1].content + assert acceptance.status == "executing" + assert acceptance.taker_order_hash is None + + asyncio.run(run()) + + +def test_sync_accept_combo_quote_retries_once_after_transport_drop() -> None: + captured: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + if len(captured) == 1: + raise httpx.ReadTimeout("connection dropped", request=request) + 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) + + acceptance = client.accept_combo_quote(QUOTE) + + assert len(captured) == 2 + assert captured[0].content == captured[1].content + assert acceptance.status == "executing" + + +def test_combo_quote_round_trips_as_json_between_clients() -> None: + async def run() -> None: + requesting_client = await make_eoa_client() + install_builder_gateway_handler( + requesting_client, + json_handler(httpx.Response(200, json=QUOTE_READY)), + ) + result = await requesting_client.request_combo_quote( + leg_position_ids=LEGS, direction="BUY", amount=100 + ) + assert result.quote is not None + + portable = json.loads(result.quote.model_dump_json()) + accepting_client = await make_eoa_client() + install_builder_gateway_handler( + accepting_client, + json_handler(httpx.Response(200, json={"rfq_id": "rfq-1", "status": "EXECUTING"})), + ) + + acceptance = await accepting_client.accept_combo_quote(portable) + + assert acceptance.status == "executing" + asyncio.run(run()) @@ -480,6 +582,34 @@ def handler(request: httpx.Request) -> httpx.Response: client.wait_for_combo_fill(rfq_id="rfq-1", timeout=0.01, polling_interval=0.001) +@pytest.mark.parametrize( + ("timeout", "polling_interval"), + [(float("nan"), 1.0), (1.0, float("nan")), (True, 1.0), (1.0, False)], +) +def test_wait_for_combo_fill_rejects_invalid_wait_params( + timeout: float, polling_interval: float +) -> None: + client = make_sync_eoa_client() + + with pytest.raises(UserInputError, match="finite number greater than 0"): + client.wait_for_combo_fill( + rfq_id="rfq-1", timeout=timeout, polling_interval=polling_interval + ) + + +def test_async_wait_for_combo_fill_rejects_nan_wait_params() -> None: + async def run() -> None: + client = await make_eoa_client() + + with pytest.raises(UserInputError, match="finite number greater than 0"): + await client.wait_for_combo_fill(rfq_id="rfq-1", timeout=float("nan")) + + with pytest.raises(UserInputError, match="finite number greater than 0"): + await client.wait_for_combo_fill(rfq_id="rfq-1", polling_interval=float("nan")) + + asyncio.run(run()) + + def test_fetch_rfq_status_maps_rejections() -> None: client = make_sync_eoa_client() install_sync_builder_gateway_handler( @@ -496,6 +626,37 @@ def test_fetch_rfq_status_maps_rejections() -> None: assert rejected.value.code == "RFQ_NOT_ACCEPTED" +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"), + [("taker_order_hash", 123), ("tx_hash", None)], +) +def test_fetch_rfq_status_rejects_malformed_optional_hashes(field: str, value: object) -> None: + client = make_sync_eoa_client() + install_sync_builder_gateway_handler( + client, + json_handler( + httpx.Response( + 200, + json={"rfq_id": "rfq-1", "status": "EXECUTING", field: value}, + ) + ), + ) + + with pytest.raises(UnexpectedResponseError, match=field): + client.fetch_rfq_status(rfq_id="rfq-1") + + def test_sync_client_requests_and_accepts_a_combo_quote() -> None: captured: list[httpx.Request] = [] @@ -519,7 +680,7 @@ def handler(request: httpx.Request) -> httpx.Response: result = client.request_combo_quote(leg_position_ids=LEGS, direction="BUY", amount=100) assert result.quote is not None - acceptance = client.accept_combo_quote(result) + acceptance = client.accept_combo_quote(result.quote) accept_request = captured[1] assert accept_request.url.path == "/v1/builder/rfq/requests/rfq-1/accept" @@ -531,8 +692,6 @@ def handler(request: httpx.Request) -> httpx.Response: def test_request_combo_quote_rejects_malformed_condition_id() -> None: - from polymarket.errors import UnexpectedResponseError - malformed = copy.deepcopy(QUOTE_READY) malformed["request"]["condition_id"] = "0x04" + "0" * 60 client = make_sync_eoa_client() @@ -540,3 +699,45 @@ def test_request_combo_quote_rejects_malformed_condition_id() -> None: with pytest.raises(UnexpectedResponseError): client.request_combo_quote(leg_position_ids=LEGS, direction="BUY", amount=100) + + +def test_request_combo_quote_rejects_quote_in_wrong_lifecycle_status() -> None: + malformed = copy.deepcopy(QUOTE_READY) + malformed["status"] = "EXECUTING" + client = make_sync_eoa_client() + install_sync_builder_gateway_handler(client, json_handler(httpx.Response(200, json=malformed))) + + with pytest.raises(UnexpectedResponseError, match="included a quote"): + client.request_combo_quote(leg_position_ids=LEGS, direction="BUY", amount=100) + + +def test_request_combo_quote_rejects_mismatched_nested_rfq_id() -> None: + malformed = copy.deepcopy(QUOTE_READY) + malformed["request"]["rfq_id"] = "rfq-2" + client = make_sync_eoa_client() + install_sync_builder_gateway_handler(client, json_handler(httpx.Response(200, json=malformed))) + + with pytest.raises(UnexpectedResponseError, match="did not match requested ID"): + client.request_combo_quote(leg_position_ids=LEGS, direction="BUY", amount=100) + + +def test_accept_combo_quote_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.accept_combo_quote(QUOTE) + + +def test_wait_for_combo_fill_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.wait_for_combo_fill(rfq_id="rfq-1") From 215ffa71c234670522097171ebd86d133485e7c5 Mon Sep 17 00:00:00 2001 From: kartojal Date: Thu, 13 Aug 2026 12:02:20 +0200 Subject: [PATCH 4/5] fix: harden combo requester quote handling --- src/polymarket/_internal/actions/combo_rfq.py | 121 ++++++++++++++-- src/polymarket/clients/async_secure.py | 8 +- src/polymarket/clients/secure.py | 8 +- src/polymarket/rfq.py | 19 ++- tests/integration/conftest.py | 10 ++ tests/integration/test_combo_rfq_live.py | 37 +++-- tests/unit/test_combo_rfq.py | 131 +++++++++++++++++- 7 files changed, 292 insertions(+), 42 deletions(-) diff --git a/src/polymarket/_internal/actions/combo_rfq.py b/src/polymarket/_internal/actions/combo_rfq.py index 9cdbc410..aed69fa1 100644 --- a/src/polymarket/_internal/actions/combo_rfq.py +++ b/src/polymarket/_internal/actions/combo_rfq.py @@ -44,6 +44,7 @@ RfqErrorDetail, RfqExecutionStatus, RfqRejectionCode, + RfqRequestedSizeUnit, RfqRequestRejectedError, RfqSide, RfqStatus, @@ -83,7 +84,7 @@ async def request_combo_quote( size: Decimal | int | float | str | None = None, side: RfqSide | str = RfqSide.YES, ) -> ComboQuoteResult: - parsed_direction, body = build_combo_quote_request_body( + _, body = build_combo_quote_request_body( ctx, leg_position_ids=leg_position_ids, direction=direction, @@ -98,7 +99,7 @@ async def request_combo_quote( ) except RequestRejectedError as error: raise _to_rfq_request_rejected(error) from error - return _parse_combo_quote_result(data, direction=parsed_direction) + return _parse_combo_quote_result(data, request=body) def request_combo_quote_sync( @@ -110,7 +111,7 @@ def request_combo_quote_sync( size: Decimal | int | float | str | None = None, side: RfqSide | str = RfqSide.YES, ) -> ComboQuoteResult: - parsed_direction, body = build_combo_quote_request_body( + _, body = build_combo_quote_request_body( ctx, leg_position_ids=leg_position_ids, direction=direction, @@ -125,7 +126,7 @@ def request_combo_quote_sync( ) except RequestRejectedError as error: raise _to_rfq_request_rejected(error) from error - return _parse_combo_quote_result(data, direction=parsed_direction) + return _parse_combo_quote_result(data, request=body) async def accept_combo_quote( @@ -140,19 +141,21 @@ async def accept_combo_quote( data = await ctx.builder_gateway.post_json( path, json=body, timeout=_HELD_REQUEST_TIMEOUT ) - except TransportError: + 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 connection drops during the maker last-look hold. + # 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 - status = _parse_rfq_status(data, expected_rfq_id=quote.rfq_id) # 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 @@ -177,17 +180,19 @@ def accept_combo_quote_sync( try: try: data = ctx.builder_gateway.post_json(path, json=body, timeout=_HELD_REQUEST_TIMEOUT) - except TransportError: + 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 connection drops during the maker last-look hold. + # 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 - status = _parse_rfq_status(data, expected_rfq_id=quote.rfq_id) # 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 @@ -440,7 +445,7 @@ def _validate_legs(leg_position_ids: list[str] | tuple[str, ...]) -> list[str]: 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 legs + return sorted(legs, key=int) def _validate_position_id(name: str, value: object) -> PositionId: @@ -558,7 +563,7 @@ def _to_fill_result(status: RfqStatusResult) -> ComboFillResult | None: return None -def _parse_combo_quote_result(data: object, *, direction: RfqDirection) -> ComboQuoteResult: +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")) @@ -578,9 +583,20 @@ def _parse_combo_quote_result(data: object, *, direction: RfqDirection) -> Combo ) request_payload = _expect_object(payload.get("request")) quote_object = _expect_object(quote_payload) - _expect_matching_rfq_id(request_payload, expected_rfq_id=rfq_id) + 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"), @@ -593,6 +609,7 @@ def _parse_combo_quote_result(data: object, *, direction: RfqDirection) -> Combo 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( @@ -604,6 +621,44 @@ def _parse_combo_quote_result(data: object, *, direction: RfqDirection) -> Combo 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 @@ -677,6 +732,33 @@ def _parse_error_code(value: str) -> RfqErrorCode | str: 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) @@ -696,6 +778,19 @@ def _parse_builder_code(value: str) -> HexString: 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.") diff --git a/src/polymarket/clients/async_secure.py b/src/polymarket/clients/async_secure.py index 6693dff7..fea0ab43 100644 --- a/src/polymarket/clients/async_secure.py +++ b/src/polymarket/clients/async_secure.py @@ -2933,10 +2933,10 @@ async def accept_combo_quote( the trade was handed off for onchain execution; follow it with :meth:`wait_for_combo_fill`. - A retry after a dropped connection 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. + 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 diff --git a/src/polymarket/clients/secure.py b/src/polymarket/clients/secure.py index dba1af7c..3c51e321 100644 --- a/src/polymarket/clients/secure.py +++ b/src/polymarket/clients/secure.py @@ -2648,10 +2648,10 @@ def accept_combo_quote(self, quote: ComboQuote | Mapping[str, object]) -> ComboQ the trade was handed off for onchain execution; follow it with :meth:`wait_for_combo_fill`. - A retry after a dropped connection 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. + 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 diff --git a/src/polymarket/rfq.py b/src/polymarket/rfq.py index 9ca64daf..d62d37a6 100644 --- a/src/polymarket/rfq.py +++ b/src/polymarket/rfq.py @@ -5,7 +5,9 @@ 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 pydantic import model_validator from polymarket.errors import PolymarketError, RequestRejectedError from polymarket.models.base import BaseModel @@ -218,9 +220,11 @@ class ComboQuote(BaseModel): ``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 collateral received. ``total_required`` is - the total collateral (BUY) or position-share (SELL) balance required to - accept. ``expires_at`` is the acceptance deadline in Unix milliseconds. + 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 @@ -236,8 +240,15 @@ class ComboQuote(BaseModel): 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: 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 index d242b1f2..fafcd9ee 100644 --- a/tests/integration/test_combo_rfq_live.py +++ b/tests/integration/test_combo_rfq_live.py @@ -1,6 +1,5 @@ from __future__ import annotations -import os from decimal import Decimal import pytest @@ -10,6 +9,7 @@ BuilderApiKey, ComboAcceptFailureReason, ComboMarket, + RfqDirection, RfqRequestRejectedError, RfqStatus, ) @@ -41,14 +41,6 @@ async def test_fetch_rfq_status_rejects_unknown_rfq( await builder_client.fetch_rfq_status(rfq_id="rfq-00000000-0000-0000-0000-000000000000") -def _load_combo_leg_position_ids() -> list[str] | None: - 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 - - # 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. @@ -70,12 +62,37 @@ async def _discover_combo_leg_position_ids(client: AsyncSecureClient) -> list[st 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 = _load_combo_leg_position_ids() or await _discover_combo_leg_position_ids(builder_client) + 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." diff --git a/tests/unit/test_combo_rfq.py b/tests/unit/test_combo_rfq.py index b16f1384..4f8005fc 100644 --- a/tests/unit/test_combo_rfq.py +++ b/tests/unit/test_combo_rfq.py @@ -60,6 +60,7 @@ "no_position_id": "790", "direction": "BUY", "side": "YES", + "requested_size": {"unit": "notional", "value_e6": "100000000"}, "created_at": 1_773_890_758_000, }, "quote": { @@ -68,6 +69,33 @@ "maker_amount_e6": "966191", "taker_amount_e6": "1932381", "total_required_e6": "1000000", + "net_receive_e6": "1932381", + }, +} + +SELL_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": "SELL", + "side": "YES", + "requested_size": {"unit": "shares", "value_e6": "2500000"}, + "created_at": 1_773_890_758_000, + }, + "quote": { + "quote_id": "quote-1", + "blended_price_e6": "450000", + "maker_amount_e6": "2500000", + "taker_amount_e6": "1125000", + "total_required_e6": "2500000", + "net_receive_e6": "1090000", }, } @@ -185,20 +213,38 @@ async def run() -> None: def handler(request: httpx.Request) -> httpx.Response: captured.append(request) - return httpx.Response(200, json=QUOTE_READY, request=request) + return httpx.Response(200, json=SELL_QUOTE_READY, request=request) client = await make_eoa_client() install_builder_gateway_handler(client, handler) - await client.request_combo_quote(leg_position_ids=LEGS, direction="SELL", size="2.5") + result = await client.request_combo_quote( + leg_position_ids=LEGS, direction="SELL", size="2.5" + ) body = json.loads(captured[0].content.decode("utf-8")) assert body["direction"] == "SELL" assert body["requested_size"] == {"unit": "shares", "value_e6": "2500000"} + assert result.quote is not None + assert result.quote.direction is RfqDirection.SELL + assert result.quote.maker_amount == Decimal("2.5") + assert result.quote.taker_amount == Decimal("1.125") + assert result.quote.total_required == Decimal("2.5") + assert result.quote.net_receive == Decimal("1.09") asyncio.run(run()) +def test_request_combo_quote_rejects_sell_quote_without_net_proceeds() -> None: + malformed = copy.deepcopy(SELL_QUOTE_READY) + del malformed["quote"]["net_receive_e6"] + client = make_sync_eoa_client() + install_sync_builder_gateway_handler(client, json_handler(httpx.Response(200, json=malformed))) + + 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: async def run() -> None: client = await make_eoa_client() @@ -259,18 +305,20 @@ def unexpected(request: httpx.Request) -> httpx.Response: 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=QUOTE_READY, request=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=["000123", "0456"], direction="BUY", amount=100) + client.request_combo_quote(leg_position_ids=["0010", "02"], direction="BUY", amount=100) body = json.loads(captured[0].content.decode("utf-8")) - assert body["leg_position_ids"] == ["123", "456"] + assert body["leg_position_ids"] == ["2", "10"] def test_request_combo_quote_requires_builder_api_key() -> None: @@ -499,6 +547,53 @@ def handler(request: httpx.Request) -> httpx.Response: assert acceptance.status == "executing" +def test_accept_combo_quote_retries_once_after_unexpected_response() -> None: + async def run() -> None: + captured: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + status = "UNKNOWN" if len(captured) == 1 else "EXECUTING" + return httpx.Response( + 200, + json={"rfq_id": "rfq-1", "status": status}, + request=request, + ) + + client = await make_eoa_client() + install_builder_gateway_handler(client, handler) + + 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()) + + +def test_sync_accept_combo_quote_retries_once_after_unexpected_response() -> None: + captured: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + status = "UNKNOWN" if len(captured) == 1 else "EXECUTING" + return httpx.Response( + 200, + json={"rfq_id": "rfq-1", "status": status}, + request=request, + ) + + client = make_sync_eoa_client() + install_sync_builder_gateway_handler(client, handler) + + acceptance = client.accept_combo_quote(QUOTE) + + assert len(captured) == 2 + assert captured[0].content == captured[1].content + assert acceptance.status == "executing" + + def test_combo_quote_round_trips_as_json_between_clients() -> None: async def run() -> None: requesting_client = await make_eoa_client() @@ -717,7 +812,26 @@ def test_request_combo_quote_rejects_mismatched_nested_rfq_id() -> None: client = make_sync_eoa_client() install_sync_builder_gateway_handler(client, json_handler(httpx.Response(200, json=malformed))) - with pytest.raises(UnexpectedResponseError, match="did not match requested ID"): + with pytest.raises(UnexpectedResponseError, match="rfq_id"): + client.request_combo_quote(leg_position_ids=LEGS, direction="BUY", amount=100) + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("direction", "SELL"), + ("side", "NO"), + ("leg_position_ids", list(reversed(LEGS))), + ("requested_size", {"unit": "notional", "value_e6": "99999999"}), + ], +) +def test_request_combo_quote_rejects_mismatched_request_echo(field: str, value: object) -> None: + malformed = copy.deepcopy(QUOTE_READY) + malformed["request"][field] = value + client = make_sync_eoa_client() + install_sync_builder_gateway_handler(client, json_handler(httpx.Response(200, json=malformed))) + + with pytest.raises(UnexpectedResponseError, match=field): client.request_combo_quote(leg_position_ids=LEGS, direction="BUY", amount=100) @@ -725,7 +839,10 @@ def test_accept_combo_quote_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"})), + json_handler( + httpx.Response(200, json={"rfq_id": "rfq-2", "status": "EXECUTING"}), + httpx.Response(200, json={"rfq_id": "rfq-2", "status": "EXECUTING"}), + ), ) with pytest.raises(UnexpectedResponseError, match="did not match requested ID"): From 168f1a9e707af0bc698fd75c7da78101dce5b9dd Mon Sep 17 00:00:00 2001 From: kartojal Date: Thu, 13 Aug 2026 15:12:12 +0200 Subject: [PATCH 5/5] test: focus combo RFQ coverage on edge cases --- tests/unit/test_combo_rfq.py | 726 ++++++----------------------------- 1 file changed, 111 insertions(+), 615 deletions(-) diff --git a/tests/unit/test_combo_rfq.py b/tests/unit/test_combo_rfq.py index 4f8005fc..3e7ed6ae 100644 --- a/tests/unit/test_combo_rfq.py +++ b/tests/unit/test_combo_rfq.py @@ -11,12 +11,7 @@ import httpx import pytest -from _relayer_helpers import ( - BUILDER_AUTH, - FAKE_CREDS, - PK_DEPLOY_WALLET, - make_eoa_client, -) +from _relayer_helpers import BUILDER_AUTH, FAKE_CREDS, PK_DEPLOY_WALLET, make_eoa_client from polymarket import ( AsyncSecureClient, @@ -24,26 +19,16 @@ ComboQuote, ComboQuoteUnavailableReason, RfqDirection, - RfqRejectionCode, - RfqRequestRejectedError, RfqStatus, SecureClient, - UserInputError, ) from polymarket.clients._transport import AsyncTransport, SyncTransport -from polymarket.errors import ( - RequestRejectedError, - UnexpectedResponseError, -) -from polymarket.errors import ( - TimeoutError as SdkTimeoutError, -) +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 -TX_HASH = "0x" + "cd" * 32 -TAKER_ORDER_HASH = "0x" + "ef" * 32 LEGS = ["123", "456"] CONDITION_ID = "0x03" + "0" * 60 @@ -73,32 +58,6 @@ }, } -SELL_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": "SELL", - "side": "YES", - "requested_size": {"unit": "shares", "value_e6": "2500000"}, - "created_at": 1_773_890_758_000, - }, - "quote": { - "quote_id": "quote-1", - "blended_price_e6": "450000", - "maker_amount_e6": "2500000", - "taker_amount_e6": "1125000", - "total_required_e6": "2500000", - "net_receive_e6": "1090000", - }, -} - QUOTE = ComboQuote( rfq_id="rfq-1", quote_id="quote-1", @@ -127,7 +86,7 @@ def install_builder_gateway_handler( client._ctx = dataclasses.replace(client._ctx, builder_gateway=transport) -def make_sync_eoa_client(*, with_api_key: bool = True) -> SecureClient: +def make_sync_eoa_client() -> SecureClient: from eth_account import Account signer = Account.from_key(PK_DEPLOY_WALLET) @@ -135,7 +94,7 @@ def make_sync_eoa_client(*, with_api_key: bool = True) -> SecureClient: private_key=PK_DEPLOY_WALLET, wallet=signer.address, credentials=FAKE_CREDS, - api_key=BUILDER_AUTH if with_api_key else None, + api_key=BUILDER_AUTH, validate_credentials=False, ) @@ -165,142 +124,81 @@ def handler(request: httpx.Request) -> httpx.Response: return handler -def test_request_combo_quote_builds_buy_request_and_parses_quote() -> None: - async def run() -> None: - captured: list[httpx.Request] = [] - - def handler(request: httpx.Request) -> httpx.Response: - captured.append(request) - return httpx.Response(200, json=QUOTE_READY, request=request) - - client = await make_eoa_client() - install_builder_gateway_handler(client, handler) - - result = await client.request_combo_quote( - leg_position_ids=LEGS, direction="BUY", amount=100 - ) - - request = captured[0] - assert request.url.path == "/v1/builder/rfq/requests" - body = json.loads(request.content.decode("utf-8")) - assert body["direction"] == "BUY" - assert body["side"] == "YES" - assert body["leg_position_ids"] == LEGS - assert body["requested_size"] == {"unit": "notional", "value_e6": "100000000"} - assert body["signature_type"] == 0 - assert body["signer_address"] == body["maker_address"] - assert request.headers["POLY_API_KEY"] == FAKE_CREDS.key - assert request.headers["POLY_BUILDER_API_KEY"] == BUILDER_AUTH.key - - assert result.rfq_id == "rfq-1" - assert result.quote is not None - assert result.quote.rfq_id == "rfq-1" - assert result.quote.direction is RfqDirection.BUY - assert result.quote.position_id == "789" - assert result.quote.builder_code == BUILDER_CODE - assert result.quote.blended_price == Decimal("0.45") - assert result.quote.maker_amount == Decimal("0.966191") - assert result.quote.taker_amount == Decimal("1.932381") - assert result.quote.total_required == Decimal("1") - assert result.quote.expires_at == 1_773_890_765_500 - - asyncio.run(run()) - - -def test_request_combo_quote_sell_is_sized_in_shares() -> None: - async def run() -> None: - captured: list[httpx.Request] = [] - - def handler(request: httpx.Request) -> httpx.Response: - captured.append(request) - return httpx.Response(200, json=SELL_QUOTE_READY, request=request) - - client = await make_eoa_client() - install_builder_gateway_handler(client, handler) - - result = await client.request_combo_quote( - leg_position_ids=LEGS, direction="SELL", size="2.5" +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, ) - body = json.loads(captured[0].content.decode("utf-8")) - assert body["direction"] == "SELL" - assert body["requested_size"] == {"unit": "shares", "value_e6": "2500000"} - assert result.quote is not None - assert result.quote.direction is RfqDirection.SELL - assert result.quote.maker_amount == Decimal("2.5") - assert result.quote.taker_amount == Decimal("1.125") - assert result.quote.total_required == Decimal("2.5") - assert result.quote.net_receive == Decimal("1.09") - - asyncio.run(run()) + return handler -def test_request_combo_quote_rejects_sell_quote_without_net_proceeds() -> None: - malformed = copy.deepcopy(SELL_QUOTE_READY) - del malformed["quote"]["net_receive_e6"] +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=malformed))) + 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: - async def run() -> None: - client = await make_eoa_client() - install_builder_gateway_handler( - client, - json_handler( - httpx.Response( - 200, - json={ - "rfq_id": "rfq-2", - "status": "FAILED", - "builder_code": BUILDER_CODE, - "error": {"code": "NO_QUOTES", "message": "no quotes"}, - }, - ) - ), - ) - - result = await client.request_combo_quote( - leg_position_ids=LEGS, direction="BUY", amount=100 - ) - - assert result.quote is None - assert result.reason is ComboQuoteUnavailableReason.NO_QUOTES - assert result.rfq_id == "rfq-2" - - asyncio.run(run()) - - -def test_request_combo_quote_validates_input_before_sending() -> 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"}, + }, + ) + ), + ) - def unexpected(request: httpx.Request) -> httpx.Response: - raise AssertionError("No request expected") - - install_sync_builder_gateway_handler(client, unexpected) - - invalid_calls = [ - {"leg_position_ids": ["123"], "direction": "BUY", "amount": 100}, - {"leg_position_ids": ["123", "123"], "direction": "BUY", "amount": 100}, - {"leg_position_ids": ["123", "0123"], "direction": "BUY", "amount": 100}, - {"leg_position_ids": ["123", "0x2"], "direction": "BUY", "amount": 100}, - {"leg_position_ids": LEGS, "direction": "BUY", "amount": "0.0000001"}, - {"leg_position_ids": LEGS, "direction": "BUY", "amount": 0}, - {"leg_position_ids": LEGS, "direction": "BUY", "size": 1}, - {"leg_position_ids": LEGS, "direction": "SELL", "amount": 1}, - {"leg_position_ids": LEGS, "direction": "SELL", "size": -1}, - {"leg_position_ids": LEGS, "direction": "HOLD", "amount": 1}, - {"leg_position_ids": LEGS, "direction": "BUY", "amount": 1, "side": "NO"}, - {"leg_position_ids": LEGS, "direction": "BUY", "amount": float("nan")}, - {"leg_position_ids": LEGS, "direction": "BUY", "amount": float("inf")}, - ] + result = client.request_combo_quote(leg_position_ids=LEGS, direction="BUY", amount=100) - for kwargs in invalid_calls: - with pytest.raises(UserInputError): - client.request_combo_quote(**kwargs) # type: ignore[arg-type] + 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: @@ -317,251 +215,51 @@ def handler(request: httpx.Request) -> httpx.Response: client.request_combo_quote(leg_position_ids=["0010", "02"], direction="BUY", amount=100) - body = json.loads(captured[0].content.decode("utf-8")) + body = json.loads(captured[0].content) assert body["leg_position_ids"] == ["2", "10"] -def test_request_combo_quote_requires_builder_api_key() -> None: - async def run() -> None: - client = await make_eoa_client(with_api_key=False) - - with pytest.raises(UserInputError, match="Builder API Key"): - await client.request_combo_quote(leg_position_ids=LEGS, direction="BUY", amount=100) - - asyncio.run(run()) - - -def test_request_combo_quote_classifies_rejections() -> None: - async def run() -> None: - client = await make_eoa_client() - install_builder_gateway_handler( - client, - json_handler( - httpx.Response( - 400, json={"error": "contradictory legs", "code": "CONTRADICTORY_LEGS"} - ), - httpx.Response( - 503, - json={"error": "temporarily unavailable", "code": "SERVICE_UNAVAILABLE"}, - headers={"Retry-After": "2"}, - ), - httpx.Response(400, json={"error": "something new", "code": "SOMETHING_NEW"}), - ), - ) - - with pytest.raises(RfqRequestRejectedError) as known: - await client.request_combo_quote(leg_position_ids=LEGS, direction="BUY", amount=100) - assert known.value.code is RfqRejectionCode.CONTRADICTORY_LEGS - assert known.value.status == 400 - - with pytest.raises(RfqRequestRejectedError) as transient: - await client.request_combo_quote(leg_position_ids=LEGS, direction="BUY", amount=100) - assert transient.value.code is RfqRejectionCode.SERVICE_UNAVAILABLE - assert transient.value.status == 503 - assert transient.value.retry_after == 2.0 - assert isinstance(transient.value, RequestRejectedError) - - with pytest.raises(RfqRequestRejectedError) as unknown: - await client.request_combo_quote(leg_position_ids=LEGS, direction="BUY", amount=100) - assert unknown.value.code == "SOMETHING_NEW" - - asyncio.run(run()) - - -def test_accept_combo_quote_signs_and_submits_the_acceptance_order() -> None: - async def run() -> None: - captured: list[httpx.Request] = [] - - def handler(request: httpx.Request) -> httpx.Response: - captured.append(request) - return httpx.Response( - 200, - json={ - "rfq_id": "rfq-1", - "status": "EXECUTING", - "taker_order_hash": TAKER_ORDER_HASH, - }, - request=request, - ) - - client = await make_eoa_client() - install_builder_gateway_handler(client, handler) - - acceptance = await client.accept_combo_quote(QUOTE) - - request = captured[0] - assert request.url.path == "/v1/builder/rfq/requests/rfq-1/accept" - assert request.headers["POLY_BUILDER_API_KEY"] == BUILDER_AUTH.key - body = json.loads(request.content.decode("utf-8")) - assert body["quote_id"] == "quote-1" - order = body["signed_order"] - assert order["builder"] == BUILDER_CODE - assert order["tokenId"] == "789" - assert order["side"] == 0 - assert order["signatureType"] == 0 - assert order["makerAmount"] == "966191" - assert order["takerAmount"] == "1932381" - assert order["maker"] == order["signer"] - assert order["metadata"] == "0x" + "0" * 64 - assert order["signature"].startswith("0x") - - assert acceptance.status == "executing" - assert acceptance.taker_order_hash == TAKER_ORDER_HASH - - asyncio.run(run()) - - -def test_accept_combo_quote_polls_until_the_outcome_lands() -> None: - async def run() -> None: - def handler(request: httpx.Request) -> httpx.Response: - if request.method == "POST": - return httpx.Response( - 200, - json={ - "rfq_id": "rfq-1", - "status": "AWAITING_MAKER_CONFIRMATION", - "taker_order_hash": TAKER_ORDER_HASH, - }, - request=request, - ) - assert "POLY_BUILDER_API_KEY" not in request.headers - assert request.headers["POLY_API_KEY"] == FAKE_CREDS.key - return httpx.Response( - 200, json={"rfq_id": "rfq-1", "status": "EXECUTING"}, request=request - ) - - client = await make_eoa_client() - install_builder_gateway_handler(client, handler) - - acceptance = await client.accept_combo_quote(QUOTE) - - assert acceptance.status == "executing" - assert acceptance.taker_order_hash == TAKER_ORDER_HASH - - asyncio.run(run()) - - -def test_accept_combo_quote_reports_maker_decline_as_failed() -> None: - async def run() -> None: - client = await make_eoa_client() - install_builder_gateway_handler( - client, - json_handler( - httpx.Response( - 200, - json={ - "rfq_id": "rfq-1", - "status": "FAILED", - "taker_order_hash": TAKER_ORDER_HASH, - "error": {"code": "MAKER_DECLINED", "message": "maker declined"}, - }, - ) - ), - ) - - acceptance = await client.accept_combo_quote(QUOTE) - - assert acceptance.status == "failed" - assert acceptance.reason is ComboAcceptFailureReason.MAKER_DECLINED - assert acceptance.error is not None - assert acceptance.error.message == "maker declined" - - asyncio.run(run()) - - -def test_accept_combo_quote_reports_expired_window_as_failed() -> None: - async def run() -> None: - client = await make_eoa_client() - install_builder_gateway_handler( - client, - json_handler(httpx.Response(409, json={"error": "expired rfq", "code": "EXPIRED_RFQ"})), - ) - - acceptance = await client.accept_combo_quote(QUOTE) - - assert acceptance.status == "failed" - assert acceptance.reason is ComboAcceptFailureReason.ACCEPTANCE_WINDOW_EXPIRED - - asyncio.run(run()) - - -def test_accept_combo_quote_rejects_invalid_portable_quote() -> None: - async def run() -> None: - client = await make_eoa_client() - portable = json.loads(QUOTE.model_dump_json()) - portable["builder_code"] = "not-a-builder-code" - - with pytest.raises(UserInputError, match="builder_code"): - await client.accept_combo_quote(portable) - - asyncio.run(run()) - - -def test_accept_combo_quote_retries_once_after_transport_drop() -> None: - async def run() -> None: - captured: list[httpx.Request] = [] - - def handler(request: httpx.Request) -> httpx.Response: - captured.append(request) - if len(captured) == 1: - raise httpx.ReadTimeout("connection dropped", request=request) - return httpx.Response( - 200, - json={"rfq_id": "rfq-1", "status": "EXECUTING"}, - request=request, - ) - - client = await make_eoa_client() - install_builder_gateway_handler(client, handler) - - acceptance = await client.accept_combo_quote(QUOTE) - - assert len(captured) == 2 - assert captured[0].content == captured[1].content - assert acceptance.status == "executing" - assert acceptance.taker_order_hash is None - - asyncio.run(run()) - - -def test_sync_accept_combo_quote_retries_once_after_transport_drop() -> None: - captured: list[httpx.Request] = [] - - def handler(request: httpx.Request) -> httpx.Response: - captured.append(request) - if len(captured) == 1: - raise httpx.ReadTimeout("connection dropped", request=request) - return httpx.Response( +@pytest.mark.parametrize( + ("status_code", "payload", "reason"), + [ + ( 200, - json={"rfq_id": "rfq-1", "status": "EXECUTING"}, - request=request, - ) - + { + "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, handler) + install_sync_builder_gateway_handler( + client, json_handler(httpx.Response(status_code, json=payload)) + ) acceptance = client.accept_combo_quote(QUOTE) - assert len(captured) == 2 - assert captured[0].content == captured[1].content - assert acceptance.status == "executing" + assert acceptance.status == "failed" + assert acceptance.reason is reason -def test_accept_combo_quote_retries_once_after_unexpected_response() -> None: +@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] = [] - - def handler(request: httpx.Request) -> httpx.Response: - captured.append(request) - status = "UNKNOWN" if len(captured) == 1 else "EXECUTING" - return httpx.Response( - 200, - json={"rfq_id": "rfq-1", "status": status}, - request=request, - ) - client = await make_eoa_client() - install_builder_gateway_handler(client, handler) + install_builder_gateway_handler(client, accept_retry_handler(failure, captured)) acceptance = await client.accept_combo_quote(QUOTE) @@ -572,20 +270,11 @@ def handler(request: httpx.Request) -> httpx.Response: asyncio.run(run()) -def test_sync_accept_combo_quote_retries_once_after_unexpected_response() -> None: +@pytest.mark.parametrize("failure", ["transport", "unexpected_response"]) +def test_accept_combo_quote_retries_ambiguous_sync_failure(failure: str) -> None: captured: list[httpx.Request] = [] - - def handler(request: httpx.Request) -> httpx.Response: - captured.append(request) - status = "UNKNOWN" if len(captured) == 1 else "EXECUTING" - return httpx.Response( - 200, - json={"rfq_id": "rfq-1", "status": status}, - request=request, - ) - client = make_sync_eoa_client() - install_sync_builder_gateway_handler(client, handler) + install_sync_builder_gateway_handler(client, accept_retry_handler(failure, captured)) acceptance = client.accept_combo_quote(QUOTE) @@ -594,51 +283,6 @@ def handler(request: httpx.Request) -> httpx.Response: assert acceptance.status == "executing" -def test_combo_quote_round_trips_as_json_between_clients() -> None: - async def run() -> None: - requesting_client = await make_eoa_client() - install_builder_gateway_handler( - requesting_client, - json_handler(httpx.Response(200, json=QUOTE_READY)), - ) - result = await requesting_client.request_combo_quote( - leg_position_ids=LEGS, direction="BUY", amount=100 - ) - assert result.quote is not None - - portable = json.loads(result.quote.model_dump_json()) - accepting_client = await make_eoa_client() - install_builder_gateway_handler( - accepting_client, - json_handler(httpx.Response(200, json={"rfq_id": "rfq-1", "status": "EXECUTING"})), - ) - - acceptance = await accepting_client.accept_combo_quote(portable) - - assert acceptance.status == "executing" - - asyncio.run(run()) - - -def test_wait_for_combo_fill_normalizes_confirmed_to_filled() -> None: - client = make_sync_eoa_client() - install_sync_builder_gateway_handler( - client, - json_handler( - httpx.Response(200, json={"rfq_id": "rfq-1", "status": "EXECUTING"}), - httpx.Response(200, json={"rfq_id": "rfq-1", "status": "MINED", "tx_hash": TX_HASH}), - httpx.Response( - 200, json={"rfq_id": "rfq-1", "status": "CONFIRMED", "tx_hash": TX_HASH} - ), - ), - ) - - fill = client.wait_for_combo_fill(rfq_id="rfq-1", polling_interval=0.001) - - assert fill.status is RfqStatus.FILLED - assert fill.tx_hash == TX_HASH - - def test_wait_for_combo_fill_returns_terminal_failure() -> None: client = make_sync_eoa_client() install_sync_builder_gateway_handler( @@ -661,14 +305,17 @@ def test_wait_for_combo_fill_returns_terminal_failure() -> None: fill = client.wait_for_combo_fill(rfq_id="rfq-1") assert fill.status is RfqStatus.FAILED - assert fill.tx_hash is None 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) + 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) @@ -677,50 +324,6 @@ def handler(request: httpx.Request) -> httpx.Response: client.wait_for_combo_fill(rfq_id="rfq-1", timeout=0.01, polling_interval=0.001) -@pytest.mark.parametrize( - ("timeout", "polling_interval"), - [(float("nan"), 1.0), (1.0, float("nan")), (True, 1.0), (1.0, False)], -) -def test_wait_for_combo_fill_rejects_invalid_wait_params( - timeout: float, polling_interval: float -) -> None: - client = make_sync_eoa_client() - - with pytest.raises(UserInputError, match="finite number greater than 0"): - client.wait_for_combo_fill( - rfq_id="rfq-1", timeout=timeout, polling_interval=polling_interval - ) - - -def test_async_wait_for_combo_fill_rejects_nan_wait_params() -> None: - async def run() -> None: - client = await make_eoa_client() - - with pytest.raises(UserInputError, match="finite number greater than 0"): - await client.wait_for_combo_fill(rfq_id="rfq-1", timeout=float("nan")) - - with pytest.raises(UserInputError, match="finite number greater than 0"): - await client.wait_for_combo_fill(rfq_id="rfq-1", polling_interval=float("nan")) - - asyncio.run(run()) - - -def test_fetch_rfq_status_maps_rejections() -> None: - client = make_sync_eoa_client() - install_sync_builder_gateway_handler( - client, - json_handler( - httpx.Response(409, json={"error": "rfq not accepted", "code": "RFQ_NOT_ACCEPTED"}) - ), - ) - - with pytest.raises(RfqRequestRejectedError) as rejected: - client.fetch_rfq_status(rfq_id="rfq-1") - - assert rejected.value.status == 409 - assert rejected.value.code == "RFQ_NOT_ACCEPTED" - - def test_fetch_rfq_status_rejects_mismatched_response_id() -> None: client = make_sync_eoa_client() install_sync_builder_gateway_handler( @@ -732,129 +335,22 @@ def test_fetch_rfq_status_rejects_mismatched_response_id() -> None: client.fetch_rfq_status(rfq_id="rfq-1") -@pytest.mark.parametrize( - ("field", "value"), - [("taker_order_hash", 123), ("tx_hash", None)], -) -def test_fetch_rfq_status_rejects_malformed_optional_hashes(field: str, value: object) -> None: - client = make_sync_eoa_client() - install_sync_builder_gateway_handler( - client, - json_handler( - httpx.Response( - 200, - json={"rfq_id": "rfq-1", "status": "EXECUTING", field: value}, - ) - ), - ) - - with pytest.raises(UnexpectedResponseError, match=field): - client.fetch_rfq_status(rfq_id="rfq-1") - - -def test_sync_client_requests_and_accepts_a_combo_quote() -> None: - captured: list[httpx.Request] = [] - - def handler(request: httpx.Request) -> httpx.Response: - captured.append(request) - if request.url.path.endswith("/accept"): - return httpx.Response( - 200, - json={ - "rfq_id": "rfq-1", - "status": "EXECUTING", - "taker_order_hash": TAKER_ORDER_HASH, - }, - request=request, - ) - return httpx.Response(200, json=QUOTE_READY, request=request) - - client = make_sync_eoa_client() - install_sync_builder_gateway_handler(client, handler) - - result = client.request_combo_quote(leg_position_ids=LEGS, direction="BUY", amount=100) - assert result.quote is not None - - acceptance = client.accept_combo_quote(result.quote) - - accept_request = captured[1] - assert accept_request.url.path == "/v1/builder/rfq/requests/rfq-1/accept" - body = json.loads(accept_request.content.decode("utf-8")) - assert body["quote_id"] == "quote-1" - assert body["signed_order"]["makerAmount"] == "966191" - assert acceptance.status == "executing" - assert acceptance.taker_order_hash == TAKER_ORDER_HASH - - -def test_request_combo_quote_rejects_malformed_condition_id() -> None: - malformed = copy.deepcopy(QUOTE_READY) - malformed["request"]["condition_id"] = "0x04" + "0" * 60 - client = make_sync_eoa_client() - install_sync_builder_gateway_handler(client, json_handler(httpx.Response(200, json=malformed))) - - with pytest.raises(UnexpectedResponseError): - client.request_combo_quote(leg_position_ids=LEGS, direction="BUY", amount=100) - - -def test_request_combo_quote_rejects_quote_in_wrong_lifecycle_status() -> None: - malformed = copy.deepcopy(QUOTE_READY) - malformed["status"] = "EXECUTING" - client = make_sync_eoa_client() - install_sync_builder_gateway_handler(client, json_handler(httpx.Response(200, json=malformed))) - - with pytest.raises(UnexpectedResponseError, match="included a quote"): - client.request_combo_quote(leg_position_ids=LEGS, direction="BUY", amount=100) - - -def test_request_combo_quote_rejects_mismatched_nested_rfq_id() -> None: - malformed = copy.deepcopy(QUOTE_READY) - malformed["request"]["rfq_id"] = "rfq-2" - client = make_sync_eoa_client() - install_sync_builder_gateway_handler(client, json_handler(httpx.Response(200, json=malformed))) - - with pytest.raises(UnexpectedResponseError, match="rfq_id"): - client.request_combo_quote(leg_position_ids=LEGS, direction="BUY", amount=100) - - @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: - malformed = copy.deepcopy(QUOTE_READY) - malformed["request"][field] = value + 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=malformed))) + 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) - - -def test_accept_combo_quote_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"}), - httpx.Response(200, json={"rfq_id": "rfq-2", "status": "EXECUTING"}), - ), - ) - - with pytest.raises(UnexpectedResponseError, match="did not match requested ID"): - client.accept_combo_quote(QUOTE) - - -def test_wait_for_combo_fill_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.wait_for_combo_fill(rfq_id="rfq-1")