From 3a0ccbd423f812eda0a73b4efc6527e2eca932e7 Mon Sep 17 00:00:00 2001 From: magqqgq <146786427+magqqgq@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:58:05 +0300 Subject: [PATCH] fix(sdk-python): harden cryptographic order salts, preserve zero salts, and enforce cancellation preflight validation ### Description This PR hardens the Python SDK against predictable order identifiers, implicit truthiness bugs on order parameters, and unvalidated cancellation requests[cite: 35, 36, 37]. It replaces weak pseudo-random generators with cryptographic selections, protects explicit zero-salt assignments, and enforces preflight token validation prior to order cancellation submission[cite: 35, 36, 37]. ### Key Changes * **Cryptographic Order Salts (`predict_sdk/_internal/utils.py`):** - Replaced the non-cryptographic `random.randint` with Python's standard `secrets.randbelow(MAX_SALT + 1)` to ensure secure, unbiased order salt generation[cite: 35]. * **Zero-Salt Preservation (`predict_sdk/order_builder.py`):** - Replaced truthiness checks (`data.salt`) with an explicit `None` check (`data.salt is not None`). This ensures that an explicitly provided `salt=0` is correctly preserved and serialized as `"0"` instead of being overwritten by a random value[cite: 36]. * **Cancellation Preflight Validation (`predict_sdk/order_builder.py`):** - Enforced the existing `options.with_validation` contract by calling `validate_token_ids_async` before encoding and dispatching `cancelOrders`, preventing late on-chain failures due to incorrect market configurations[cite: 36]. * **Regression Coverage (`tests/test_order_builder.py`):** - Added unit test coverage verifying zero-salt preservation and the correct rejection behavior when invalid token IDs are passed during cancellations[cite: 37]. ### Validation & Testing * **Syntax & Compiling:** Python source files pass syntax compilation cleanly (`python -m py_compile`). --- src/predict_sdk/_internal/utils.py | 392 +-- src/predict_sdk/order_builder.py | 4167 ++++++++++++++-------------- tests/test_order_builder.py | 1758 ++++++------ 3 files changed, 3189 insertions(+), 3128 deletions(-) diff --git a/src/predict_sdk/_internal/utils.py b/src/predict_sdk/_internal/utils.py index 7fc1ac2..5f65cc5 100644 --- a/src/predict_sdk/_internal/utils.py +++ b/src/predict_sdk/_internal/utils.py @@ -1,197 +1,197 @@ -"""Internal utility functions for the Predict SDK.""" - -from __future__ import annotations - -import random +"""Internal utility functions for the Predict SDK.""" + +from __future__ import annotations + +import secrets from decimal import ROUND_DOWN, Decimal -from typing import Any - -from eth_abi import encode # type: ignore[attr-defined] -from web3 import Web3 - -from predict_sdk.constants import MAX_SALT - - -def float_to_wei(value: float, precision: int) -> int: - """ - Convert a floating-point value to wei using exact decimal arithmetic. - - Avoids IEEE 754 floating-point precision errors by converting - the float to a string first, then using Python's Decimal module. - - Uses ROUND_DOWN to match Solidity's integer division behavior. - - Args: - value: The floating-point value to convert (e.g., 0.46 for a price). - precision: The precision multiplier (e.g., 10**18 for wei). - - Returns: - The value converted to wei as an integer. - - Example: - >>> float_to_wei(0.46, 10**18) - 460000000000000000 - >>> float_to_wei(0.421031, 10**18) - 421031000000000000 # Correct! (not 421030999999999936) - """ - d = Decimal(str(value)) * Decimal(precision) - return int(d.quantize(Decimal("1"), rounding=ROUND_DOWN)) - - -def generate_order_salt() -> str: - """ - Generate a random salt for an order. - - Returns: - A random numeric string value for the salt. - """ - return str(random.randint(0, MAX_SALT)) - - -def retain_significant_digits(num: int, significant_digits: int) -> int: - """ - Retain the specified number of significant digits. - - In the case of negative numbers, the significant digits are retained as - expected without the sign affecting the calculation. - - Args: - num: The integer number to truncate. - significant_digits: The number of significant digits to retain. - - Returns: - The integer number with the specified significant digits retained. - """ - if num == 0: - return 0 - - is_negative = num < 0 # Check if the number is negative - abs_num = -num if is_negative else num # Work with the absolute value - - # Convert to string to find magnitude (length before trailing zeros) - str_num = str(abs_num) - magnitude = len(str_num) - - # Calculate divisor to remove excess digits - excess = magnitude - significant_digits - if excess <= 0: - return num # Return original number if no truncation is needed - - divisor: int = 10**excess - - # Divide then multiply to truncate, and restore the sign - result: int = (abs_num // divisor) * divisor - return -result if is_negative else result - - -def hash_kernel_message(message_hash: str) -> str: - """ - Hash a message for Kernel smart wallet. - - Args: - message_hash: The message hash to wrap (hex string with 0x prefix). - - Returns: - The wrapped message hash as a hex string. - """ - # "Kernel(bytes32 hash)" type hash - kernel_type_hash = Web3.keccak(text="Kernel(bytes32 hash)") - - # Convert message_hash from hex string to bytes - message_hash_bytes = ( - bytes.fromhex(message_hash[2:]) - if message_hash.startswith("0x") - else bytes.fromhex(message_hash) - ) - - # Encode [bytes32, bytes32] - encoded = encode(["bytes32", "bytes32"], [kernel_type_hash, message_hash_bytes]) - - return "0x" + Web3.keccak(encoded).hex() - - -def eip712_wrap_hash(message_hash: str, domain: dict[str, Any]) -> str: - """ - Wrap a message hash with EIP-712 domain separator. - - This is used for Predict account (Kernel smart wallet) signing. - - Args: - message_hash: The message hash (hex string with 0x prefix). - domain: The EIP-712 domain containing name, version, chainId, verifyingContract. - - Returns: - The wrapped hash as a hex string. - """ - # Calculate domain separator - domain_separator = _hash_eip712_domain(domain) - - # Get the final message hash using Kernel wrapper - final_message_hash = hash_kernel_message(message_hash) - - # Convert to bytes - final_hash_bytes = ( - bytes.fromhex(final_message_hash[2:]) - if final_message_hash.startswith("0x") - else bytes.fromhex(final_message_hash) - ) - - # Concatenate: 0x1901 + domainSeparator + messageHash - data = b"\x19\x01" + domain_separator + final_hash_bytes - - return "0x" + Web3.keccak(data).hex() - - -def _hash_eip712_domain(domain: dict[str, Any]) -> bytes: - """ - Hash an EIP-712 domain. - - Args: - domain: The domain containing name, version, chainId, verifyingContract. - - Returns: - The domain separator as bytes. - """ - # EIP-712 Domain Type Hash - domain_type = ( - "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" - ) - domain_type_hash = Web3.keccak(text=domain_type) - - # Hash the name and version strings - name_hash = Web3.keccak(text=domain["name"]) - version_hash = Web3.keccak(text=domain["version"]) - - # Convert chainId to int if necessary - chain_id = int(domain["chainId"]) - - # Convert verifyingContract to checksum address if it's a string - verifying_contract = domain["verifyingContract"] - if isinstance(verifying_contract, str): - verifying_contract = Web3.to_checksum_address(verifying_contract) - - # Encode the domain struct - encoded = encode( - ["bytes32", "bytes32", "bytes32", "uint256", "address"], - [domain_type_hash, name_hash, version_hash, chain_id, verifying_contract], - ) - - return Web3.keccak(encoded) - - -def compute_order_hash( - typed_data: dict[str, Any], -) -> str: - """ - Compute the hash of EIP-712 typed data for an order. - - Args: - typed_data: The EIP-712 typed data structure. - - Returns: - The hash as a hex string. - """ - from eth_account.messages import _hash_eip191_message, encode_typed_data - - encoded = encode_typed_data(full_message=typed_data) - return "0x" + _hash_eip191_message(encoded).hex() +from typing import Any + +from eth_abi import encode # type: ignore[attr-defined] +from web3 import Web3 + +from predict_sdk.constants import MAX_SALT + + +def float_to_wei(value: float, precision: int) -> int: + """ + Convert a floating-point value to wei using exact decimal arithmetic. + + Avoids IEEE 754 floating-point precision errors by converting + the float to a string first, then using Python's Decimal module. + + Uses ROUND_DOWN to match Solidity's integer division behavior. + + Args: + value: The floating-point value to convert (e.g., 0.46 for a price). + precision: The precision multiplier (e.g., 10**18 for wei). + + Returns: + The value converted to wei as an integer. + + Example: + >>> float_to_wei(0.46, 10**18) + 460000000000000000 + >>> float_to_wei(0.421031, 10**18) + 421031000000000000 # Correct! (not 421030999999999936) + """ + d = Decimal(str(value)) * Decimal(precision) + return int(d.quantize(Decimal("1"), rounding=ROUND_DOWN)) + + +def generate_order_salt() -> str: + """ + Generate a random salt for an order. + + Returns: + A random numeric string value for the salt. + """ + return str(secrets.randbelow(MAX_SALT + 1)) + + +def retain_significant_digits(num: int, significant_digits: int) -> int: + """ + Retain the specified number of significant digits. + + In the case of negative numbers, the significant digits are retained as + expected without the sign affecting the calculation. + + Args: + num: The integer number to truncate. + significant_digits: The number of significant digits to retain. + + Returns: + The integer number with the specified significant digits retained. + """ + if num == 0: + return 0 + + is_negative = num < 0 # Check if the number is negative + abs_num = -num if is_negative else num # Work with the absolute value + + # Convert to string to find magnitude (length before trailing zeros) + str_num = str(abs_num) + magnitude = len(str_num) + + # Calculate divisor to remove excess digits + excess = magnitude - significant_digits + if excess <= 0: + return num # Return original number if no truncation is needed + + divisor: int = 10**excess + + # Divide then multiply to truncate, and restore the sign + result: int = (abs_num // divisor) * divisor + return -result if is_negative else result + + +def hash_kernel_message(message_hash: str) -> str: + """ + Hash a message for Kernel smart wallet. + + Args: + message_hash: The message hash to wrap (hex string with 0x prefix). + + Returns: + The wrapped message hash as a hex string. + """ + # "Kernel(bytes32 hash)" type hash + kernel_type_hash = Web3.keccak(text="Kernel(bytes32 hash)") + + # Convert message_hash from hex string to bytes + message_hash_bytes = ( + bytes.fromhex(message_hash[2:]) + if message_hash.startswith("0x") + else bytes.fromhex(message_hash) + ) + + # Encode [bytes32, bytes32] + encoded = encode(["bytes32", "bytes32"], [kernel_type_hash, message_hash_bytes]) + + return "0x" + Web3.keccak(encoded).hex() + + +def eip712_wrap_hash(message_hash: str, domain: dict[str, Any]) -> str: + """ + Wrap a message hash with EIP-712 domain separator. + + This is used for Predict account (Kernel smart wallet) signing. + + Args: + message_hash: The message hash (hex string with 0x prefix). + domain: The EIP-712 domain containing name, version, chainId, verifyingContract. + + Returns: + The wrapped hash as a hex string. + """ + # Calculate domain separator + domain_separator = _hash_eip712_domain(domain) + + # Get the final message hash using Kernel wrapper + final_message_hash = hash_kernel_message(message_hash) + + # Convert to bytes + final_hash_bytes = ( + bytes.fromhex(final_message_hash[2:]) + if final_message_hash.startswith("0x") + else bytes.fromhex(final_message_hash) + ) + + # Concatenate: 0x1901 + domainSeparator + messageHash + data = b"\x19\x01" + domain_separator + final_hash_bytes + + return "0x" + Web3.keccak(data).hex() + + +def _hash_eip712_domain(domain: dict[str, Any]) -> bytes: + """ + Hash an EIP-712 domain. + + Args: + domain: The domain containing name, version, chainId, verifyingContract. + + Returns: + The domain separator as bytes. + """ + # EIP-712 Domain Type Hash + domain_type = ( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ) + domain_type_hash = Web3.keccak(text=domain_type) + + # Hash the name and version strings + name_hash = Web3.keccak(text=domain["name"]) + version_hash = Web3.keccak(text=domain["version"]) + + # Convert chainId to int if necessary + chain_id = int(domain["chainId"]) + + # Convert verifyingContract to checksum address if it's a string + verifying_contract = domain["verifyingContract"] + if isinstance(verifying_contract, str): + verifying_contract = Web3.to_checksum_address(verifying_contract) + + # Encode the domain struct + encoded = encode( + ["bytes32", "bytes32", "bytes32", "uint256", "address"], + [domain_type_hash, name_hash, version_hash, chain_id, verifying_contract], + ) + + return Web3.keccak(encoded) + + +def compute_order_hash( + typed_data: dict[str, Any], +) -> str: + """ + Compute the hash of EIP-712 typed data for an order. + + Args: + typed_data: The EIP-712 typed data structure. + + Returns: + The hash as a hex string. + """ + from eth_account.messages import _hash_eip191_message, encode_typed_data + + encoded = encode_typed_data(full_message=typed_data) + return "0x" + _hash_eip191_message(encoded).hex() diff --git a/src/predict_sdk/order_builder.py b/src/predict_sdk/order_builder.py index df5f1eb..ef24831 100644 --- a/src/predict_sdk/order_builder.py +++ b/src/predict_sdk/order_builder.py @@ -1,1975 +1,1986 @@ -"""Main OrderBuilder class for creating and signing orders.""" - -from __future__ import annotations - -import asyncio -import time -from collections.abc import Callable, Coroutine -from datetime import datetime, timezone -from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload - -from eth_account import Account -from eth_account.messages import _hash_eip191_message, encode_defunct, encode_typed_data -from eth_account.signers.local import LocalAccount -from web3 import Web3 -from web3.middleware import ExtraDataToPOAMiddleware - -from predict_sdk._internal.contracts import ( - Contracts, - get_conditional_tokens_contract, - get_exchange_contract, - get_neg_risk_adapter_contract, - make_contract, - make_contracts, -) -from predict_sdk._internal.utils import ( - eip712_wrap_hash, - float_to_wei, - generate_order_salt, - retain_significant_digits, -) -from predict_sdk.abis import KERNEL_ABI -from predict_sdk.constants import ( - ADDRESSES_BY_CHAIN_ID, - APPROVAL_STEP_COPY, - EIP712_DOMAIN, - FIVE_MINUTES_SECONDS, - KERNEL_DOMAIN_BY_CHAIN_ID, - MAX_INT256, - MAX_UINT256, - ORDER_STRUCTURE, - PROTOCOL_NAME, - PROTOCOL_VERSION, - RPC_URLS_BY_CHAIN_ID, - SPENDER_ROLE_BY_KEY, - ZERO_ADDRESS, - ZERO_HASH, - Addresses, - ChainId, - Side, - SignatureType, -) -from predict_sdk.errors import ( - FailedOrderSignError, - FailedTypedDataEncoderError, - InvalidApprovalOperationError, +"""Main OrderBuilder class for creating and signing orders.""" + +from __future__ import annotations + +import asyncio +import time +from collections.abc import Callable, Coroutine +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload + +from eth_account import Account +from eth_account.messages import _hash_eip191_message, encode_defunct, encode_typed_data +from eth_account.signers.local import LocalAccount +from web3 import Web3 +from web3.middleware import ExtraDataToPOAMiddleware + +from predict_sdk._internal.contracts import ( + Contracts, + get_conditional_tokens_contract, + get_exchange_contract, + get_neg_risk_adapter_contract, + make_contract, + make_contracts, +) +from predict_sdk._internal.utils import ( + eip712_wrap_hash, + float_to_wei, + generate_order_salt, + retain_significant_digits, +) +from predict_sdk.abis import KERNEL_ABI +from predict_sdk.constants import ( + ADDRESSES_BY_CHAIN_ID, + APPROVAL_STEP_COPY, + EIP712_DOMAIN, + FIVE_MINUTES_SECONDS, + KERNEL_DOMAIN_BY_CHAIN_ID, + MAX_INT256, + MAX_UINT256, + ORDER_STRUCTURE, + PROTOCOL_NAME, + PROTOCOL_VERSION, + RPC_URLS_BY_CHAIN_ID, + SPENDER_ROLE_BY_KEY, + ZERO_ADDRESS, + ZERO_HASH, + Addresses, + ChainId, + Side, + SignatureType, +) +from predict_sdk.errors import ( + FailedOrderSignError, + FailedTypedDataEncoderError, + InvalidApprovalOperationError, InvalidExpirationError, + InvalidNegRiskConfig, InvalidQuantityError, - InvalidSignerError, - MakerSignerMismatchError, - MissingSignerError, -) -from predict_sdk.logger import Logger -from predict_sdk.types import ( - ApprovalCheck, - ApprovalOperation, - ApprovalProgress, - ApprovalRunReport, - ApprovalScope, - ApprovalStep, - ApprovalStepResult, - ApprovalStepType, - Book, - BuildOrderInput, - CancelOrdersOptions, - DepthLevel, - EIP712TypedData, - LimitHelperInput, - MarketHelperInput, - MarketHelperValueInput, - Order, - OrderAmounts, - OrderBuilderOptions, - ProcessedBookAmounts, - SetApprovalsResult, - SignedOrder, - TransactionFail, - TransactionResult, - TransactionSuccess, -) - -if TYPE_CHECKING: - from web3.contract import Contract - -_T = TypeVar("_T") - - -class OrderBuilder: - """ - Helper class to build, sign, and manage orders. - - Use the static `make()` method to create instances. - """ - - def __init__( - self, - chain_id: ChainId, - precision: int, - addresses: Addresses, - generate_salt_fn: Callable[[], str], - logger: Logger, - signer: LocalAccount | None = None, - predict_account: str | None = None, - contracts: Contracts | None = None, - web3: Web3 | None = None, - ) -> None: - self._chain_id = chain_id - self._precision = 10**precision - self._addresses = addresses - self._generate_salt = generate_salt_fn - self._logger = logger - self._signer = signer - self._predict_account = predict_account - self._contracts = contracts - self._web3 = web3 - self._execution_mode = bytes.fromhex(ZERO_HASH[2:]) - - @overload - @classmethod - def make( - cls, - chain_id: ChainId, - signer: None = None, - options: OrderBuilderOptions | None = None, - ) -> OrderBuilder: ... - - @overload - @classmethod - def make( - cls, - chain_id: ChainId, - signer: LocalAccount | str, - options: OrderBuilderOptions | None = None, - ) -> OrderBuilder: ... - - @classmethod - def make( - cls, - chain_id: ChainId, - signer: LocalAccount | str | None = None, - options: OrderBuilderOptions | None = None, - ) -> OrderBuilder: - """ - Factory method to create an OrderBuilder instance. - - Args: - chain_id: The chain ID for the network. - signer: Optional signer (LocalAccount or private key string). - options: Optional configuration options. - - Returns: - A new OrderBuilder instance. - - Raises: - InvalidSignerError: If the signer is not the owner of the Predict account. - """ - opts = options or OrderBuilderOptions() - addresses = ADDRESSES_BY_CHAIN_ID[chain_id] - precision = opts.precision - generate_salt_fn = opts.generate_salt or generate_order_salt - predict_account = opts.predict_account - logger = Logger(opts.log_level) - - contracts = None - web3 = None - signer_account = None - - if signer is not None: - # Convert private key to account if needed - if isinstance(signer, str): - signer_account = Account.from_key(signer) - else: - signer_account = signer - - # Create Web3 instance - rpc_url = RPC_URLS_BY_CHAIN_ID[chain_id] - web3 = Web3(Web3.HTTPProvider(rpc_url)) - - # Inject POA middleware for BNB chain (required for extraData validation) - if chain_id in (ChainId.BNB_MAINNET, ChainId.BNB_TESTNET): - web3.middleware_onion.inject(ExtraDataToPOAMiddleware, layer=0) - - # Create contract instances - contracts = make_contracts(web3, addresses, signer_account) - - # Validate Predict account ownership if provided - if predict_account: - owner = contracts.ecdsa_validator.functions.ecdsaValidatorStorage( - predict_account - ).call() - if owner != signer_account.address: - raise InvalidSignerError() - - return cls( - chain_id=chain_id, - precision=precision, - addresses=addresses, - generate_salt_fn=generate_salt_fn, - logger=logger, - signer=signer_account, - predict_account=predict_account, - contracts=contracts, - web3=web3, - ) - - @property - def contracts(self) -> Contracts | None: - """Access to contract instances (read-only).""" - return self._contracts - - # --- Order Amount Calculation Methods --- - - def get_limit_order_amounts(self, data: LimitHelperInput) -> OrderAmounts: - """ - Calculate the amounts for a LIMIT strategy order. - - Args: - data: The input data containing side, price, and quantity. - - Returns: - OrderAmounts with price_per_share, maker_amount, and taker_amount. - - Raises: - InvalidQuantityError: If quantity_wei is less than 1e16 or price is invalid. - """ - if data.price_per_share_wei <= 0: - raise InvalidQuantityError("Invalid pricePerShareWei. Must be greater than 0.") - if data.quantity_wei < int(1e16): - raise InvalidQuantityError() - - # Truncate to significant digits for precision - price = retain_significant_digits(data.price_per_share_wei, 3) - qty = retain_significant_digits(data.quantity_wei, 5) - - if price != data.price_per_share_wei: - self._logger.debug( - "getLimitOrderAmounts truncated pricePerShareWei to 3 significant digits" - ) - if qty != data.quantity_wei: - self._logger.debug("getLimitOrderAmounts truncated quantityWei to 5 significant digits") - - if data.side == Side.BUY: - return OrderAmounts( - last_price=price, - price_per_share=price, - maker_amount=(price * qty) // self._precision, - taker_amount=qty, - amount=qty, - slippage_bps=0, - is_min_amount_out=False, - ) - else: # SELL - return OrderAmounts( - last_price=price, - price_per_share=price, - maker_amount=qty, - taker_amount=(price * qty) // self._precision, - amount=qty, - slippage_bps=0, - is_min_amount_out=False, - ) - - def _process_book(self, depths: list[DepthLevel], quantity_wei: int) -> ProcessedBookAmounts: - """ - Process the order book to derive average price and last price for MARKET orders. - - Args: - depths: Array of price levels and their quantities, sorted by price. - quantity_wei: The total quantity of shares in wei. - - Returns: - ProcessedBookAmounts containing quantity, price, and last_price. - """ - result = ProcessedBookAmounts(quantity_wei=0, price_wei=0, last_price_wei=0) - - for price, qty in depths: - remaining_qty_wei = quantity_wei - result.quantity_wei - price_wei = float_to_wei(price, self._precision) - qty_wei = float_to_wei(qty, self._precision) - - if remaining_qty_wei <= 0: - break - - if remaining_qty_wei < qty_wei: - result.quantity_wei += remaining_qty_wei - # Accumulate price * qty without intermediate division to preserve precision - result.price_wei += price_wei * remaining_qty_wei - result.last_price_wei = price_wei - else: - result.quantity_wei += qty_wei - # Accumulate price * qty without intermediate division to preserve precision - result.price_wei += price_wei * qty_wei - result.last_price_wei = price_wei - - return result - - def _get_market_order_amounts_by_quantity( - self, - data: MarketHelperInput, - book: Book, - ) -> OrderAmounts: - """Calculate market order amounts by quantity.""" - qty = retain_significant_digits(data.quantity_wei, 5) - - if qty != data.quantity_wei: - self._logger.debug( - "getMarketOrderAmountsByQuantity truncated quantityWei to 5 significant digits" - ) - - if qty < int(1e16): - raise InvalidQuantityError() - - slippage_bps = data.slippage_bps - - if data.side == Side.BUY: - processed = self._process_book(book.asks, qty) - is_min_amount_out = data.is_min_amount_out - price_per_share = ( - processed.price_wei // processed.quantity_wei if processed.quantity_wei > 0 else 0 - ) - - if is_min_amount_out: - # makerAmount = expected cost (avg price * shares), not worstTierPrice * shares. - # the signed ratio makerAmount/takerAmount equals worstTierPrice/(1-slippage), - # which enables SPLIT (mint) matches at all book price levels while minimising - # the USD commitment so users can spend their full wallet balance. - maker_amount = processed.price_wei // self._precision - # signedShares = expectedCost / worstTierPrice. fewer than actual shares, - # but the OB fills up to `amount` (actual shares), constrained by the USD budget. - signed_shares = ( - processed.price_wei // processed.last_price_wei - if processed.last_price_wei > 0 - else 0 - ) - taker_amount = ( - max((signed_shares * (10_000 - slippage_bps)) // 10_000, 0) - if slippage_bps > 0 - else signed_shares - ) - return OrderAmounts( - last_price=processed.last_price_wei, - price_per_share=price_per_share, - maker_amount=maker_amount, - taker_amount=taker_amount, - amount=processed.quantity_wei, - slippage_bps=slippage_bps, - is_min_amount_out=is_min_amount_out, - ) - - # default: makerAmount = worstTierPrice * shares, inflated by slippage. - # takerAmount = shares (unchanged). - base_maker_amount = ( - processed.last_price_wei * processed.quantity_wei - ) // self._precision - maker_amount = ( - min( - (base_maker_amount * (10_000 + slippage_bps)) // 10_000, - processed.quantity_wei, - ) - if slippage_bps > 0 - else base_maker_amount - ) - return OrderAmounts( - last_price=processed.last_price_wei, - price_per_share=price_per_share, - maker_amount=maker_amount, - taker_amount=processed.quantity_wei, - amount=processed.quantity_wei, - slippage_bps=slippage_bps, - is_min_amount_out=is_min_amount_out, - ) - else: # SELL - processed = self._process_book(book.bids, qty) - base_taker_amount = ( - processed.last_price_wei * processed.quantity_wei - ) // self._precision - # Floor at 0 to prevent underflow - taker_amount = ( - max( - (base_taker_amount * (10_000 - slippage_bps)) // 10_000, - 0, - ) - if slippage_bps > 0 - else base_taker_amount - ) - return OrderAmounts( - last_price=processed.last_price_wei, - # price_wei now contains sum of (price * qty) without division, - # so divide by quantity only (no need to multiply by precision) - price_per_share=processed.price_wei // processed.quantity_wei - if processed.quantity_wei > 0 - else 0, - maker_amount=processed.quantity_wei, - taker_amount=taker_amount, - amount=processed.quantity_wei, - slippage_bps=slippage_bps, - is_min_amount_out=False, - ) - - def _get_market_order_amounts_by_value( - self, - data: MarketHelperValueInput, - book: Book, - ) -> OrderAmounts: - """Calculate market order amounts by value (BUY only).""" - if data.value_wei < int(1e18): - raise InvalidQuantityError() - - currency_amount_wei = data.value_wei - number_of_shares = 0 - total_price = 0 - - for price, qty in book.asks: - price_wei = float_to_wei(price, self._precision) - qty_wei = float_to_wei(qty, self._precision) - - remaining_spend = currency_amount_wei - total_price - - if remaining_spend <= 0: - break - - tier_total_price = (price_wei * qty_wei) // self._precision - - # Check if the market buy can consume this entire price tier - if tier_total_price <= remaining_spend: - number_of_shares += qty_wei - total_price += (price_wei * qty_wei) // self._precision - else: - # Consume as much as we can - fractional_share_amount = ( - (remaining_spend * self._precision) // price_wei if price_wei > 0 else 0 - ) - number_of_shares += fractional_share_amount - total_price += (price_wei * fractional_share_amount) // self._precision - - rounded_shares = retain_significant_digits(number_of_shares, 5) - amounts = self._get_market_order_amounts_by_quantity( - MarketHelperInput( - side=Side.BUY, - quantity_wei=rounded_shares, - slippage_bps=data.slippage_bps, - is_min_amount_out=data.is_min_amount_out, - ), - book, - ) - - return OrderAmounts( - price_per_share=amounts.price_per_share, - maker_amount=amounts.maker_amount, - taker_amount=amounts.taker_amount, - amount=rounded_shares, - last_price=amounts.last_price, - slippage_bps=amounts.slippage_bps, - is_min_amount_out=amounts.is_min_amount_out, - ) - - def get_market_order_amounts( - self, - data: MarketHelperInput | MarketHelperValueInput, - book: Book, - ) -> OrderAmounts: - """ - Calculate the amounts for a MARKET strategy order. - - The order book should be retrieved from the `GET /orderbook/{marketId}` endpoint. - - Args: - data: The input data (quantity or value based). - book: The orderbook data. - - Returns: - OrderAmounts with average price_per_share, maker_amount, and taker_amount. - - Raises: - InvalidQuantityError: If quantity_wei is less than 1e16. - """ - if isinstance(data, MarketHelperValueInput) and data.side == Side.BUY: - return self._get_market_order_amounts_by_value(data, book) - return self._get_market_order_amounts_by_quantity(data, book) - - # --- Order Building Methods --- - - def build_order( - self, - strategy: Literal["MARKET", "LIMIT"], - data: BuildOrderInput, - ) -> Order: - """ - Build an order based on the provided strategy and data. - - The current `feeRateBps` should be fetched via the `GET /markets` endpoint. - The expiration for market orders is ignored. - - Args: - strategy: The order strategy ("MARKET" or "LIMIT"). - data: The order input data. - - Returns: - A constructed Order object. - - Raises: - InvalidExpirationError: If expiration is not in the future (LIMIT only). - MakerSignerMismatchError: If maker and signer don't match. - """ - # Default expiration: 2100-01-01 (arbitrary date for orders without expiration) - expires_at = data.expires_at or datetime(2100, 1, 1, tzinfo=timezone.utc) - limit_expiration = int(expires_at.timestamp()) - market_expiration = int(time.time()) + FIVE_MINUTES_SECONDS - - if self._predict_account and ( - data.maker != self._predict_account or data.signer != self._predict_account - ): - self._logger.warn("When using a Predict account the maker and signer are ignored.") - - if strategy == "MARKET" and data.expires_at: - self._logger.warn("expiresAt for market orders is ignored.") - - if strategy != "MARKET" and expires_at.timestamp() <= time.time(): - raise InvalidExpirationError() - - signer_address = data.signer or (self._signer.address if self._signer else None) - - # Only validate maker/signer match when NOT using a predict_account - # (when using predict_account, maker/signer from data are ignored) - if not self._predict_account and data.maker and signer_address != data.maker: - raise MakerSignerMismatchError() - - # Validate we have a valid signer/maker address - effective_maker = self._predict_account or data.maker or signer_address - effective_signer = self._predict_account or signer_address - if not effective_maker or not effective_signer: - raise MissingSignerError() - - return Order( - salt=str(data.salt or self._generate_salt()), - maker=effective_maker, - signer=effective_signer, - taker=data.taker or ZERO_ADDRESS, - token_id=str(data.token_id), - maker_amount=str(data.maker_amount), - taker_amount=str(data.taker_amount), - expiration=str(market_expiration if strategy == "MARKET" else limit_expiration), - nonce=str(data.nonce or 0), - fee_rate_bps=str(data.fee_rate_bps), - side=data.side, - signature_type=data.signature_type or SignatureType.EOA, - ) - - def _get_exchange_identifier( - self, - is_neg_risk: bool, - is_yield_bearing: bool, - ) -> str: - """Get the exchange contract address based on market type.""" - if is_neg_risk: - if is_yield_bearing: - return self._addresses.YIELD_BEARING_NEG_RISK_CTF_EXCHANGE - return self._addresses.NEG_RISK_CTF_EXCHANGE - else: - if is_yield_bearing: - return self._addresses.YIELD_BEARING_CTF_EXCHANGE - return self._addresses.CTF_EXCHANGE - - def build_typed_data( - self, - order: Order, - *, - is_neg_risk: bool, - is_yield_bearing: bool, - ) -> EIP712TypedData: - """ - Build EIP-712 typed data for an order. - - The param `isNegRisk` can be found via the `GET /markets` or `GET /categories` endpoints. - - Args: - order: The order to build typed data for. - is_neg_risk: Whether this is a NegRisk market. - is_yield_bearing: Whether this is a yield-bearing market. - - Returns: - EIP712TypedData structure for signing. - """ - verifying_contract = self._get_exchange_identifier(is_neg_risk, is_yield_bearing) - - return EIP712TypedData( - primary_type="Order", - types={ - "EIP712Domain": EIP712_DOMAIN, - "Order": ORDER_STRUCTURE, - }, - domain={ - "name": PROTOCOL_NAME, - "version": PROTOCOL_VERSION, - "chainId": self._chain_id, - "verifyingContract": verifying_contract, - }, - message=self._order_to_message(order), - ) - - def _order_to_message(self, order: Order) -> dict[str, Any]: - """Convert Order to EIP-712 message format.""" - return { - "salt": order.salt, - "maker": order.maker, - "signer": order.signer, - "taker": order.taker, - "tokenId": order.token_id, - "makerAmount": order.maker_amount, - "takerAmount": order.taker_amount, - "expiration": order.expiration, - "nonce": order.nonce, - "feeRateBps": order.fee_rate_bps, - "side": order.side, - "signatureType": order.signature_type, - } - - def _message_to_order(self, message: dict[str, Any]) -> Order: - """Convert EIP-712 message to Order.""" - return Order( - salt=str(message["salt"]), - maker=message["maker"], - signer=message["signer"], - taker=message["taker"], - token_id=str(message["tokenId"]), - maker_amount=str(message["makerAmount"]), - taker_amount=str(message["takerAmount"]), - expiration=str(message["expiration"]), - nonce=str(message["nonce"]), - fee_rate_bps=str(message["feeRateBps"]), - side=message["side"], - signature_type=message["signatureType"], - ) - - def build_typed_data_hash(self, typed_data: EIP712TypedData) -> str: - """ - Compute the hash of EIP-712 typed data. - - Args: - typed_data: The typed data to hash. - - Returns: - The hex-encoded hash string. - - Raises: - FailedTypedDataEncoderError: If hashing fails. - """ - try: - structured_data = { - "types": typed_data.types, - "primaryType": typed_data.primary_type, - "domain": typed_data.domain, - "message": typed_data.message, - } - - encoded = encode_typed_data(full_message=structured_data) - return "0x" + _hash_eip191_message(encoded).hex() - except Exception as e: - raise FailedTypedDataEncoderError(e) from e - - def sign_typed_data_order(self, typed_data: EIP712TypedData) -> SignedOrder: - """ - Sign an order using EIP-712 typed data. - - Args: - typed_data: The typed data to sign. - - Returns: - A SignedOrder with the signature attached. - - Raises: - MissingSignerError: If no signer was provided. - FailedOrderSignError: If signing fails. - """ - if not self._signer: - raise MissingSignerError() - - order = self._message_to_order(typed_data.message) - - try: - if self._predict_account: - hash_ = self.build_typed_data_hash(typed_data) - signature = self.sign_predict_account_message({"raw": hash_}) - else: - signature = self._sign_typed_data(typed_data) - - return SignedOrder( - salt=order.salt, - maker=order.maker, - signer=order.signer, - taker=order.taker, - token_id=order.token_id, - maker_amount=order.maker_amount, - taker_amount=order.taker_amount, - expiration=order.expiration, - nonce=order.nonce, - fee_rate_bps=order.fee_rate_bps, - side=order.side, - signature_type=order.signature_type, - signature=signature, - ) - except Exception as e: - raise FailedOrderSignError(e) from e - - async def sign_typed_data_order_async(self, typed_data: EIP712TypedData) -> SignedOrder: - """ - Sign an order using EIP-712 typed data (async). - - This is a convenience async wrapper around the sync method. - Signing is CPU-bound, so this simply calls the sync version. - - Args: - typed_data: The typed data to sign. - - Returns: - A SignedOrder with the signature attached. - - Raises: - MissingSignerError: If no signer was provided. - FailedOrderSignError: If signing fails. - """ - return self.sign_typed_data_order(typed_data) - - def _sign_typed_data(self, typed_data: EIP712TypedData) -> str: - """Sign EIP-712 typed data with the signer.""" - if not self._signer: - raise MissingSignerError() - - structured_data = { - "types": typed_data.types, - "primaryType": typed_data.primary_type, - "domain": typed_data.domain, - "message": typed_data.message, - } - - encoded = encode_typed_data(full_message=structured_data) - signed = self._signer.sign_message(encoded) - return signed.signature.hex() - - def sign_predict_account_message(self, message: str | dict[str, str]) -> str: - """ - Sign a message for a Predict account. - - Args: - message: The message to sign (string or dict with 'raw' key for raw hash). - - Returns: - The signature as a hex string. - - Raises: - MissingSignerError: If no signer or predict_account was provided. - """ - if not self._signer or not self._predict_account: - raise MissingSignerError() - - validator_address = self._addresses.ECDSA_VALIDATOR - kernel_domain = KERNEL_DOMAIN_BY_CHAIN_ID[self._chain_id] - - if isinstance(message, dict) and "raw" in message: - message_hash = message["raw"] - else: - # Use EIP-191 prefix hash to match TS SDK's hashMessage() - msg = encode_defunct(text=str(message)) - message_hash = "0x" + _hash_eip191_message(msg).hex() - - digest = eip712_wrap_hash( - message_hash, - {**kernel_domain, "verifyingContract": self._predict_account}, - ) - - # Sign the digest - message_bytes = ( - bytes.fromhex(digest[2:]) if digest.startswith("0x") else bytes.fromhex(digest) - ) - signable_msg = encode_defunct(primitive=message_bytes) - signed = self._signer.sign_message(signable_msg) - - # Concatenate: 0x01 + validator_address + signature - return "0x01" + validator_address[2:] + signed.signature.hex() - - async def sign_predict_account_message_async(self, message: str | dict[str, str]) -> str: - """ - Sign a message for a Predict account (async). - - This is a convenience async wrapper around the sync method. - Signing is CPU-bound, so this simply calls the sync version. - - Args: - message: The message to sign (string or dict with 'raw' key for raw hash). - - Returns: - The signature as a hex string. - - Raises: - MissingSignerError: If no signer or predict_account was provided. - """ - return self.sign_predict_account_message(message) - - # --- Async Contract Interaction Methods --- - - def _encode_execution_calldata(self, to: str, calldata: str, value: int = 0) -> bytes: - """ - Encode calldata for Kernel's execute function. - - Format: target_address (20 bytes) + value (32 bytes) + calldata - Matches TS SDK implementation at OrderBuilder.ts:307-309 - - Args: - to: Target contract address - calldata: Encoded function call (hex string with 0x prefix) - value: ETH value to send (default 0) - - Returns: - Encoded execution calldata as bytes - """ - # Convert address to bytes (remove 0x, convert to bytes) - address_bytes = bytes.fromhex(to[2:] if to.startswith("0x") else to) - - # Convert value to 32-byte big-endian representation - value_bytes = value.to_bytes(32, byteorder="big") - - # Convert calldata to bytes - calldata_bytes = bytes.fromhex(calldata[2:] if calldata.startswith("0x") else calldata) - - # Concatenate: address + value + calldata - return address_bytes + value_bytes + calldata_bytes - - async def _handle_transaction_async( - self, - contract: Contract, - method_name: str, - *args: Any, - ) -> TransactionResult: - """Handle a transaction safely (async).""" - if not self._contracts or not self._signer or not self._web3: - raise MissingSignerError() - - try: - method = getattr(contract.functions, method_name)(*args) - - # Estimate gas - estimated_gas = method.estimate_gas({"from": self._signer.address}) - gas_limit = (estimated_gas * 125) // 100 - - # Build transaction - tx = method.build_transaction( - { - "from": self._signer.address, - "nonce": self._web3.eth.get_transaction_count(self._signer.address, "pending"), - "gas": gas_limit, - } - ) - - # Sign and send - signed = self._signer.sign_transaction(tx) - tx_hash = self._web3.eth.send_raw_transaction(signed.raw_transaction) - receipt = self._web3.eth.wait_for_transaction_receipt(tx_hash, timeout=120) - - if receipt["status"] == 1: - return TransactionSuccess(success=True, receipt=receipt) - return TransactionFail(success=False, receipt=receipt) - except Exception as e: - return TransactionFail(success=False, cause=e) - - # --- Sync Wrappers --- - - def _run_async(self, coro: Coroutine[Any, Any, _T]) -> _T: - """Run an async coroutine synchronously.""" - try: - asyncio.get_running_loop() - raise RuntimeError( - "Cannot call sync method from within an async context. " - "Use the async variant (e.g., method_async) instead." - ) - except RuntimeError as e: - if "no running event loop" not in str(e): - raise - return asyncio.run(coro) - - # --- Approval Methods --- - - def _approval_owner_address(self) -> str: - """Return the account whose approvals and allowances are being managed.""" - if self._predict_account: - return self._predict_account - if self._signer: - return self._signer.address - raise MissingSignerError() - - async def set_ctf_exchange_approval_async( - self, - *, - is_neg_risk: bool, - is_yield_bearing: bool, - approved: bool = True, - ) -> TransactionResult: - """ - Set ERC-1155 approval for the CTF Exchange (async). - - Args: - is_neg_risk: Whether this is a NegRisk market. - is_yield_bearing: Whether this is a yield-bearing market. - approved: Whether to approve or revoke. - - Returns: - TransactionResult indicating success or failure. - """ - if not self._contracts: - raise MissingSignerError() - - exchange_address = self._get_exchange_identifier(is_neg_risk, is_yield_bearing) - ct_contract = get_conditional_tokens_contract( - self._contracts, - is_neg_risk=is_neg_risk, - is_yield_bearing=is_yield_bearing, - ) - - # skip when the operator is already in the desired state (idempotent, saves gas). - # a read failure is non-fatal: fall through so the send path reports any error. - owner = self._approval_owner_address() - try: - is_approved = ct_contract.functions.isApprovedForAll(owner, exchange_address).call() - except Exception: - is_approved = None - if is_approved == approved: - return TransactionSuccess(success=True) - - if self._predict_account: - encoded = ct_contract.encode_abi( - abi_element_identifier="setApprovalForAll", - args=[exchange_address, approved], - ) - calldata = self._encode_execution_calldata(ct_contract.address, encoded, value=0) - assert self._web3 is not None - kernel_contract = make_contract(self._web3, self._predict_account, KERNEL_ABI) - return await self._handle_transaction_async( - kernel_contract, "execute", self._execution_mode, calldata - ) - else: - return await self._handle_transaction_async( - ct_contract, - "setApprovalForAll", - exchange_address, - approved, - ) - - def set_ctf_exchange_approval( - self, - *, - is_neg_risk: bool, - is_yield_bearing: bool, - approved: bool = True, - ) -> TransactionResult: - """Set ERC-1155 approval for the CTF Exchange (sync).""" - return self._run_async( - self.set_ctf_exchange_approval_async( - is_neg_risk=is_neg_risk, - is_yield_bearing=is_yield_bearing, - approved=approved, - ) - ) - - async def set_neg_risk_adapter_approval_async( - self, - *, - is_yield_bearing: bool, - approved: bool = True, - ) -> TransactionResult: - """Set ERC-1155 approval for the NegRisk Adapter (async).""" - if not self._contracts: - raise MissingSignerError() - - adapter_address = ( - self._addresses.YIELD_BEARING_NEG_RISK_ADAPTER - if is_yield_bearing - else self._addresses.NEG_RISK_ADAPTER - ) - ct_contract = get_conditional_tokens_contract( - self._contracts, - is_neg_risk=True, - is_yield_bearing=is_yield_bearing, - ) - - # skip when the operator is already in the desired state (idempotent, saves gas). - # a read failure is non-fatal: fall through so the send path reports any error. - owner = self._approval_owner_address() - try: - is_approved = ct_contract.functions.isApprovedForAll(owner, adapter_address).call() - except Exception: - is_approved = None - if is_approved == approved: - return TransactionSuccess(success=True) - - if self._predict_account: - encoded = ct_contract.encode_abi( - abi_element_identifier="setApprovalForAll", - args=[adapter_address, approved], - ) - calldata = self._encode_execution_calldata(ct_contract.address, encoded, value=0) - assert self._web3 is not None - kernel_contract = make_contract(self._web3, self._predict_account, KERNEL_ABI) - return await self._handle_transaction_async( - kernel_contract, "execute", self._execution_mode, calldata - ) - else: - return await self._handle_transaction_async( - ct_contract, - "setApprovalForAll", - adapter_address, - approved, - ) - - def set_neg_risk_adapter_approval( - self, - *, - is_yield_bearing: bool, - approved: bool = True, - ) -> TransactionResult: - """Set ERC-1155 approval for the NegRisk Adapter (sync).""" - return self._run_async( - self.set_neg_risk_adapter_approval_async( - is_yield_bearing=is_yield_bearing, - approved=approved, - ) - ) - - async def set_ctf_exchange_allowance_async( - self, - *, - is_neg_risk: bool, - is_yield_bearing: bool, - amount: int = MAX_UINT256, - ) -> TransactionResult: - """Set ERC-20 (USDT) allowance for the CTF Exchange (async).""" - if not self._contracts: - raise MissingSignerError() - - exchange_address = self._get_exchange_identifier(is_neg_risk, is_yield_bearing) - - # skip when the existing allowance already covers the requested amount (idempotent). - # a read failure is non-fatal: fall through so the send path reports any error. - owner = self._approval_owner_address() - try: - current_allowance = self._contracts.usdt.functions.allowance( - owner, exchange_address - ).call() - except Exception: - current_allowance = None - if current_allowance is not None and current_allowance >= amount: - return TransactionSuccess(success=True) - - if self._predict_account: - encoded = self._contracts.usdt.encode_abi( - abi_element_identifier="approve", - args=[exchange_address, amount], - ) - calldata = self._encode_execution_calldata( - self._contracts.usdt.address, encoded, value=0 - ) - assert self._web3 is not None - kernel_contract = make_contract(self._web3, self._predict_account, KERNEL_ABI) - return await self._handle_transaction_async( - kernel_contract, "execute", self._execution_mode, calldata - ) - else: - return await self._handle_transaction_async( - self._contracts.usdt, - "approve", - exchange_address, - amount, - ) - - def set_ctf_exchange_allowance( - self, - *, - is_neg_risk: bool, - is_yield_bearing: bool, - amount: int = MAX_UINT256, - ) -> TransactionResult: - """Set ERC-20 (USDT) allowance for the CTF Exchange (sync).""" - return self._run_async( - self.set_ctf_exchange_allowance_async( - is_neg_risk=is_neg_risk, - is_yield_bearing=is_yield_bearing, - amount=amount, - ) - ) - - async def _set_track_approvals_async(self, is_yield_bearing: bool) -> list[TransactionResult]: - """Set the five approvals required for a single market track (standard or yield-bearing).""" - return [ - # Standard CTF Exchange - await self.set_ctf_exchange_approval_async( - is_neg_risk=False, is_yield_bearing=is_yield_bearing - ), - await self.set_ctf_exchange_allowance_async( - is_neg_risk=False, is_yield_bearing=is_yield_bearing - ), - # NegRisk CTF Exchange - await self.set_ctf_exchange_approval_async( - is_neg_risk=True, is_yield_bearing=is_yield_bearing - ), - await self.set_ctf_exchange_allowance_async( - is_neg_risk=True, is_yield_bearing=is_yield_bearing - ), - # NegRisk Adapter - await self.set_neg_risk_adapter_approval_async(is_yield_bearing=is_yield_bearing), - ] - - async def set_approvals_async( - self, - *, - is_yield_bearing: bool | None = None, - ) -> SetApprovalsResult: - """ - Set all necessary approvals for trading (async). - - By default both the standard and yield-bearing contracts are approved, so the - account is ready to trade every market type from a single call. The operations - are idempotent: any approval already in place is detected on-chain and skipped, - so calling this more than once only sends the transactions that are missing. - - Args: - is_yield_bearing: ``None`` (default) approves both tracks. Pass ``True`` or - ``False`` to limit the run to the yield-bearing or standard track only. - - Returns: - SetApprovalsResult with overall success status and the per-operation results. - """ - tracks: list[bool] = [False, True] if is_yield_bearing is None else [is_yield_bearing] - - results: list[TransactionResult] = [] - for track in tracks: - results.extend(await self._set_track_approvals_async(track)) - - success = all(r.success for r in results) - return SetApprovalsResult(success=success, transactions=results) - - def set_approvals( - self, - *, - is_yield_bearing: bool | None = None, - ) -> SetApprovalsResult: - """Set all necessary approvals for trading (sync). See ``set_approvals_async``.""" - return self._run_async(self.set_approvals_async(is_yield_bearing=is_yield_bearing)) - - # --- Scoped Approvals --- - - def _exchange_key(self, is_neg_risk: bool, is_yield_bearing: bool) -> str: - """Return the Addresses key of the exchange for a market type.""" - if is_neg_risk: - return ( - "YIELD_BEARING_NEG_RISK_CTF_EXCHANGE" - if is_yield_bearing - else "NEG_RISK_CTF_EXCHANGE" - ) - return "YIELD_BEARING_CTF_EXCHANGE" if is_yield_bearing else "CTF_EXCHANGE" - - def _ctf_key(self, is_neg_risk: bool, is_yield_bearing: bool) -> str: - """Return the Addresses key of the conditional tokens contract for a market type.""" - if is_yield_bearing: - return ( - "YIELD_BEARING_NEG_RISK_CONDITIONAL_TOKENS" - if is_neg_risk - else "YIELD_BEARING_CONDITIONAL_TOKENS" - ) - return "NEG_RISK_CONDITIONAL_TOKENS" if is_neg_risk else "CONDITIONAL_TOKENS" - - def _adapter_key(self, is_yield_bearing: bool) -> str: - """Return the Addresses key of the neg risk adapter for a track.""" - return "YIELD_BEARING_NEG_RISK_ADAPTER" if is_yield_bearing else "NEG_RISK_ADAPTER" - - def _make_approval_step( - self, - step_type: ApprovalStepType, - spender_key: str, - token_key: str, - ) -> ApprovalStep: - """Build a self-describing ApprovalStep from a spender/token key pair.""" - role = SPENDER_ROLE_BY_KEY.get(spender_key) - copy = APPROVAL_STEP_COPY.get(f"{role}:{step_type}") if role else None - return ApprovalStep( - id=f"{step_type}:{spender_key}", - type=step_type, - spender=getattr(self._addresses, spender_key), - token=getattr(self._addresses, token_key), - label=copy["label"] if copy else "", - description=copy["description"] if copy else "", - ) - - def get_approval_steps(self, scope: ApprovalScope) -> list[ApprovalStep]: - """ - Return the minimal, ordered set of approvals required for an operation on a market type. - - Pure: requires no signer and performs no network calls. Operations that need no approval - (e.g. a standard MERGE or REDEEM) return an empty list. - - Args: - scope: The operation and market type to scope the approvals to. - - Returns: - The ordered approval steps. - - Raises: - InvalidApprovalOperationError: If CONVERT is requested for a non-neg-risk market. - """ - exchange_key = self._exchange_key(scope.is_neg_risk, scope.is_yield_bearing) - ctf_key = self._ctf_key(scope.is_neg_risk, scope.is_yield_bearing) - adapter_key = self._adapter_key(scope.is_yield_bearing) - - def erc1155(spender_key: str) -> ApprovalStep: - return self._make_approval_step("ERC1155_APPROVAL", spender_key, ctf_key) - - def erc20(spender_key: str) -> ApprovalStep: - return self._make_approval_step("ERC20_ALLOWANCE", spender_key, "USDT") - - op = scope.operation - if op == "TRADE": - steps: list[ApprovalStep] = [] - include_sell = scope.side is None or scope.side == Side.SELL - include_buy = scope.side is None or scope.side == Side.BUY - if include_sell: - steps.append(erc1155(exchange_key)) - # Neg risk matches route minting/merging through the adapter, which moves the - # user's conditional tokens, so the adapter must be approved regardless of side. - if scope.is_neg_risk: - steps.append(erc1155(adapter_key)) - if include_buy: - steps.append(erc20(exchange_key)) - return steps - if op == "SPLIT": - # splitPosition pulls USDT: from the adapter for neg risk, else from the CT contract. - return [erc20(adapter_key)] if scope.is_neg_risk else [erc20(ctf_key)] - if op == "MERGE": - # Neg risk merges burn the user's tokens via the adapter; standard merges burn directly. - return [erc1155(adapter_key)] if scope.is_neg_risk else [] - if op == "REDEEM": - # Neg risk claims redeem via the adapter; standard redemptions burn the user's tokens. - return [erc1155(adapter_key)] if scope.is_neg_risk else [] - if op == "CONVERT": - if not scope.is_neg_risk: - raise InvalidApprovalOperationError("CONVERT is only valid for neg-risk markets.") - return [erc1155(adapter_key)] - raise InvalidApprovalOperationError(f"Unknown approval operation: {op}") - - def get_all_approval_steps( - self, - *, - is_yield_bearing: bool | None = None, - ) -> list[ApprovalStep]: - """ - Return every approval the protocol could require, deduplicated by id. - - Spans both market types (standard and neg risk) and, by default, both tracks (standard and - yield-bearing). This is the per-step, progress-reportable equivalent of set_approvals() - (and a slight superset, since it also includes the split allowances). Pure: needs no signer. - - Args: - is_yield_bearing: Limit to a single track. When None (default), both tracks are included. - - Returns: - The full, deduplicated list of approval steps. - """ - tracks = [False, True] if is_yield_bearing is None else [is_yield_bearing] - seen: set[str] = set() - steps: list[ApprovalStep] = [] - - for yb in tracks: - for is_neg_risk in (False, True): - # CONVERT is neg-risk only; its single step is already covered by the others. - operations: list[ApprovalOperation] = ( - ["TRADE", "SPLIT", "MERGE", "REDEEM", "CONVERT"] - if is_neg_risk - else ["TRADE", "SPLIT", "MERGE", "REDEEM"] - ) - for operation in operations: - scope = ApprovalScope( - operation=operation, is_neg_risk=is_neg_risk, is_yield_bearing=yb - ) - for step in self.get_approval_steps(scope): - if step.id not in seen: - seen.add(step.id) - steps.append(step) - - return steps - - def _resolve_token_contract(self, step: ApprovalStep) -> Contract: - """Resolve the web3 contract an approval step acts on (USDT or a CT contract).""" - if not self._contracts: - raise MissingSignerError() - if step.type == "ERC20_ALLOWANCE": - return self._contracts.usdt - target = Web3.to_checksum_address(step.token) - for ct in ( - self._contracts.conditional_tokens, - self._contracts.neg_risk_conditional_tokens, - self._contracts.yield_bearing_conditional_tokens, - self._contracts.yield_bearing_neg_risk_conditional_tokens, - ): - if ct.address == target: - return ct - raise ValueError(f"Unknown approval token: {step.token}") - - async def check_approval_async(self, step: ApprovalStep) -> bool: - """Check whether a single approval step is already satisfied on-chain (async).""" - if not self._contracts: - raise MissingSignerError() - - owner = self._approval_owner_address() - - if step.type == "ERC20_ALLOWANCE": - allowance: int = self._contracts.usdt.functions.allowance(owner, step.spender).call() - return allowance >= MAX_INT256 - - ct = self._resolve_token_contract(step) - approved: bool = ct.functions.isApprovedForAll(owner, step.spender).call() - return approved - - def check_approval(self, step: ApprovalStep) -> bool: - """Check whether a single approval step is already satisfied on-chain (sync).""" - return self._run_async(self.check_approval_async(step)) - - async def check_approvals_async(self, steps: list[ApprovalStep]) -> list[ApprovalCheck]: - """ - Check whether each approval step is already satisfied on-chain (async). - - Note: unlike the TypeScript SDK, this issues the reads sequentially (no multicall). - """ - results: list[ApprovalCheck] = [] - for step in steps: - satisfied = await self.check_approval_async(step) - results.append(ApprovalCheck(step=step, satisfied=satisfied)) - return results - - def check_approvals(self, steps: list[ApprovalStep]) -> list[ApprovalCheck]: - """Check whether each approval step is already satisfied on-chain (sync).""" - return self._run_async(self.check_approvals_async(steps)) - - async def _set_erc1155_approval_async( - self, token_contract: Contract, spender: str, approved: bool - ) -> TransactionResult: - """Set ERC-1155 setApprovalForAll for an operator (EOA or Predict-account Kernel path).""" - if self._predict_account: - encoded = token_contract.encode_abi( - abi_element_identifier="setApprovalForAll", - args=[spender, approved], - ) - calldata = self._encode_execution_calldata(token_contract.address, encoded, value=0) - assert self._web3 is not None - kernel_contract = make_contract(self._web3, self._predict_account, KERNEL_ABI) - return await self._handle_transaction_async( - kernel_contract, "execute", self._execution_mode, calldata - ) - return await self._handle_transaction_async( - token_contract, "setApprovalForAll", spender, approved - ) - - async def _set_erc20_allowance_async(self, spender: str, amount: int) -> TransactionResult: - """Set ERC-20 (USDT) allowance for a spender (EOA or Predict-account Kernel path).""" - assert self._contracts is not None - usdt = self._contracts.usdt - if self._predict_account: - encoded = usdt.encode_abi( - abi_element_identifier="approve", - args=[spender, amount], - ) - calldata = self._encode_execution_calldata(usdt.address, encoded, value=0) - assert self._web3 is not None - kernel_contract = make_contract(self._web3, self._predict_account, KERNEL_ABI) - return await self._handle_transaction_async( - kernel_contract, "execute", self._execution_mode, calldata - ) - return await self._handle_transaction_async(usdt, "approve", spender, amount) - - async def set_approval_async( - self, - step: ApprovalStep, - *, - approved: bool = True, - amount: int = MAX_UINT256, - ) -> TransactionResult: - """ - Execute a single approval step on-chain (async). Raw send: does not pre-check. - - Args: - step: The step to execute. - approved: ERC-1155: approve (default) or revoke (False). ERC-20: when False, revokes by - setting the allowance to 0 (ignoring ``amount``). - amount: ERC-20 only: the allowance to set when approving. Defaults to MAX_UINT256. - - Returns: - The transaction result. - """ - if not self._contracts: - raise MissingSignerError() - - if step.type == "ERC1155_APPROVAL": - token_contract = self._resolve_token_contract(step) - return await self._set_erc1155_approval_async(token_contract, step.spender, approved) - - # For ERC-20, ``approved=False`` revokes by setting the allowance to 0. - return await self._set_erc20_allowance_async(step.spender, amount if approved else 0) - - def set_approval( - self, - step: ApprovalStep, - *, - approved: bool = True, - amount: int = MAX_UINT256, - ) -> TransactionResult: - """Execute a single approval step on-chain (sync).""" - return self._run_async(self.set_approval_async(step, approved=approved, amount=amount)) - - async def run_approvals_async( - self, - steps: list[ApprovalStep], - *, - skip_satisfied: bool = True, - stop_on_error: bool = True, - on_progress: Callable[[ApprovalProgress], None] | None = None, - ) -> ApprovalRunReport: - """ - Run the given approval steps in order, reporting progress (async). - - Duplicate steps (by id) are removed, so you can pass a union of scopes or a curated subset. - Produce the steps with get_approval_steps(scope) (one operation) or get_all_approval_steps() - (everything). By default each step is checked first and skipped if already satisfied, and the - run stops on the first failure. Use check_approval + set_approval directly when you need finer - control (e.g. gating each step on a user confirmation). - - Args: - steps: The steps to run (e.g. from get_approval_steps / get_all_approval_steps). - skip_satisfied: When True (default), skip steps already in place. - stop_on_error: When True (default), stop after the first failure. - on_progress: Optional callback invoked as each step transitions. - - Returns: - The per-step report and overall success. - """ - # Dedupe by id (first occurrence wins) so unioned/curated step lists "just work". - seen: set[str] = set() - unique_steps: list[ApprovalStep] = [] - for step in steps: - if step.id not in seen: - seen.add(step.id) - unique_steps.append(step) - - results: list[ApprovalStepResult] = [] - success = True - - for step in unique_steps: - if skip_satisfied: - if on_progress: - on_progress(ApprovalProgress(step=step, status="checking")) - # A pre-check read failure is non-fatal: fall through to the send path (matching - # the legacy approval helpers) rather than aborting the whole run. - try: - already_satisfied = await self.check_approval_async(step) - except Exception: - already_satisfied = False - if already_satisfied: - if on_progress: - on_progress(ApprovalProgress(step=step, status="skipped")) - results.append(ApprovalStepResult(step=step, status="skipped")) - continue - - if on_progress: - on_progress(ApprovalProgress(step=step, status="submitting")) - - transaction = await self.set_approval_async(step) - status: Literal["confirmed", "failed"] = ( - "confirmed" if transaction.success else "failed" - ) - - if on_progress: - on_progress(ApprovalProgress(step=step, status=status, transaction=transaction)) - results.append(ApprovalStepResult(step=step, status=status, transaction=transaction)) - - if not transaction.success: - success = False - if stop_on_error: - break - - return ApprovalRunReport(success=success, steps=results) - - def run_approvals( - self, - steps: list[ApprovalStep], - *, - skip_satisfied: bool = True, - stop_on_error: bool = True, - on_progress: Callable[[ApprovalProgress], None] | None = None, - ) -> ApprovalRunReport: - """Run the given approval steps in order (sync). See run_approvals_async.""" - return self._run_async( - self.run_approvals_async( - steps, - skip_satisfied=skip_satisfied, - stop_on_error=stop_on_error, - on_progress=on_progress, - ) - ) - - # --- Balance Methods --- - - async def balance_of_async( - self, - token: Literal["USDT"] = "USDT", - address: str | None = None, - ) -> int: - """ - Get the token balance for an address (async). - - Args: - token: The token to check (currently only "USDT"). - address: The address to check (defaults to signer). - - Returns: - The balance in wei. - """ - if not self._contracts: - raise MissingSignerError() - - check_address = ( - address or self._predict_account or (self._signer.address if self._signer else None) - ) - if not check_address: - raise MissingSignerError() - - result: int = self._contracts.usdt.functions.balanceOf(check_address).call() - return result - - def balance_of( - self, - token: Literal["USDT"] = "USDT", - address: str | None = None, - ) -> int: - """Get the token balance for an address (sync).""" - return self._run_async(self.balance_of_async(token, address)) - - # --- Redemption Methods --- - - async def redeem_positions_async( - self, - condition_id: str, - index_set: Literal[1, 2], - amount: int | None = None, - *, - is_neg_risk: bool, - is_yield_bearing: bool, - ) -> TransactionResult: - """ - Redeem positions for a market (async). - - Args: - condition_id: The condition ID. - index_set: The index set (1 or 2). - amount: The amount to redeem. Required for NegRisk markets. - is_neg_risk: Whether this is a NegRisk (winner-takes-all) market. - is_yield_bearing: Whether this is a yield-bearing market. - - Returns: - TransactionResult indicating success or failure. - - Raises: - MissingSignerError: If signer was not provided. - ValueError: If amount is not provided for NegRisk markets. - """ - if not self._contracts: - raise MissingSignerError() - - if is_neg_risk: - if amount is None: - raise ValueError("amount is required for NegRisk markets") - - adapter_contract = get_neg_risk_adapter_contract( - self._contracts, - is_yield_bearing=is_yield_bearing, - ) - amounts = [amount, 0] if index_set == 1 else [0, amount] - - if self._predict_account: - encoded = adapter_contract.encode_abi( - abi_element_identifier="redeemPositions", - args=[condition_id, amounts], - ) - calldata = self._encode_execution_calldata( - adapter_contract.address, encoded, value=0 - ) - assert self._web3 is not None - kernel_contract = make_contract(self._web3, self._predict_account, KERNEL_ABI) - return await self._handle_transaction_async( - kernel_contract, "execute", self._execution_mode, calldata - ) - else: - return await self._handle_transaction_async( - adapter_contract, - "redeemPositions", - condition_id, - amounts, - ) - else: - ct_contract = get_conditional_tokens_contract( - self._contracts, - is_neg_risk=False, - is_yield_bearing=is_yield_bearing, - ) - amounts = [index_set] - - if self._predict_account: - encoded = ct_contract.encode_abi( - abi_element_identifier="redeemPositions", - args=[ - self._addresses.USDT, - bytes.fromhex(ZERO_HASH[2:]), - condition_id, - amounts, - ], - ) - calldata = self._encode_execution_calldata(ct_contract.address, encoded, value=0) - assert self._web3 is not None - kernel_contract = make_contract(self._web3, self._predict_account, KERNEL_ABI) - return await self._handle_transaction_async( - kernel_contract, "execute", self._execution_mode, calldata - ) - else: - return await self._handle_transaction_async( - ct_contract, - "redeemPositions", - self._addresses.USDT, - bytes.fromhex(ZERO_HASH[2:]), - condition_id, - amounts, - ) - - def redeem_positions( - self, - condition_id: str, - index_set: Literal[1, 2], - amount: int | None = None, - *, - is_neg_risk: bool, - is_yield_bearing: bool, - ) -> TransactionResult: - """Redeem positions for a market (sync).""" - return self._run_async( - self.redeem_positions_async( - condition_id, - index_set, - amount, - is_neg_risk=is_neg_risk, - is_yield_bearing=is_yield_bearing, - ) - ) - - # --- Merge Positions Methods --- - - async def merge_positions_async( - self, - condition_id: str, - amount: int, - *, - is_neg_risk: bool, - is_yield_bearing: bool, - ) -> TransactionResult: - """ - Merge both outcome tokens back into collateral (USDT) (async). - - This combines both outcome tokens (YES and NO) back into the collateral token. - Both outcome positions must have equal amounts to merge. - - Args: - condition_id: The condition ID to merge positions for. - amount: The amount of each outcome token to merge. - is_neg_risk: Whether this is a NegRisk (winner-takes-all) market. - is_yield_bearing: Whether this is a yield-bearing market. - - Returns: - TransactionResult indicating success or failure. - """ - if not self._contracts: - raise MissingSignerError() - - if is_neg_risk: - # NegRisk markets use the adapter contract - adapter_contract = get_neg_risk_adapter_contract( - self._contracts, - is_yield_bearing=is_yield_bearing, - ) - - if self._predict_account: - encoded = adapter_contract.encode_abi( - abi_element_identifier="mergePositions", - args=[condition_id, amount], - ) - calldata = self._encode_execution_calldata( - adapter_contract.address, encoded, value=0 - ) - assert self._web3 is not None - kernel_contract = make_contract(self._web3, self._predict_account, KERNEL_ABI) - return await self._handle_transaction_async( - kernel_contract, "execute", self._execution_mode, calldata - ) - else: - return await self._handle_transaction_async( - adapter_contract, - "mergePositions", - condition_id, - amount, - ) - else: - # Standard markets use the conditional tokens contract - ct_contract = get_conditional_tokens_contract( - self._contracts, - is_neg_risk=False, - is_yield_bearing=is_yield_bearing, - ) - partition = [1, 2] # Both outcomes - - if self._predict_account: - encoded = ct_contract.encode_abi( - abi_element_identifier="mergePositions", - args=[ - self._addresses.USDT, - bytes.fromhex(ZERO_HASH[2:]), - condition_id, - partition, - amount, - ], - ) - calldata = self._encode_execution_calldata(ct_contract.address, encoded, value=0) - assert self._web3 is not None - kernel_contract = make_contract(self._web3, self._predict_account, KERNEL_ABI) - return await self._handle_transaction_async( - kernel_contract, "execute", self._execution_mode, calldata - ) - else: - return await self._handle_transaction_async( - ct_contract, - "mergePositions", - self._addresses.USDT, - bytes.fromhex(ZERO_HASH[2:]), - condition_id, - partition, - amount, - ) - - def merge_positions( - self, - condition_id: str, - amount: int, - *, - is_neg_risk: bool, - is_yield_bearing: bool, - ) -> TransactionResult: - """Merge both outcome tokens back into collateral (USDT) (sync).""" - return self._run_async( - self.merge_positions_async( - condition_id, amount, is_neg_risk=is_neg_risk, is_yield_bearing=is_yield_bearing - ) - ) - - # --- Split Positions Methods --- - - async def split_positions_async( - self, - condition_id: str, - amount: int, - *, - is_neg_risk: bool, - is_yield_bearing: bool, - ) -> TransactionResult: - """ - Split collateral (USDT) into outcome tokens (async). - - This splits the collateral token into both outcome tokens for a condition. - The amount specified will be converted into equal amounts of each outcome token. - - Args: - condition_id: The condition ID to split positions for. - amount: The amount of collateral to split into outcome tokens. - is_neg_risk: Whether this is a NegRisk (winner-takes-all) market. - is_yield_bearing: Whether this is a yield-bearing market. - - Returns: - TransactionResult indicating success or failure. - """ - if not self._contracts: - raise MissingSignerError() - - if is_neg_risk: - # NegRisk markets use the adapter contract - adapter_contract = get_neg_risk_adapter_contract( - self._contracts, - is_yield_bearing=is_yield_bearing, - ) - - if self._predict_account: - encoded = adapter_contract.encode_abi( - abi_element_identifier="splitPosition", - args=[condition_id, amount], - ) - calldata = self._encode_execution_calldata( - adapter_contract.address, encoded, value=0 - ) - assert self._web3 is not None - kernel_contract = make_contract(self._web3, self._predict_account, KERNEL_ABI) - return await self._handle_transaction_async( - kernel_contract, "execute", self._execution_mode, calldata - ) - else: - return await self._handle_transaction_async( - adapter_contract, - "splitPosition", - condition_id, - amount, - ) - else: - # Standard markets use the conditional tokens contract - ct_contract = get_conditional_tokens_contract( - self._contracts, - is_neg_risk=False, - is_yield_bearing=is_yield_bearing, - ) - partition = [1, 2] # Both outcomes - - if self._predict_account: - encoded = ct_contract.encode_abi( - abi_element_identifier="splitPosition", - args=[ - self._addresses.USDT, - bytes.fromhex(ZERO_HASH[2:]), - condition_id, - partition, - amount, - ], - ) - calldata = self._encode_execution_calldata(ct_contract.address, encoded, value=0) - assert self._web3 is not None - kernel_contract = make_contract(self._web3, self._predict_account, KERNEL_ABI) - return await self._handle_transaction_async( - kernel_contract, "execute", self._execution_mode, calldata - ) - else: - return await self._handle_transaction_async( - ct_contract, - "splitPosition", - self._addresses.USDT, - bytes.fromhex(ZERO_HASH[2:]), - condition_id, - partition, - amount, - ) - - def split_positions( - self, - condition_id: str, - amount: int, - *, - is_neg_risk: bool, - is_yield_bearing: bool, - ) -> TransactionResult: - """Split collateral (USDT) into outcome tokens (sync).""" - return self._run_async( - self.split_positions_async( - condition_id, amount, is_neg_risk=is_neg_risk, is_yield_bearing=is_yield_bearing - ) - ) - - # --- Convert Positions Methods --- - - async def convert_positions_async( - self, - neg_risk_on_chain_id: str, - index_set: int, - amount: int, - *, - is_yield_bearing: bool, - ) -> TransactionResult: - """ - Convert a set of NO positions in a NegRisk market (async). - - Burns the given amount of each NO position in the index set and returns the same - amount of each complementary YES position, plus collateral (USDT) proportional to - the number of NO positions converted minus one. If the market has a fee, it is - taken from both the collateral and the YES tokens. Only NegRisk markets support - conversions. - - Args: - neg_risk_on_chain_id: The category's on-chain NegRisk market ID (32-byte - hex), as returned by the API. This is not the numeric API id of a - market or category. - index_set: Bitmask of the NO positions to convert, where bit `n` is the - market's question at index `n`. - amount: The amount of each NO position to convert. - is_yield_bearing: Whether this is a yield-bearing market. - - Returns: - TransactionResult indicating success or failure. - """ - if not self._contracts: - raise MissingSignerError() - - # Conversions only exist on NegRisk markets and always go through the adapter - adapter_contract = get_neg_risk_adapter_contract( - self._contracts, - is_yield_bearing=is_yield_bearing, - ) - - if self._predict_account: - encoded = adapter_contract.encode_abi( - abi_element_identifier="convertPositions", - args=[neg_risk_on_chain_id, index_set, amount], - ) - calldata = self._encode_execution_calldata(adapter_contract.address, encoded, value=0) - assert self._web3 is not None - kernel_contract = make_contract(self._web3, self._predict_account, KERNEL_ABI) - return await self._handle_transaction_async( - kernel_contract, "execute", self._execution_mode, calldata - ) - else: - return await self._handle_transaction_async( - adapter_contract, - "convertPositions", - neg_risk_on_chain_id, - index_set, - amount, - ) - - def convert_positions( - self, - neg_risk_on_chain_id: str, - index_set: int, - amount: int, - *, - is_yield_bearing: bool, - ) -> TransactionResult: - """Convert a set of NO positions in a NegRisk market (sync).""" - return self._run_async( - self.convert_positions_async( - neg_risk_on_chain_id, index_set, amount, is_yield_bearing=is_yield_bearing + InvalidSignerError, + MakerSignerMismatchError, + MissingSignerError, +) +from predict_sdk.logger import Logger +from predict_sdk.types import ( + ApprovalCheck, + ApprovalOperation, + ApprovalProgress, + ApprovalRunReport, + ApprovalScope, + ApprovalStep, + ApprovalStepResult, + ApprovalStepType, + Book, + BuildOrderInput, + CancelOrdersOptions, + DepthLevel, + EIP712TypedData, + LimitHelperInput, + MarketHelperInput, + MarketHelperValueInput, + Order, + OrderAmounts, + OrderBuilderOptions, + ProcessedBookAmounts, + SetApprovalsResult, + SignedOrder, + TransactionFail, + TransactionResult, + TransactionSuccess, +) + +if TYPE_CHECKING: + from web3.contract import Contract + +_T = TypeVar("_T") + + +class OrderBuilder: + """ + Helper class to build, sign, and manage orders. + + Use the static `make()` method to create instances. + """ + + def __init__( + self, + chain_id: ChainId, + precision: int, + addresses: Addresses, + generate_salt_fn: Callable[[], str], + logger: Logger, + signer: LocalAccount | None = None, + predict_account: str | None = None, + contracts: Contracts | None = None, + web3: Web3 | None = None, + ) -> None: + self._chain_id = chain_id + self._precision = 10**precision + self._addresses = addresses + self._generate_salt = generate_salt_fn + self._logger = logger + self._signer = signer + self._predict_account = predict_account + self._contracts = contracts + self._web3 = web3 + self._execution_mode = bytes.fromhex(ZERO_HASH[2:]) + + @overload + @classmethod + def make( + cls, + chain_id: ChainId, + signer: None = None, + options: OrderBuilderOptions | None = None, + ) -> OrderBuilder: ... + + @overload + @classmethod + def make( + cls, + chain_id: ChainId, + signer: LocalAccount | str, + options: OrderBuilderOptions | None = None, + ) -> OrderBuilder: ... + + @classmethod + def make( + cls, + chain_id: ChainId, + signer: LocalAccount | str | None = None, + options: OrderBuilderOptions | None = None, + ) -> OrderBuilder: + """ + Factory method to create an OrderBuilder instance. + + Args: + chain_id: The chain ID for the network. + signer: Optional signer (LocalAccount or private key string). + options: Optional configuration options. + + Returns: + A new OrderBuilder instance. + + Raises: + InvalidSignerError: If the signer is not the owner of the Predict account. + """ + opts = options or OrderBuilderOptions() + addresses = ADDRESSES_BY_CHAIN_ID[chain_id] + precision = opts.precision + generate_salt_fn = opts.generate_salt or generate_order_salt + predict_account = opts.predict_account + logger = Logger(opts.log_level) + + contracts = None + web3 = None + signer_account = None + + if signer is not None: + # Convert private key to account if needed + if isinstance(signer, str): + signer_account = Account.from_key(signer) + else: + signer_account = signer + + # Create Web3 instance + rpc_url = RPC_URLS_BY_CHAIN_ID[chain_id] + web3 = Web3(Web3.HTTPProvider(rpc_url)) + + # Inject POA middleware for BNB chain (required for extraData validation) + if chain_id in (ChainId.BNB_MAINNET, ChainId.BNB_TESTNET): + web3.middleware_onion.inject(ExtraDataToPOAMiddleware, layer=0) + + # Create contract instances + contracts = make_contracts(web3, addresses, signer_account) + + # Validate Predict account ownership if provided + if predict_account: + owner = contracts.ecdsa_validator.functions.ecdsaValidatorStorage( + predict_account + ).call() + if owner != signer_account.address: + raise InvalidSignerError() + + return cls( + chain_id=chain_id, + precision=precision, + addresses=addresses, + generate_salt_fn=generate_salt_fn, + logger=logger, + signer=signer_account, + predict_account=predict_account, + contracts=contracts, + web3=web3, + ) + + @property + def contracts(self) -> Contracts | None: + """Access to contract instances (read-only).""" + return self._contracts + + # --- Order Amount Calculation Methods --- + + def get_limit_order_amounts(self, data: LimitHelperInput) -> OrderAmounts: + """ + Calculate the amounts for a LIMIT strategy order. + + Args: + data: The input data containing side, price, and quantity. + + Returns: + OrderAmounts with price_per_share, maker_amount, and taker_amount. + + Raises: + InvalidQuantityError: If quantity_wei is less than 1e16 or price is invalid. + """ + if data.price_per_share_wei <= 0: + raise InvalidQuantityError("Invalid pricePerShareWei. Must be greater than 0.") + if data.quantity_wei < int(1e16): + raise InvalidQuantityError() + + # Truncate to significant digits for precision + price = retain_significant_digits(data.price_per_share_wei, 3) + qty = retain_significant_digits(data.quantity_wei, 5) + + if price != data.price_per_share_wei: + self._logger.debug( + "getLimitOrderAmounts truncated pricePerShareWei to 3 significant digits" + ) + if qty != data.quantity_wei: + self._logger.debug("getLimitOrderAmounts truncated quantityWei to 5 significant digits") + + if data.side == Side.BUY: + return OrderAmounts( + last_price=price, + price_per_share=price, + maker_amount=(price * qty) // self._precision, + taker_amount=qty, + amount=qty, + slippage_bps=0, + is_min_amount_out=False, + ) + else: # SELL + return OrderAmounts( + last_price=price, + price_per_share=price, + maker_amount=qty, + taker_amount=(price * qty) // self._precision, + amount=qty, + slippage_bps=0, + is_min_amount_out=False, + ) + + def _process_book(self, depths: list[DepthLevel], quantity_wei: int) -> ProcessedBookAmounts: + """ + Process the order book to derive average price and last price for MARKET orders. + + Args: + depths: Array of price levels and their quantities, sorted by price. + quantity_wei: The total quantity of shares in wei. + + Returns: + ProcessedBookAmounts containing quantity, price, and last_price. + """ + result = ProcessedBookAmounts(quantity_wei=0, price_wei=0, last_price_wei=0) + + for price, qty in depths: + remaining_qty_wei = quantity_wei - result.quantity_wei + price_wei = float_to_wei(price, self._precision) + qty_wei = float_to_wei(qty, self._precision) + + if remaining_qty_wei <= 0: + break + + if remaining_qty_wei < qty_wei: + result.quantity_wei += remaining_qty_wei + # Accumulate price * qty without intermediate division to preserve precision + result.price_wei += price_wei * remaining_qty_wei + result.last_price_wei = price_wei + else: + result.quantity_wei += qty_wei + # Accumulate price * qty without intermediate division to preserve precision + result.price_wei += price_wei * qty_wei + result.last_price_wei = price_wei + + return result + + def _get_market_order_amounts_by_quantity( + self, + data: MarketHelperInput, + book: Book, + ) -> OrderAmounts: + """Calculate market order amounts by quantity.""" + qty = retain_significant_digits(data.quantity_wei, 5) + + if qty != data.quantity_wei: + self._logger.debug( + "getMarketOrderAmountsByQuantity truncated quantityWei to 5 significant digits" + ) + + if qty < int(1e16): + raise InvalidQuantityError() + + slippage_bps = data.slippage_bps + + if data.side == Side.BUY: + processed = self._process_book(book.asks, qty) + is_min_amount_out = data.is_min_amount_out + price_per_share = ( + processed.price_wei // processed.quantity_wei if processed.quantity_wei > 0 else 0 + ) + + if is_min_amount_out: + # makerAmount = expected cost (avg price * shares), not worstTierPrice * shares. + # the signed ratio makerAmount/takerAmount equals worstTierPrice/(1-slippage), + # which enables SPLIT (mint) matches at all book price levels while minimising + # the USD commitment so users can spend their full wallet balance. + maker_amount = processed.price_wei // self._precision + # signedShares = expectedCost / worstTierPrice. fewer than actual shares, + # but the OB fills up to `amount` (actual shares), constrained by the USD budget. + signed_shares = ( + processed.price_wei // processed.last_price_wei + if processed.last_price_wei > 0 + else 0 + ) + taker_amount = ( + max((signed_shares * (10_000 - slippage_bps)) // 10_000, 0) + if slippage_bps > 0 + else signed_shares + ) + return OrderAmounts( + last_price=processed.last_price_wei, + price_per_share=price_per_share, + maker_amount=maker_amount, + taker_amount=taker_amount, + amount=processed.quantity_wei, + slippage_bps=slippage_bps, + is_min_amount_out=is_min_amount_out, + ) + + # default: makerAmount = worstTierPrice * shares, inflated by slippage. + # takerAmount = shares (unchanged). + base_maker_amount = ( + processed.last_price_wei * processed.quantity_wei + ) // self._precision + maker_amount = ( + min( + (base_maker_amount * (10_000 + slippage_bps)) // 10_000, + processed.quantity_wei, + ) + if slippage_bps > 0 + else base_maker_amount + ) + return OrderAmounts( + last_price=processed.last_price_wei, + price_per_share=price_per_share, + maker_amount=maker_amount, + taker_amount=processed.quantity_wei, + amount=processed.quantity_wei, + slippage_bps=slippage_bps, + is_min_amount_out=is_min_amount_out, + ) + else: # SELL + processed = self._process_book(book.bids, qty) + base_taker_amount = ( + processed.last_price_wei * processed.quantity_wei + ) // self._precision + # Floor at 0 to prevent underflow + taker_amount = ( + max( + (base_taker_amount * (10_000 - slippage_bps)) // 10_000, + 0, + ) + if slippage_bps > 0 + else base_taker_amount + ) + return OrderAmounts( + last_price=processed.last_price_wei, + # price_wei now contains sum of (price * qty) without division, + # so divide by quantity only (no need to multiply by precision) + price_per_share=processed.price_wei // processed.quantity_wei + if processed.quantity_wei > 0 + else 0, + maker_amount=processed.quantity_wei, + taker_amount=taker_amount, + amount=processed.quantity_wei, + slippage_bps=slippage_bps, + is_min_amount_out=False, + ) + + def _get_market_order_amounts_by_value( + self, + data: MarketHelperValueInput, + book: Book, + ) -> OrderAmounts: + """Calculate market order amounts by value (BUY only).""" + if data.value_wei < int(1e18): + raise InvalidQuantityError() + + currency_amount_wei = data.value_wei + number_of_shares = 0 + total_price = 0 + + for price, qty in book.asks: + price_wei = float_to_wei(price, self._precision) + qty_wei = float_to_wei(qty, self._precision) + + remaining_spend = currency_amount_wei - total_price + + if remaining_spend <= 0: + break + + tier_total_price = (price_wei * qty_wei) // self._precision + + # Check if the market buy can consume this entire price tier + if tier_total_price <= remaining_spend: + number_of_shares += qty_wei + total_price += (price_wei * qty_wei) // self._precision + else: + # Consume as much as we can + fractional_share_amount = ( + (remaining_spend * self._precision) // price_wei if price_wei > 0 else 0 + ) + number_of_shares += fractional_share_amount + total_price += (price_wei * fractional_share_amount) // self._precision + + rounded_shares = retain_significant_digits(number_of_shares, 5) + amounts = self._get_market_order_amounts_by_quantity( + MarketHelperInput( + side=Side.BUY, + quantity_wei=rounded_shares, + slippage_bps=data.slippage_bps, + is_min_amount_out=data.is_min_amount_out, + ), + book, + ) + + return OrderAmounts( + price_per_share=amounts.price_per_share, + maker_amount=amounts.maker_amount, + taker_amount=amounts.taker_amount, + amount=rounded_shares, + last_price=amounts.last_price, + slippage_bps=amounts.slippage_bps, + is_min_amount_out=amounts.is_min_amount_out, + ) + + def get_market_order_amounts( + self, + data: MarketHelperInput | MarketHelperValueInput, + book: Book, + ) -> OrderAmounts: + """ + Calculate the amounts for a MARKET strategy order. + + The order book should be retrieved from the `GET /orderbook/{marketId}` endpoint. + + Args: + data: The input data (quantity or value based). + book: The orderbook data. + + Returns: + OrderAmounts with average price_per_share, maker_amount, and taker_amount. + + Raises: + InvalidQuantityError: If quantity_wei is less than 1e16. + """ + if isinstance(data, MarketHelperValueInput) and data.side == Side.BUY: + return self._get_market_order_amounts_by_value(data, book) + return self._get_market_order_amounts_by_quantity(data, book) + + # --- Order Building Methods --- + + def build_order( + self, + strategy: Literal["MARKET", "LIMIT"], + data: BuildOrderInput, + ) -> Order: + """ + Build an order based on the provided strategy and data. + + The current `feeRateBps` should be fetched via the `GET /markets` endpoint. + The expiration for market orders is ignored. + + Args: + strategy: The order strategy ("MARKET" or "LIMIT"). + data: The order input data. + + Returns: + A constructed Order object. + + Raises: + InvalidExpirationError: If expiration is not in the future (LIMIT only). + MakerSignerMismatchError: If maker and signer don't match. + """ + # Default expiration: 2100-01-01 (arbitrary date for orders without expiration) + expires_at = data.expires_at or datetime(2100, 1, 1, tzinfo=timezone.utc) + limit_expiration = int(expires_at.timestamp()) + market_expiration = int(time.time()) + FIVE_MINUTES_SECONDS + + if self._predict_account and ( + data.maker != self._predict_account or data.signer != self._predict_account + ): + self._logger.warn("When using a Predict account the maker and signer are ignored.") + + if strategy == "MARKET" and data.expires_at: + self._logger.warn("expiresAt for market orders is ignored.") + + if strategy != "MARKET" and expires_at.timestamp() <= time.time(): + raise InvalidExpirationError() + + signer_address = data.signer or (self._signer.address if self._signer else None) + + # Only validate maker/signer match when NOT using a predict_account + # (when using predict_account, maker/signer from data are ignored) + if not self._predict_account and data.maker and signer_address != data.maker: + raise MakerSignerMismatchError() + + # Validate we have a valid signer/maker address + effective_maker = self._predict_account or data.maker or signer_address + effective_signer = self._predict_account or signer_address + if not effective_maker or not effective_signer: + raise MissingSignerError() + + return Order( + salt=str(data.salt if data.salt is not None else self._generate_salt()), + maker=effective_maker, + signer=effective_signer, + taker=data.taker or ZERO_ADDRESS, + token_id=str(data.token_id), + maker_amount=str(data.maker_amount), + taker_amount=str(data.taker_amount), + expiration=str(market_expiration if strategy == "MARKET" else limit_expiration), + nonce=str(data.nonce or 0), + fee_rate_bps=str(data.fee_rate_bps), + side=data.side, + signature_type=data.signature_type or SignatureType.EOA, + ) + + def _get_exchange_identifier( + self, + is_neg_risk: bool, + is_yield_bearing: bool, + ) -> str: + """Get the exchange contract address based on market type.""" + if is_neg_risk: + if is_yield_bearing: + return self._addresses.YIELD_BEARING_NEG_RISK_CTF_EXCHANGE + return self._addresses.NEG_RISK_CTF_EXCHANGE + else: + if is_yield_bearing: + return self._addresses.YIELD_BEARING_CTF_EXCHANGE + return self._addresses.CTF_EXCHANGE + + def build_typed_data( + self, + order: Order, + *, + is_neg_risk: bool, + is_yield_bearing: bool, + ) -> EIP712TypedData: + """ + Build EIP-712 typed data for an order. + + The param `isNegRisk` can be found via the `GET /markets` or `GET /categories` endpoints. + + Args: + order: The order to build typed data for. + is_neg_risk: Whether this is a NegRisk market. + is_yield_bearing: Whether this is a yield-bearing market. + + Returns: + EIP712TypedData structure for signing. + """ + verifying_contract = self._get_exchange_identifier(is_neg_risk, is_yield_bearing) + + return EIP712TypedData( + primary_type="Order", + types={ + "EIP712Domain": EIP712_DOMAIN, + "Order": ORDER_STRUCTURE, + }, + domain={ + "name": PROTOCOL_NAME, + "version": PROTOCOL_VERSION, + "chainId": self._chain_id, + "verifyingContract": verifying_contract, + }, + message=self._order_to_message(order), + ) + + def _order_to_message(self, order: Order) -> dict[str, Any]: + """Convert Order to EIP-712 message format.""" + return { + "salt": order.salt, + "maker": order.maker, + "signer": order.signer, + "taker": order.taker, + "tokenId": order.token_id, + "makerAmount": order.maker_amount, + "takerAmount": order.taker_amount, + "expiration": order.expiration, + "nonce": order.nonce, + "feeRateBps": order.fee_rate_bps, + "side": order.side, + "signatureType": order.signature_type, + } + + def _message_to_order(self, message: dict[str, Any]) -> Order: + """Convert EIP-712 message to Order.""" + return Order( + salt=str(message["salt"]), + maker=message["maker"], + signer=message["signer"], + taker=message["taker"], + token_id=str(message["tokenId"]), + maker_amount=str(message["makerAmount"]), + taker_amount=str(message["takerAmount"]), + expiration=str(message["expiration"]), + nonce=str(message["nonce"]), + fee_rate_bps=str(message["feeRateBps"]), + side=message["side"], + signature_type=message["signatureType"], + ) + + def build_typed_data_hash(self, typed_data: EIP712TypedData) -> str: + """ + Compute the hash of EIP-712 typed data. + + Args: + typed_data: The typed data to hash. + + Returns: + The hex-encoded hash string. + + Raises: + FailedTypedDataEncoderError: If hashing fails. + """ + try: + structured_data = { + "types": typed_data.types, + "primaryType": typed_data.primary_type, + "domain": typed_data.domain, + "message": typed_data.message, + } + + encoded = encode_typed_data(full_message=structured_data) + return "0x" + _hash_eip191_message(encoded).hex() + except Exception as e: + raise FailedTypedDataEncoderError(e) from e + + def sign_typed_data_order(self, typed_data: EIP712TypedData) -> SignedOrder: + """ + Sign an order using EIP-712 typed data. + + Args: + typed_data: The typed data to sign. + + Returns: + A SignedOrder with the signature attached. + + Raises: + MissingSignerError: If no signer was provided. + FailedOrderSignError: If signing fails. + """ + if not self._signer: + raise MissingSignerError() + + order = self._message_to_order(typed_data.message) + + try: + if self._predict_account: + hash_ = self.build_typed_data_hash(typed_data) + signature = self.sign_predict_account_message({"raw": hash_}) + else: + signature = self._sign_typed_data(typed_data) + + return SignedOrder( + salt=order.salt, + maker=order.maker, + signer=order.signer, + taker=order.taker, + token_id=order.token_id, + maker_amount=order.maker_amount, + taker_amount=order.taker_amount, + expiration=order.expiration, + nonce=order.nonce, + fee_rate_bps=order.fee_rate_bps, + side=order.side, + signature_type=order.signature_type, + signature=signature, + ) + except Exception as e: + raise FailedOrderSignError(e) from e + + async def sign_typed_data_order_async(self, typed_data: EIP712TypedData) -> SignedOrder: + """ + Sign an order using EIP-712 typed data (async). + + This is a convenience async wrapper around the sync method. + Signing is CPU-bound, so this simply calls the sync version. + + Args: + typed_data: The typed data to sign. + + Returns: + A SignedOrder with the signature attached. + + Raises: + MissingSignerError: If no signer was provided. + FailedOrderSignError: If signing fails. + """ + return self.sign_typed_data_order(typed_data) + + def _sign_typed_data(self, typed_data: EIP712TypedData) -> str: + """Sign EIP-712 typed data with the signer.""" + if not self._signer: + raise MissingSignerError() + + structured_data = { + "types": typed_data.types, + "primaryType": typed_data.primary_type, + "domain": typed_data.domain, + "message": typed_data.message, + } + + encoded = encode_typed_data(full_message=structured_data) + signed = self._signer.sign_message(encoded) + return signed.signature.hex() + + def sign_predict_account_message(self, message: str | dict[str, str]) -> str: + """ + Sign a message for a Predict account. + + Args: + message: The message to sign (string or dict with 'raw' key for raw hash). + + Returns: + The signature as a hex string. + + Raises: + MissingSignerError: If no signer or predict_account was provided. + """ + if not self._signer or not self._predict_account: + raise MissingSignerError() + + validator_address = self._addresses.ECDSA_VALIDATOR + kernel_domain = KERNEL_DOMAIN_BY_CHAIN_ID[self._chain_id] + + if isinstance(message, dict) and "raw" in message: + message_hash = message["raw"] + else: + # Use EIP-191 prefix hash to match TS SDK's hashMessage() + msg = encode_defunct(text=str(message)) + message_hash = "0x" + _hash_eip191_message(msg).hex() + + digest = eip712_wrap_hash( + message_hash, + {**kernel_domain, "verifyingContract": self._predict_account}, + ) + + # Sign the digest + message_bytes = ( + bytes.fromhex(digest[2:]) if digest.startswith("0x") else bytes.fromhex(digest) + ) + signable_msg = encode_defunct(primitive=message_bytes) + signed = self._signer.sign_message(signable_msg) + + # Concatenate: 0x01 + validator_address + signature + return "0x01" + validator_address[2:] + signed.signature.hex() + + async def sign_predict_account_message_async(self, message: str | dict[str, str]) -> str: + """ + Sign a message for a Predict account (async). + + This is a convenience async wrapper around the sync method. + Signing is CPU-bound, so this simply calls the sync version. + + Args: + message: The message to sign (string or dict with 'raw' key for raw hash). + + Returns: + The signature as a hex string. + + Raises: + MissingSignerError: If no signer or predict_account was provided. + """ + return self.sign_predict_account_message(message) + + # --- Async Contract Interaction Methods --- + + def _encode_execution_calldata(self, to: str, calldata: str, value: int = 0) -> bytes: + """ + Encode calldata for Kernel's execute function. + + Format: target_address (20 bytes) + value (32 bytes) + calldata + Matches TS SDK implementation at OrderBuilder.ts:307-309 + + Args: + to: Target contract address + calldata: Encoded function call (hex string with 0x prefix) + value: ETH value to send (default 0) + + Returns: + Encoded execution calldata as bytes + """ + # Convert address to bytes (remove 0x, convert to bytes) + address_bytes = bytes.fromhex(to[2:] if to.startswith("0x") else to) + + # Convert value to 32-byte big-endian representation + value_bytes = value.to_bytes(32, byteorder="big") + + # Convert calldata to bytes + calldata_bytes = bytes.fromhex(calldata[2:] if calldata.startswith("0x") else calldata) + + # Concatenate: address + value + calldata + return address_bytes + value_bytes + calldata_bytes + + async def _handle_transaction_async( + self, + contract: Contract, + method_name: str, + *args: Any, + ) -> TransactionResult: + """Handle a transaction safely (async).""" + if not self._contracts or not self._signer or not self._web3: + raise MissingSignerError() + + try: + method = getattr(contract.functions, method_name)(*args) + + # Estimate gas + estimated_gas = method.estimate_gas({"from": self._signer.address}) + gas_limit = (estimated_gas * 125) // 100 + + # Build transaction + tx = method.build_transaction( + { + "from": self._signer.address, + "nonce": self._web3.eth.get_transaction_count(self._signer.address, "pending"), + "gas": gas_limit, + } + ) + + # Sign and send + signed = self._signer.sign_transaction(tx) + tx_hash = self._web3.eth.send_raw_transaction(signed.raw_transaction) + receipt = self._web3.eth.wait_for_transaction_receipt(tx_hash, timeout=120) + + if receipt["status"] == 1: + return TransactionSuccess(success=True, receipt=receipt) + return TransactionFail(success=False, receipt=receipt) + except Exception as e: + return TransactionFail(success=False, cause=e) + + # --- Sync Wrappers --- + + def _run_async(self, coro: Coroutine[Any, Any, _T]) -> _T: + """Run an async coroutine synchronously.""" + try: + asyncio.get_running_loop() + raise RuntimeError( + "Cannot call sync method from within an async context. " + "Use the async variant (e.g., method_async) instead." + ) + except RuntimeError as e: + if "no running event loop" not in str(e): + raise + return asyncio.run(coro) + + # --- Approval Methods --- + + def _approval_owner_address(self) -> str: + """Return the account whose approvals and allowances are being managed.""" + if self._predict_account: + return self._predict_account + if self._signer: + return self._signer.address + raise MissingSignerError() + + async def set_ctf_exchange_approval_async( + self, + *, + is_neg_risk: bool, + is_yield_bearing: bool, + approved: bool = True, + ) -> TransactionResult: + """ + Set ERC-1155 approval for the CTF Exchange (async). + + Args: + is_neg_risk: Whether this is a NegRisk market. + is_yield_bearing: Whether this is a yield-bearing market. + approved: Whether to approve or revoke. + + Returns: + TransactionResult indicating success or failure. + """ + if not self._contracts: + raise MissingSignerError() + + exchange_address = self._get_exchange_identifier(is_neg_risk, is_yield_bearing) + ct_contract = get_conditional_tokens_contract( + self._contracts, + is_neg_risk=is_neg_risk, + is_yield_bearing=is_yield_bearing, + ) + + # skip when the operator is already in the desired state (idempotent, saves gas). + # a read failure is non-fatal: fall through so the send path reports any error. + owner = self._approval_owner_address() + try: + is_approved = ct_contract.functions.isApprovedForAll(owner, exchange_address).call() + except Exception: + is_approved = None + if is_approved == approved: + return TransactionSuccess(success=True) + + if self._predict_account: + encoded = ct_contract.encode_abi( + abi_element_identifier="setApprovalForAll", + args=[exchange_address, approved], + ) + calldata = self._encode_execution_calldata(ct_contract.address, encoded, value=0) + assert self._web3 is not None + kernel_contract = make_contract(self._web3, self._predict_account, KERNEL_ABI) + return await self._handle_transaction_async( + kernel_contract, "execute", self._execution_mode, calldata + ) + else: + return await self._handle_transaction_async( + ct_contract, + "setApprovalForAll", + exchange_address, + approved, + ) + + def set_ctf_exchange_approval( + self, + *, + is_neg_risk: bool, + is_yield_bearing: bool, + approved: bool = True, + ) -> TransactionResult: + """Set ERC-1155 approval for the CTF Exchange (sync).""" + return self._run_async( + self.set_ctf_exchange_approval_async( + is_neg_risk=is_neg_risk, + is_yield_bearing=is_yield_bearing, + approved=approved, + ) + ) + + async def set_neg_risk_adapter_approval_async( + self, + *, + is_yield_bearing: bool, + approved: bool = True, + ) -> TransactionResult: + """Set ERC-1155 approval for the NegRisk Adapter (async).""" + if not self._contracts: + raise MissingSignerError() + + adapter_address = ( + self._addresses.YIELD_BEARING_NEG_RISK_ADAPTER + if is_yield_bearing + else self._addresses.NEG_RISK_ADAPTER + ) + ct_contract = get_conditional_tokens_contract( + self._contracts, + is_neg_risk=True, + is_yield_bearing=is_yield_bearing, + ) + + # skip when the operator is already in the desired state (idempotent, saves gas). + # a read failure is non-fatal: fall through so the send path reports any error. + owner = self._approval_owner_address() + try: + is_approved = ct_contract.functions.isApprovedForAll(owner, adapter_address).call() + except Exception: + is_approved = None + if is_approved == approved: + return TransactionSuccess(success=True) + + if self._predict_account: + encoded = ct_contract.encode_abi( + abi_element_identifier="setApprovalForAll", + args=[adapter_address, approved], + ) + calldata = self._encode_execution_calldata(ct_contract.address, encoded, value=0) + assert self._web3 is not None + kernel_contract = make_contract(self._web3, self._predict_account, KERNEL_ABI) + return await self._handle_transaction_async( + kernel_contract, "execute", self._execution_mode, calldata + ) + else: + return await self._handle_transaction_async( + ct_contract, + "setApprovalForAll", + adapter_address, + approved, + ) + + def set_neg_risk_adapter_approval( + self, + *, + is_yield_bearing: bool, + approved: bool = True, + ) -> TransactionResult: + """Set ERC-1155 approval for the NegRisk Adapter (sync).""" + return self._run_async( + self.set_neg_risk_adapter_approval_async( + is_yield_bearing=is_yield_bearing, + approved=approved, + ) + ) + + async def set_ctf_exchange_allowance_async( + self, + *, + is_neg_risk: bool, + is_yield_bearing: bool, + amount: int = MAX_UINT256, + ) -> TransactionResult: + """Set ERC-20 (USDT) allowance for the CTF Exchange (async).""" + if not self._contracts: + raise MissingSignerError() + + exchange_address = self._get_exchange_identifier(is_neg_risk, is_yield_bearing) + + # skip when the existing allowance already covers the requested amount (idempotent). + # a read failure is non-fatal: fall through so the send path reports any error. + owner = self._approval_owner_address() + try: + current_allowance = self._contracts.usdt.functions.allowance( + owner, exchange_address + ).call() + except Exception: + current_allowance = None + if current_allowance is not None and current_allowance >= amount: + return TransactionSuccess(success=True) + + if self._predict_account: + encoded = self._contracts.usdt.encode_abi( + abi_element_identifier="approve", + args=[exchange_address, amount], + ) + calldata = self._encode_execution_calldata( + self._contracts.usdt.address, encoded, value=0 + ) + assert self._web3 is not None + kernel_contract = make_contract(self._web3, self._predict_account, KERNEL_ABI) + return await self._handle_transaction_async( + kernel_contract, "execute", self._execution_mode, calldata + ) + else: + return await self._handle_transaction_async( + self._contracts.usdt, + "approve", + exchange_address, + amount, + ) + + def set_ctf_exchange_allowance( + self, + *, + is_neg_risk: bool, + is_yield_bearing: bool, + amount: int = MAX_UINT256, + ) -> TransactionResult: + """Set ERC-20 (USDT) allowance for the CTF Exchange (sync).""" + return self._run_async( + self.set_ctf_exchange_allowance_async( + is_neg_risk=is_neg_risk, + is_yield_bearing=is_yield_bearing, + amount=amount, + ) + ) + + async def _set_track_approvals_async(self, is_yield_bearing: bool) -> list[TransactionResult]: + """Set the five approvals required for a single market track (standard or yield-bearing).""" + return [ + # Standard CTF Exchange + await self.set_ctf_exchange_approval_async( + is_neg_risk=False, is_yield_bearing=is_yield_bearing + ), + await self.set_ctf_exchange_allowance_async( + is_neg_risk=False, is_yield_bearing=is_yield_bearing + ), + # NegRisk CTF Exchange + await self.set_ctf_exchange_approval_async( + is_neg_risk=True, is_yield_bearing=is_yield_bearing + ), + await self.set_ctf_exchange_allowance_async( + is_neg_risk=True, is_yield_bearing=is_yield_bearing + ), + # NegRisk Adapter + await self.set_neg_risk_adapter_approval_async(is_yield_bearing=is_yield_bearing), + ] + + async def set_approvals_async( + self, + *, + is_yield_bearing: bool | None = None, + ) -> SetApprovalsResult: + """ + Set all necessary approvals for trading (async). + + By default both the standard and yield-bearing contracts are approved, so the + account is ready to trade every market type from a single call. The operations + are idempotent: any approval already in place is detected on-chain and skipped, + so calling this more than once only sends the transactions that are missing. + + Args: + is_yield_bearing: ``None`` (default) approves both tracks. Pass ``True`` or + ``False`` to limit the run to the yield-bearing or standard track only. + + Returns: + SetApprovalsResult with overall success status and the per-operation results. + """ + tracks: list[bool] = [False, True] if is_yield_bearing is None else [is_yield_bearing] + + results: list[TransactionResult] = [] + for track in tracks: + results.extend(await self._set_track_approvals_async(track)) + + success = all(r.success for r in results) + return SetApprovalsResult(success=success, transactions=results) + + def set_approvals( + self, + *, + is_yield_bearing: bool | None = None, + ) -> SetApprovalsResult: + """Set all necessary approvals for trading (sync). See ``set_approvals_async``.""" + return self._run_async(self.set_approvals_async(is_yield_bearing=is_yield_bearing)) + + # --- Scoped Approvals --- + + def _exchange_key(self, is_neg_risk: bool, is_yield_bearing: bool) -> str: + """Return the Addresses key of the exchange for a market type.""" + if is_neg_risk: + return ( + "YIELD_BEARING_NEG_RISK_CTF_EXCHANGE" + if is_yield_bearing + else "NEG_RISK_CTF_EXCHANGE" + ) + return "YIELD_BEARING_CTF_EXCHANGE" if is_yield_bearing else "CTF_EXCHANGE" + + def _ctf_key(self, is_neg_risk: bool, is_yield_bearing: bool) -> str: + """Return the Addresses key of the conditional tokens contract for a market type.""" + if is_yield_bearing: + return ( + "YIELD_BEARING_NEG_RISK_CONDITIONAL_TOKENS" + if is_neg_risk + else "YIELD_BEARING_CONDITIONAL_TOKENS" + ) + return "NEG_RISK_CONDITIONAL_TOKENS" if is_neg_risk else "CONDITIONAL_TOKENS" + + def _adapter_key(self, is_yield_bearing: bool) -> str: + """Return the Addresses key of the neg risk adapter for a track.""" + return "YIELD_BEARING_NEG_RISK_ADAPTER" if is_yield_bearing else "NEG_RISK_ADAPTER" + + def _make_approval_step( + self, + step_type: ApprovalStepType, + spender_key: str, + token_key: str, + ) -> ApprovalStep: + """Build a self-describing ApprovalStep from a spender/token key pair.""" + role = SPENDER_ROLE_BY_KEY.get(spender_key) + copy = APPROVAL_STEP_COPY.get(f"{role}:{step_type}") if role else None + return ApprovalStep( + id=f"{step_type}:{spender_key}", + type=step_type, + spender=getattr(self._addresses, spender_key), + token=getattr(self._addresses, token_key), + label=copy["label"] if copy else "", + description=copy["description"] if copy else "", + ) + + def get_approval_steps(self, scope: ApprovalScope) -> list[ApprovalStep]: + """ + Return the minimal, ordered set of approvals required for an operation on a market type. + + Pure: requires no signer and performs no network calls. Operations that need no approval + (e.g. a standard MERGE or REDEEM) return an empty list. + + Args: + scope: The operation and market type to scope the approvals to. + + Returns: + The ordered approval steps. + + Raises: + InvalidApprovalOperationError: If CONVERT is requested for a non-neg-risk market. + """ + exchange_key = self._exchange_key(scope.is_neg_risk, scope.is_yield_bearing) + ctf_key = self._ctf_key(scope.is_neg_risk, scope.is_yield_bearing) + adapter_key = self._adapter_key(scope.is_yield_bearing) + + def erc1155(spender_key: str) -> ApprovalStep: + return self._make_approval_step("ERC1155_APPROVAL", spender_key, ctf_key) + + def erc20(spender_key: str) -> ApprovalStep: + return self._make_approval_step("ERC20_ALLOWANCE", spender_key, "USDT") + + op = scope.operation + if op == "TRADE": + steps: list[ApprovalStep] = [] + include_sell = scope.side is None or scope.side == Side.SELL + include_buy = scope.side is None or scope.side == Side.BUY + if include_sell: + steps.append(erc1155(exchange_key)) + # Neg risk matches route minting/merging through the adapter, which moves the + # user's conditional tokens, so the adapter must be approved regardless of side. + if scope.is_neg_risk: + steps.append(erc1155(adapter_key)) + if include_buy: + steps.append(erc20(exchange_key)) + return steps + if op == "SPLIT": + # splitPosition pulls USDT: from the adapter for neg risk, else from the CT contract. + return [erc20(adapter_key)] if scope.is_neg_risk else [erc20(ctf_key)] + if op == "MERGE": + # Neg risk merges burn the user's tokens via the adapter; standard merges burn directly. + return [erc1155(adapter_key)] if scope.is_neg_risk else [] + if op == "REDEEM": + # Neg risk claims redeem via the adapter; standard redemptions burn the user's tokens. + return [erc1155(adapter_key)] if scope.is_neg_risk else [] + if op == "CONVERT": + if not scope.is_neg_risk: + raise InvalidApprovalOperationError("CONVERT is only valid for neg-risk markets.") + return [erc1155(adapter_key)] + raise InvalidApprovalOperationError(f"Unknown approval operation: {op}") + + def get_all_approval_steps( + self, + *, + is_yield_bearing: bool | None = None, + ) -> list[ApprovalStep]: + """ + Return every approval the protocol could require, deduplicated by id. + + Spans both market types (standard and neg risk) and, by default, both tracks (standard and + yield-bearing). This is the per-step, progress-reportable equivalent of set_approvals() + (and a slight superset, since it also includes the split allowances). Pure: needs no signer. + + Args: + is_yield_bearing: Limit to a single track. When None (default), both tracks are included. + + Returns: + The full, deduplicated list of approval steps. + """ + tracks = [False, True] if is_yield_bearing is None else [is_yield_bearing] + seen: set[str] = set() + steps: list[ApprovalStep] = [] + + for yb in tracks: + for is_neg_risk in (False, True): + # CONVERT is neg-risk only; its single step is already covered by the others. + operations: list[ApprovalOperation] = ( + ["TRADE", "SPLIT", "MERGE", "REDEEM", "CONVERT"] + if is_neg_risk + else ["TRADE", "SPLIT", "MERGE", "REDEEM"] + ) + for operation in operations: + scope = ApprovalScope( + operation=operation, is_neg_risk=is_neg_risk, is_yield_bearing=yb + ) + for step in self.get_approval_steps(scope): + if step.id not in seen: + seen.add(step.id) + steps.append(step) + + return steps + + def _resolve_token_contract(self, step: ApprovalStep) -> Contract: + """Resolve the web3 contract an approval step acts on (USDT or a CT contract).""" + if not self._contracts: + raise MissingSignerError() + if step.type == "ERC20_ALLOWANCE": + return self._contracts.usdt + target = Web3.to_checksum_address(step.token) + for ct in ( + self._contracts.conditional_tokens, + self._contracts.neg_risk_conditional_tokens, + self._contracts.yield_bearing_conditional_tokens, + self._contracts.yield_bearing_neg_risk_conditional_tokens, + ): + if ct.address == target: + return ct + raise ValueError(f"Unknown approval token: {step.token}") + + async def check_approval_async(self, step: ApprovalStep) -> bool: + """Check whether a single approval step is already satisfied on-chain (async).""" + if not self._contracts: + raise MissingSignerError() + + owner = self._approval_owner_address() + + if step.type == "ERC20_ALLOWANCE": + allowance: int = self._contracts.usdt.functions.allowance(owner, step.spender).call() + return allowance >= MAX_INT256 + + ct = self._resolve_token_contract(step) + approved: bool = ct.functions.isApprovedForAll(owner, step.spender).call() + return approved + + def check_approval(self, step: ApprovalStep) -> bool: + """Check whether a single approval step is already satisfied on-chain (sync).""" + return self._run_async(self.check_approval_async(step)) + + async def check_approvals_async(self, steps: list[ApprovalStep]) -> list[ApprovalCheck]: + """ + Check whether each approval step is already satisfied on-chain (async). + + Note: unlike the TypeScript SDK, this issues the reads sequentially (no multicall). + """ + results: list[ApprovalCheck] = [] + for step in steps: + satisfied = await self.check_approval_async(step) + results.append(ApprovalCheck(step=step, satisfied=satisfied)) + return results + + def check_approvals(self, steps: list[ApprovalStep]) -> list[ApprovalCheck]: + """Check whether each approval step is already satisfied on-chain (sync).""" + return self._run_async(self.check_approvals_async(steps)) + + async def _set_erc1155_approval_async( + self, token_contract: Contract, spender: str, approved: bool + ) -> TransactionResult: + """Set ERC-1155 setApprovalForAll for an operator (EOA or Predict-account Kernel path).""" + if self._predict_account: + encoded = token_contract.encode_abi( + abi_element_identifier="setApprovalForAll", + args=[spender, approved], + ) + calldata = self._encode_execution_calldata(token_contract.address, encoded, value=0) + assert self._web3 is not None + kernel_contract = make_contract(self._web3, self._predict_account, KERNEL_ABI) + return await self._handle_transaction_async( + kernel_contract, "execute", self._execution_mode, calldata + ) + return await self._handle_transaction_async( + token_contract, "setApprovalForAll", spender, approved + ) + + async def _set_erc20_allowance_async(self, spender: str, amount: int) -> TransactionResult: + """Set ERC-20 (USDT) allowance for a spender (EOA or Predict-account Kernel path).""" + assert self._contracts is not None + usdt = self._contracts.usdt + if self._predict_account: + encoded = usdt.encode_abi( + abi_element_identifier="approve", + args=[spender, amount], + ) + calldata = self._encode_execution_calldata(usdt.address, encoded, value=0) + assert self._web3 is not None + kernel_contract = make_contract(self._web3, self._predict_account, KERNEL_ABI) + return await self._handle_transaction_async( + kernel_contract, "execute", self._execution_mode, calldata + ) + return await self._handle_transaction_async(usdt, "approve", spender, amount) + + async def set_approval_async( + self, + step: ApprovalStep, + *, + approved: bool = True, + amount: int = MAX_UINT256, + ) -> TransactionResult: + """ + Execute a single approval step on-chain (async). Raw send: does not pre-check. + + Args: + step: The step to execute. + approved: ERC-1155: approve (default) or revoke (False). ERC-20: when False, revokes by + setting the allowance to 0 (ignoring ``amount``). + amount: ERC-20 only: the allowance to set when approving. Defaults to MAX_UINT256. + + Returns: + The transaction result. + """ + if not self._contracts: + raise MissingSignerError() + + if step.type == "ERC1155_APPROVAL": + token_contract = self._resolve_token_contract(step) + return await self._set_erc1155_approval_async(token_contract, step.spender, approved) + + # For ERC-20, ``approved=False`` revokes by setting the allowance to 0. + return await self._set_erc20_allowance_async(step.spender, amount if approved else 0) + + def set_approval( + self, + step: ApprovalStep, + *, + approved: bool = True, + amount: int = MAX_UINT256, + ) -> TransactionResult: + """Execute a single approval step on-chain (sync).""" + return self._run_async(self.set_approval_async(step, approved=approved, amount=amount)) + + async def run_approvals_async( + self, + steps: list[ApprovalStep], + *, + skip_satisfied: bool = True, + stop_on_error: bool = True, + on_progress: Callable[[ApprovalProgress], None] | None = None, + ) -> ApprovalRunReport: + """ + Run the given approval steps in order, reporting progress (async). + + Duplicate steps (by id) are removed, so you can pass a union of scopes or a curated subset. + Produce the steps with get_approval_steps(scope) (one operation) or get_all_approval_steps() + (everything). By default each step is checked first and skipped if already satisfied, and the + run stops on the first failure. Use check_approval + set_approval directly when you need finer + control (e.g. gating each step on a user confirmation). + + Args: + steps: The steps to run (e.g. from get_approval_steps / get_all_approval_steps). + skip_satisfied: When True (default), skip steps already in place. + stop_on_error: When True (default), stop after the first failure. + on_progress: Optional callback invoked as each step transitions. + + Returns: + The per-step report and overall success. + """ + # Dedupe by id (first occurrence wins) so unioned/curated step lists "just work". + seen: set[str] = set() + unique_steps: list[ApprovalStep] = [] + for step in steps: + if step.id not in seen: + seen.add(step.id) + unique_steps.append(step) + + results: list[ApprovalStepResult] = [] + success = True + + for step in unique_steps: + if skip_satisfied: + if on_progress: + on_progress(ApprovalProgress(step=step, status="checking")) + # A pre-check read failure is non-fatal: fall through to the send path (matching + # the legacy approval helpers) rather than aborting the whole run. + try: + already_satisfied = await self.check_approval_async(step) + except Exception: + already_satisfied = False + if already_satisfied: + if on_progress: + on_progress(ApprovalProgress(step=step, status="skipped")) + results.append(ApprovalStepResult(step=step, status="skipped")) + continue + + if on_progress: + on_progress(ApprovalProgress(step=step, status="submitting")) + + transaction = await self.set_approval_async(step) + status: Literal["confirmed", "failed"] = ( + "confirmed" if transaction.success else "failed" + ) + + if on_progress: + on_progress(ApprovalProgress(step=step, status=status, transaction=transaction)) + results.append(ApprovalStepResult(step=step, status=status, transaction=transaction)) + + if not transaction.success: + success = False + if stop_on_error: + break + + return ApprovalRunReport(success=success, steps=results) + + def run_approvals( + self, + steps: list[ApprovalStep], + *, + skip_satisfied: bool = True, + stop_on_error: bool = True, + on_progress: Callable[[ApprovalProgress], None] | None = None, + ) -> ApprovalRunReport: + """Run the given approval steps in order (sync). See run_approvals_async.""" + return self._run_async( + self.run_approvals_async( + steps, + skip_satisfied=skip_satisfied, + stop_on_error=stop_on_error, + on_progress=on_progress, + ) + ) + + # --- Balance Methods --- + + async def balance_of_async( + self, + token: Literal["USDT"] = "USDT", + address: str | None = None, + ) -> int: + """ + Get the token balance for an address (async). + + Args: + token: The token to check (currently only "USDT"). + address: The address to check (defaults to signer). + + Returns: + The balance in wei. + """ + if not self._contracts: + raise MissingSignerError() + + check_address = ( + address or self._predict_account or (self._signer.address if self._signer else None) + ) + if not check_address: + raise MissingSignerError() + + result: int = self._contracts.usdt.functions.balanceOf(check_address).call() + return result + + def balance_of( + self, + token: Literal["USDT"] = "USDT", + address: str | None = None, + ) -> int: + """Get the token balance for an address (sync).""" + return self._run_async(self.balance_of_async(token, address)) + + # --- Redemption Methods --- + + async def redeem_positions_async( + self, + condition_id: str, + index_set: Literal[1, 2], + amount: int | None = None, + *, + is_neg_risk: bool, + is_yield_bearing: bool, + ) -> TransactionResult: + """ + Redeem positions for a market (async). + + Args: + condition_id: The condition ID. + index_set: The index set (1 or 2). + amount: The amount to redeem. Required for NegRisk markets. + is_neg_risk: Whether this is a NegRisk (winner-takes-all) market. + is_yield_bearing: Whether this is a yield-bearing market. + + Returns: + TransactionResult indicating success or failure. + + Raises: + MissingSignerError: If signer was not provided. + ValueError: If amount is not provided for NegRisk markets. + """ + if not self._contracts: + raise MissingSignerError() + + if is_neg_risk: + if amount is None: + raise ValueError("amount is required for NegRisk markets") + + adapter_contract = get_neg_risk_adapter_contract( + self._contracts, + is_yield_bearing=is_yield_bearing, + ) + amounts = [amount, 0] if index_set == 1 else [0, amount] + + if self._predict_account: + encoded = adapter_contract.encode_abi( + abi_element_identifier="redeemPositions", + args=[condition_id, amounts], + ) + calldata = self._encode_execution_calldata( + adapter_contract.address, encoded, value=0 + ) + assert self._web3 is not None + kernel_contract = make_contract(self._web3, self._predict_account, KERNEL_ABI) + return await self._handle_transaction_async( + kernel_contract, "execute", self._execution_mode, calldata + ) + else: + return await self._handle_transaction_async( + adapter_contract, + "redeemPositions", + condition_id, + amounts, + ) + else: + ct_contract = get_conditional_tokens_contract( + self._contracts, + is_neg_risk=False, + is_yield_bearing=is_yield_bearing, + ) + amounts = [index_set] + + if self._predict_account: + encoded = ct_contract.encode_abi( + abi_element_identifier="redeemPositions", + args=[ + self._addresses.USDT, + bytes.fromhex(ZERO_HASH[2:]), + condition_id, + amounts, + ], + ) + calldata = self._encode_execution_calldata(ct_contract.address, encoded, value=0) + assert self._web3 is not None + kernel_contract = make_contract(self._web3, self._predict_account, KERNEL_ABI) + return await self._handle_transaction_async( + kernel_contract, "execute", self._execution_mode, calldata + ) + else: + return await self._handle_transaction_async( + ct_contract, + "redeemPositions", + self._addresses.USDT, + bytes.fromhex(ZERO_HASH[2:]), + condition_id, + amounts, + ) + + def redeem_positions( + self, + condition_id: str, + index_set: Literal[1, 2], + amount: int | None = None, + *, + is_neg_risk: bool, + is_yield_bearing: bool, + ) -> TransactionResult: + """Redeem positions for a market (sync).""" + return self._run_async( + self.redeem_positions_async( + condition_id, + index_set, + amount, + is_neg_risk=is_neg_risk, + is_yield_bearing=is_yield_bearing, + ) + ) + + # --- Merge Positions Methods --- + + async def merge_positions_async( + self, + condition_id: str, + amount: int, + *, + is_neg_risk: bool, + is_yield_bearing: bool, + ) -> TransactionResult: + """ + Merge both outcome tokens back into collateral (USDT) (async). + + This combines both outcome tokens (YES and NO) back into the collateral token. + Both outcome positions must have equal amounts to merge. + + Args: + condition_id: The condition ID to merge positions for. + amount: The amount of each outcome token to merge. + is_neg_risk: Whether this is a NegRisk (winner-takes-all) market. + is_yield_bearing: Whether this is a yield-bearing market. + + Returns: + TransactionResult indicating success or failure. + """ + if not self._contracts: + raise MissingSignerError() + + if is_neg_risk: + # NegRisk markets use the adapter contract + adapter_contract = get_neg_risk_adapter_contract( + self._contracts, + is_yield_bearing=is_yield_bearing, + ) + + if self._predict_account: + encoded = adapter_contract.encode_abi( + abi_element_identifier="mergePositions", + args=[condition_id, amount], + ) + calldata = self._encode_execution_calldata( + adapter_contract.address, encoded, value=0 + ) + assert self._web3 is not None + kernel_contract = make_contract(self._web3, self._predict_account, KERNEL_ABI) + return await self._handle_transaction_async( + kernel_contract, "execute", self._execution_mode, calldata + ) + else: + return await self._handle_transaction_async( + adapter_contract, + "mergePositions", + condition_id, + amount, + ) + else: + # Standard markets use the conditional tokens contract + ct_contract = get_conditional_tokens_contract( + self._contracts, + is_neg_risk=False, + is_yield_bearing=is_yield_bearing, + ) + partition = [1, 2] # Both outcomes + + if self._predict_account: + encoded = ct_contract.encode_abi( + abi_element_identifier="mergePositions", + args=[ + self._addresses.USDT, + bytes.fromhex(ZERO_HASH[2:]), + condition_id, + partition, + amount, + ], + ) + calldata = self._encode_execution_calldata(ct_contract.address, encoded, value=0) + assert self._web3 is not None + kernel_contract = make_contract(self._web3, self._predict_account, KERNEL_ABI) + return await self._handle_transaction_async( + kernel_contract, "execute", self._execution_mode, calldata + ) + else: + return await self._handle_transaction_async( + ct_contract, + "mergePositions", + self._addresses.USDT, + bytes.fromhex(ZERO_HASH[2:]), + condition_id, + partition, + amount, + ) + + def merge_positions( + self, + condition_id: str, + amount: int, + *, + is_neg_risk: bool, + is_yield_bearing: bool, + ) -> TransactionResult: + """Merge both outcome tokens back into collateral (USDT) (sync).""" + return self._run_async( + self.merge_positions_async( + condition_id, amount, is_neg_risk=is_neg_risk, is_yield_bearing=is_yield_bearing + ) + ) + + # --- Split Positions Methods --- + + async def split_positions_async( + self, + condition_id: str, + amount: int, + *, + is_neg_risk: bool, + is_yield_bearing: bool, + ) -> TransactionResult: + """ + Split collateral (USDT) into outcome tokens (async). + + This splits the collateral token into both outcome tokens for a condition. + The amount specified will be converted into equal amounts of each outcome token. + + Args: + condition_id: The condition ID to split positions for. + amount: The amount of collateral to split into outcome tokens. + is_neg_risk: Whether this is a NegRisk (winner-takes-all) market. + is_yield_bearing: Whether this is a yield-bearing market. + + Returns: + TransactionResult indicating success or failure. + """ + if not self._contracts: + raise MissingSignerError() + + if is_neg_risk: + # NegRisk markets use the adapter contract + adapter_contract = get_neg_risk_adapter_contract( + self._contracts, + is_yield_bearing=is_yield_bearing, + ) + + if self._predict_account: + encoded = adapter_contract.encode_abi( + abi_element_identifier="splitPosition", + args=[condition_id, amount], + ) + calldata = self._encode_execution_calldata( + adapter_contract.address, encoded, value=0 + ) + assert self._web3 is not None + kernel_contract = make_contract(self._web3, self._predict_account, KERNEL_ABI) + return await self._handle_transaction_async( + kernel_contract, "execute", self._execution_mode, calldata + ) + else: + return await self._handle_transaction_async( + adapter_contract, + "splitPosition", + condition_id, + amount, + ) + else: + # Standard markets use the conditional tokens contract + ct_contract = get_conditional_tokens_contract( + self._contracts, + is_neg_risk=False, + is_yield_bearing=is_yield_bearing, + ) + partition = [1, 2] # Both outcomes + + if self._predict_account: + encoded = ct_contract.encode_abi( + abi_element_identifier="splitPosition", + args=[ + self._addresses.USDT, + bytes.fromhex(ZERO_HASH[2:]), + condition_id, + partition, + amount, + ], + ) + calldata = self._encode_execution_calldata(ct_contract.address, encoded, value=0) + assert self._web3 is not None + kernel_contract = make_contract(self._web3, self._predict_account, KERNEL_ABI) + return await self._handle_transaction_async( + kernel_contract, "execute", self._execution_mode, calldata + ) + else: + return await self._handle_transaction_async( + ct_contract, + "splitPosition", + self._addresses.USDT, + bytes.fromhex(ZERO_HASH[2:]), + condition_id, + partition, + amount, + ) + + def split_positions( + self, + condition_id: str, + amount: int, + *, + is_neg_risk: bool, + is_yield_bearing: bool, + ) -> TransactionResult: + """Split collateral (USDT) into outcome tokens (sync).""" + return self._run_async( + self.split_positions_async( + condition_id, amount, is_neg_risk=is_neg_risk, is_yield_bearing=is_yield_bearing + ) + ) + + # --- Convert Positions Methods --- + + async def convert_positions_async( + self, + neg_risk_on_chain_id: str, + index_set: int, + amount: int, + *, + is_yield_bearing: bool, + ) -> TransactionResult: + """ + Convert a set of NO positions in a NegRisk market (async). + + Burns the given amount of each NO position in the index set and returns the same + amount of each complementary YES position, plus collateral (USDT) proportional to + the number of NO positions converted minus one. If the market has a fee, it is + taken from both the collateral and the YES tokens. Only NegRisk markets support + conversions. + + Args: + neg_risk_on_chain_id: The category's on-chain NegRisk market ID (32-byte + hex), as returned by the API. This is not the numeric API id of a + market or category. + index_set: Bitmask of the NO positions to convert, where bit `n` is the + market's question at index `n`. + amount: The amount of each NO position to convert. + is_yield_bearing: Whether this is a yield-bearing market. + + Returns: + TransactionResult indicating success or failure. + """ + if not self._contracts: + raise MissingSignerError() + + # Conversions only exist on NegRisk markets and always go through the adapter + adapter_contract = get_neg_risk_adapter_contract( + self._contracts, + is_yield_bearing=is_yield_bearing, + ) + + if self._predict_account: + encoded = adapter_contract.encode_abi( + abi_element_identifier="convertPositions", + args=[neg_risk_on_chain_id, index_set, amount], + ) + calldata = self._encode_execution_calldata(adapter_contract.address, encoded, value=0) + assert self._web3 is not None + kernel_contract = make_contract(self._web3, self._predict_account, KERNEL_ABI) + return await self._handle_transaction_async( + kernel_contract, "execute", self._execution_mode, calldata + ) + else: + return await self._handle_transaction_async( + adapter_contract, + "convertPositions", + neg_risk_on_chain_id, + index_set, + amount, + ) + + def convert_positions( + self, + neg_risk_on_chain_id: str, + index_set: int, + amount: int, + *, + is_yield_bearing: bool, + ) -> TransactionResult: + """Convert a set of NO positions in a NegRisk market (sync).""" + return self._run_async( + self.convert_positions_async( + neg_risk_on_chain_id, index_set, amount, is_yield_bearing=is_yield_bearing + ) + ) + + # --- Cancel Orders Methods --- + + async def cancel_orders_async( + self, + orders: list[Order], + options: CancelOrdersOptions, + ) -> TransactionResult: + """ + Cancel orders on the CTF Exchange (async). + + Args: + orders: List of orders to cancel. + options: Cancellation options. + + Returns: + TransactionResult indicating success or failure. + """ + if not self._contracts: + raise MissingSignerError() + + if options.with_validation: + token_ids = [int(order.token_id) for order in orders] + is_valid = await self.validate_token_ids_async( + token_ids, + is_neg_risk=options.is_neg_risk, + is_yield_bearing=options.is_yield_bearing, ) - ) - - # --- Cancel Orders Methods --- - - async def cancel_orders_async( - self, - orders: list[Order], - options: CancelOrdersOptions, - ) -> TransactionResult: - """ - Cancel orders on the CTF Exchange (async). - - Args: - orders: List of orders to cancel. - options: Cancellation options. - - Returns: - TransactionResult indicating success or failure. - """ - if not self._contracts: - raise MissingSignerError() + if not is_valid: + raise InvalidNegRiskConfig() exchange_contract = get_exchange_contract( self._contracts, @@ -1978,112 +1989,112 @@ async def cancel_orders_async( ) # Convert orders to the contract format - order_structs = [] - for order in orders: - order_structs.append( - ( - int(order.salt), - order.maker, - order.signer, - order.taker, - int(order.token_id), - int(order.maker_amount), - int(order.taker_amount), - int(order.expiration), - int(order.nonce), - int(order.fee_rate_bps), - order.side.value if hasattr(order.side, "value") else int(order.side), - order.signature_type.value - if hasattr(order.signature_type, "value") - else int(order.signature_type), - b"", # Empty signature for cancellation - ) - ) - - if self._predict_account: - encoded = exchange_contract.encode_abi( - abi_element_identifier="cancelOrders", args=[order_structs] - ) - - calldata = self._encode_execution_calldata( - exchange_contract.address, - encoded, - value=0, - ) - - assert self._web3 is not None - kernel_contract = make_contract(self._web3, self._predict_account, KERNEL_ABI) - - return await self._handle_transaction_async( - kernel_contract, - "execute", - self._execution_mode, - calldata, - ) - else: - return await self._handle_transaction_async( - exchange_contract, - "cancelOrders", - order_structs, - ) - - def cancel_orders( - self, - orders: list[Order], - options: CancelOrdersOptions, - ) -> TransactionResult: - """Cancel orders on the CTF Exchange (sync).""" - return self._run_async(self.cancel_orders_async(orders, options)) - - # --- Token Validation --- - - async def validate_token_ids_async( - self, - token_ids: list[int], - *, - is_neg_risk: bool, - is_yield_bearing: bool, - ) -> bool: - """ - Validate token IDs against the appropriate exchange (async). - - Args: - token_ids: List of token IDs to validate. - is_neg_risk: Whether to validate against NegRisk exchange. - is_yield_bearing: Whether this is a yield-bearing market. - - Returns: - True if all token IDs are valid. - """ - if not self._contracts: - raise MissingSignerError() - - exchange_contract = get_exchange_contract( - self._contracts, - is_neg_risk=is_neg_risk, - is_yield_bearing=is_yield_bearing, - ) - - for token_id in token_ids: - try: - exchange_contract.functions.validateTokenId(token_id).call() - except Exception: - return False - - return True - - def validate_token_ids( - self, - token_ids: list[int], - *, - is_neg_risk: bool, - is_yield_bearing: bool, - ) -> bool: - """Validate token IDs against the appropriate exchange (sync).""" - return self._run_async( - self.validate_token_ids_async( - token_ids, - is_neg_risk=is_neg_risk, - is_yield_bearing=is_yield_bearing, - ) - ) + order_structs = [] + for order in orders: + order_structs.append( + ( + int(order.salt), + order.maker, + order.signer, + order.taker, + int(order.token_id), + int(order.maker_amount), + int(order.taker_amount), + int(order.expiration), + int(order.nonce), + int(order.fee_rate_bps), + order.side.value if hasattr(order.side, "value") else int(order.side), + order.signature_type.value + if hasattr(order.signature_type, "value") + else int(order.signature_type), + b"", # Empty signature for cancellation + ) + ) + + if self._predict_account: + encoded = exchange_contract.encode_abi( + abi_element_identifier="cancelOrders", args=[order_structs] + ) + + calldata = self._encode_execution_calldata( + exchange_contract.address, + encoded, + value=0, + ) + + assert self._web3 is not None + kernel_contract = make_contract(self._web3, self._predict_account, KERNEL_ABI) + + return await self._handle_transaction_async( + kernel_contract, + "execute", + self._execution_mode, + calldata, + ) + else: + return await self._handle_transaction_async( + exchange_contract, + "cancelOrders", + order_structs, + ) + + def cancel_orders( + self, + orders: list[Order], + options: CancelOrdersOptions, + ) -> TransactionResult: + """Cancel orders on the CTF Exchange (sync).""" + return self._run_async(self.cancel_orders_async(orders, options)) + + # --- Token Validation --- + + async def validate_token_ids_async( + self, + token_ids: list[int], + *, + is_neg_risk: bool, + is_yield_bearing: bool, + ) -> bool: + """ + Validate token IDs against the appropriate exchange (async). + + Args: + token_ids: List of token IDs to validate. + is_neg_risk: Whether to validate against NegRisk exchange. + is_yield_bearing: Whether this is a yield-bearing market. + + Returns: + True if all token IDs are valid. + """ + if not self._contracts: + raise MissingSignerError() + + exchange_contract = get_exchange_contract( + self._contracts, + is_neg_risk=is_neg_risk, + is_yield_bearing=is_yield_bearing, + ) + + for token_id in token_ids: + try: + exchange_contract.functions.validateTokenId(token_id).call() + except Exception: + return False + + return True + + def validate_token_ids( + self, + token_ids: list[int], + *, + is_neg_risk: bool, + is_yield_bearing: bool, + ) -> bool: + """Validate token IDs against the appropriate exchange (sync).""" + return self._run_async( + self.validate_token_ids_async( + token_ids, + is_neg_risk=is_neg_risk, + is_yield_bearing=is_yield_bearing, + ) + ) diff --git a/tests/test_order_builder.py b/tests/test_order_builder.py index 63f6515..463a6a5 100644 --- a/tests/test_order_builder.py +++ b/tests/test_order_builder.py @@ -1,303 +1,104 @@ -"""Tests for the OrderBuilder class.""" - -from __future__ import annotations - -from datetime import datetime, timezone -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from predict_sdk import ( - BuildOrderInput, - ChainId, +"""Tests for the OrderBuilder class.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from predict_sdk import ( + BuildOrderInput, + ChainId, + CancelOrdersOptions, InvalidExpirationError, + InvalidNegRiskConfig, InvalidQuantityError, - LimitHelperInput, + LimitHelperInput, MissingSignerError, + Order, OrderBuilder, - Side, - SignatureType, -) - - -class TestOrderBuilderMake: - """Test OrderBuilder factory method.""" - - def test_make_without_signer(self): - """Create OrderBuilder without signer for read-only operations.""" - builder = OrderBuilder.make(ChainId.BNB_MAINNET) - assert builder.contracts is None - - def test_make_with_chain_id_mainnet(self): - """Create OrderBuilder for mainnet.""" - builder = OrderBuilder.make(ChainId.BNB_MAINNET) - assert builder is not None - - def test_make_with_chain_id_testnet(self): - """Create OrderBuilder for testnet.""" - builder = OrderBuilder.make(ChainId.BNB_TESTNET) - assert builder is not None - - -class TestBuildOrder: - """Test order building functionality.""" - - def test_build_limit_order(self, builder_with_signer: OrderBuilder): - """Build a limit order.""" - order = builder_with_signer.build_order( - "LIMIT", - BuildOrderInput( - side=Side.BUY, - token_id="12345", - maker_amount="1000000000000000000", - taker_amount="2000000000000000000", - fee_rate_bps=100, - ), - ) - - assert order.side == Side.BUY - assert order.token_id == "12345" - assert order.maker_amount == "1000000000000000000" - assert order.taker_amount == "2000000000000000000" - assert order.fee_rate_bps == "100" - assert order.signature_type == SignatureType.EOA - - def test_build_market_order(self, builder_with_signer: OrderBuilder): - """Build a market order.""" - order = builder_with_signer.build_order( - "MARKET", - BuildOrderInput( - side=Side.SELL, - token_id="67890", - maker_amount="500000000000000000", - taker_amount="250000000000000000", - fee_rate_bps=50, - ), - ) - - assert order.side == Side.SELL - assert order.token_id == "67890" - - def test_build_order_with_custom_salt(self, builder_with_signer: OrderBuilder): - """Build order with custom salt.""" - order = builder_with_signer.build_order( - "LIMIT", - BuildOrderInput( - side=Side.BUY, - token_id="12345", - maker_amount="1000000000000000000", - taker_amount="2000000000000000000", - fee_rate_bps=100, - salt=123456789, - ), - ) - + Side, + SignatureType, +) + + +class TestOrderBuilderMake: + """Test OrderBuilder factory method.""" + + def test_make_without_signer(self): + """Create OrderBuilder without signer for read-only operations.""" + builder = OrderBuilder.make(ChainId.BNB_MAINNET) + assert builder.contracts is None + + def test_make_with_chain_id_mainnet(self): + """Create OrderBuilder for mainnet.""" + builder = OrderBuilder.make(ChainId.BNB_MAINNET) + assert builder is not None + + def test_make_with_chain_id_testnet(self): + """Create OrderBuilder for testnet.""" + builder = OrderBuilder.make(ChainId.BNB_TESTNET) + assert builder is not None + + +class TestBuildOrder: + """Test order building functionality.""" + + def test_build_limit_order(self, builder_with_signer: OrderBuilder): + """Build a limit order.""" + order = builder_with_signer.build_order( + "LIMIT", + BuildOrderInput( + side=Side.BUY, + token_id="12345", + maker_amount="1000000000000000000", + taker_amount="2000000000000000000", + fee_rate_bps=100, + ), + ) + + assert order.side == Side.BUY + assert order.token_id == "12345" + assert order.maker_amount == "1000000000000000000" + assert order.taker_amount == "2000000000000000000" + assert order.fee_rate_bps == "100" + assert order.signature_type == SignatureType.EOA + + def test_build_market_order(self, builder_with_signer: OrderBuilder): + """Build a market order.""" + order = builder_with_signer.build_order( + "MARKET", + BuildOrderInput( + side=Side.SELL, + token_id="67890", + maker_amount="500000000000000000", + taker_amount="250000000000000000", + fee_rate_bps=50, + ), + ) + + assert order.side == Side.SELL + assert order.token_id == "67890" + + def test_build_order_with_custom_salt(self, builder_with_signer: OrderBuilder): + """Build order with custom salt.""" + order = builder_with_signer.build_order( + "LIMIT", + BuildOrderInput( + side=Side.BUY, + token_id="12345", + maker_amount="1000000000000000000", + taker_amount="2000000000000000000", + fee_rate_bps=100, + salt=123456789, + ), + ) + assert order.salt == "123456789" - def test_build_order_with_expiration(self, builder_with_signer: OrderBuilder): - """Build order with custom expiration.""" - future_date = datetime(2100, 1, 1, tzinfo=timezone.utc) - order = builder_with_signer.build_order( - "LIMIT", - BuildOrderInput( - side=Side.BUY, - token_id="12345", - maker_amount="1000000000000000000", - taker_amount="2000000000000000000", - fee_rate_bps=100, - expires_at=future_date, - ), - ) - - assert order.expiration == str(int(future_date.timestamp())) - - def test_build_order_past_expiration_raises(self, builder_with_signer: OrderBuilder): - """Building a LIMIT order with past expiration should raise.""" - past_date = datetime(2000, 1, 1, tzinfo=timezone.utc) - with pytest.raises(InvalidExpirationError): - builder_with_signer.build_order( - "LIMIT", - BuildOrderInput( - side=Side.BUY, - token_id="12345", - maker_amount="1000000000000000000", - taker_amount="2000000000000000000", - fee_rate_bps=100, - expires_at=past_date, - ), - ) - - def test_build_order_without_signer_raises(self, builder: OrderBuilder): - """Building an order without signer should raise MissingSignerError.""" - with pytest.raises(MissingSignerError): - builder.build_order( - "LIMIT", - BuildOrderInput( - side=Side.BUY, - token_id="12345", - maker_amount="1000000000000000000", - taker_amount="2000000000000000000", - fee_rate_bps=100, - ), - ) - - -class TestLimitOrderAmounts: - """Test limit order amount calculations.""" - - def test_buy_order_amounts(self, builder: OrderBuilder): - """Calculate amounts for a buy order.""" - amounts = builder.get_limit_order_amounts( - LimitHelperInput( - side=Side.BUY, - price_per_share_wei=400000000000000000, # 0.4 - quantity_wei=10000000000000000000, # 10 shares - ) - ) - - # BUY: makerAmount = price * qty / precision - # 0.4 * 10 = 4 USDT - assert amounts.maker_amount == 4000000000000000000 - assert amounts.taker_amount == 10000000000000000000 - assert amounts.price_per_share == 400000000000000000 - - def test_sell_order_amounts(self, builder: OrderBuilder): - """Calculate amounts for a sell order.""" - amounts = builder.get_limit_order_amounts( - LimitHelperInput( - side=Side.SELL, - price_per_share_wei=600000000000000000, # 0.6 - quantity_wei=5000000000000000000, # 5 shares - ) - ) - - # SELL: takerAmount = price * qty / precision - # 0.6 * 5 = 3 USDT - assert amounts.maker_amount == 5000000000000000000 # shares offered - assert amounts.taker_amount == 3000000000000000000 # USDT to receive - assert amounts.price_per_share == 600000000000000000 - - def test_invalid_quantity_raises(self, builder: OrderBuilder): - """Raise error for invalid quantity.""" - with pytest.raises(InvalidQuantityError): - builder.get_limit_order_amounts( - LimitHelperInput( - side=Side.BUY, - price_per_share_wei=400000000000000000, - quantity_wei=1000, # Too small (< 1e16) - ) - ) - - def test_significant_digit_truncation(self, builder: OrderBuilder): - """Test that values are truncated to significant digits.""" - # Price should be truncated to 3 significant digits - # Quantity should be truncated to 5 significant digits - amounts = builder.get_limit_order_amounts( - LimitHelperInput( - side=Side.BUY, - price_per_share_wei=123456789000000000, # Should truncate to 123000000000000000 - quantity_wei=12345678900000000000, # Should truncate to 12345000000000000000 - ) - ) - - # Verify truncation happened - # The exact values depend on retainSignificantDigits implementation - assert amounts.price_per_share == 123000000000000000 - assert amounts.taker_amount == 12345000000000000000 - - -class TestTypedData: - """Test EIP-712 typed data generation.""" - - def test_build_typed_data_hash_matches_ts_sdk(self, builder_with_signer: OrderBuilder): - """Hash should match the TS SDK's TypedDataEncoder.hash() output.""" - from predict_sdk import EIP712TypedData - - # Static typed data with known expected hash from TS SDK - typed_data = EIP712TypedData( - primary_type="Order", - types={ - "EIP712Domain": [ - {"name": "name", "type": "string"}, - {"name": "version", "type": "string"}, - {"name": "chainId", "type": "uint256"}, - {"name": "verifyingContract", "type": "address"}, - ], - "Order": [ - {"name": "salt", "type": "uint256"}, - {"name": "maker", "type": "address"}, - {"name": "signer", "type": "address"}, - {"name": "taker", "type": "address"}, - {"name": "tokenId", "type": "uint256"}, - {"name": "makerAmount", "type": "uint256"}, - {"name": "takerAmount", "type": "uint256"}, - {"name": "expiration", "type": "uint256"}, - {"name": "nonce", "type": "uint256"}, - {"name": "feeRateBps", "type": "uint256"}, - {"name": "side", "type": "uint8"}, - {"name": "signatureType", "type": "uint8"}, - ], - }, - domain={ - "name": "predict.fun CTF Exchange", - "version": "1", - "chainId": 56, - "verifyingContract": "0x8BC070BEdAB741406F4B1Eb65A72bee27894B689", - }, - message={ - "salt": "123456789", - "maker": "0x1234567890123456789012345678901234567890", - "signer": "0x1234567890123456789012345678901234567890", - "taker": "0x0000000000000000000000000000000000000000", - "tokenId": "12345", - "makerAmount": "1000000000000000000", - "takerAmount": "2000000000000000000", - "expiration": "4102444800", - "nonce": "0", - "feeRateBps": "100", - "side": 0, - "signatureType": 0, - }, - ) - - hash_result = builder_with_signer.build_typed_data_hash(typed_data) - - # Expected hash from TS SDK's TypedDataEncoder.hash() - expected_hash = "0x814000c89efa61ae42a2bcc4c98e06e90c11480b95a12edea00e3411ec76821d" - assert hash_result == expected_hash, ( - f"Hash mismatch: got {hash_result}, expected {expected_hash}" - ) - - def test_build_typed_data_hash_has_0x_prefix(self, builder_with_signer: OrderBuilder): - """Hash from build_typed_data_hash should have 0x prefix.""" - order = builder_with_signer.build_order( - "LIMIT", - BuildOrderInput( - side=Side.BUY, - token_id="12345", - maker_amount="1000000000000000000", - taker_amount="2000000000000000000", - fee_rate_bps=100, - ), - ) - - typed_data = builder_with_signer.build_typed_data( - order, - is_neg_risk=False, - is_yield_bearing=False, - ) - - hash_result = builder_with_signer.build_typed_data_hash(typed_data) - - # Hash should start with 0x prefix - assert hash_result.startswith("0x"), f"Hash should start with '0x', got: {hash_result}" - # Hash should be 66 characters (0x + 64 hex chars) - assert len(hash_result) == 66, f"Hash should be 66 chars, got: {len(hash_result)}" - # Remaining chars should be valid hex - assert all(c in "0123456789abcdef" for c in hash_result[2:]) - - def test_build_typed_data(self, builder_with_signer: OrderBuilder): - """Build typed data for an order.""" + def test_build_order_preserves_zero_salt(self, builder_with_signer: OrderBuilder): + """Build order should preserve zero as an explicitly supplied salt.""" order = builder_with_signer.build_order( "LIMIT", BuildOrderInput( @@ -306,370 +107,586 @@ def test_build_typed_data(self, builder_with_signer: OrderBuilder): maker_amount="1000000000000000000", taker_amount="2000000000000000000", fee_rate_bps=100, + salt=0, ), ) - typed_data = builder_with_signer.build_typed_data( - order, - is_neg_risk=False, - is_yield_bearing=False, - ) - - assert typed_data.primary_type == "Order" - assert typed_data.domain["name"] == "predict.fun CTF Exchange" - assert typed_data.domain["version"] == "1" - assert typed_data.domain["chainId"] == ChainId.BNB_MAINNET - assert "Order" in typed_data.types - assert "EIP712Domain" in typed_data.types - - def test_build_typed_data_neg_risk(self, builder_with_signer: OrderBuilder): - """Build typed data for a NegRisk order.""" - order = builder_with_signer.build_order( - "LIMIT", - BuildOrderInput( - side=Side.BUY, - token_id="12345", - maker_amount="1000000000000000000", - taker_amount="2000000000000000000", - fee_rate_bps=100, - ), - ) - - typed_data = builder_with_signer.build_typed_data( - order, - is_neg_risk=True, - is_yield_bearing=False, - ) - - # NegRisk should use a different verifying contract - assert typed_data.domain["verifyingContract"] is not None - - -class TestSignature: - """Test order signing functionality.""" - - def test_sign_typed_data_order_returns_signed_order(self, builder_with_signer: OrderBuilder): - """sign_typed_data_order should return a SignedOrder with signature.""" - order = builder_with_signer.build_order( - "LIMIT", - BuildOrderInput( - side=Side.BUY, - token_id="12345", - maker_amount="1000000000000000000", - taker_amount="2000000000000000000", - fee_rate_bps=100, - ), - ) - - typed_data = builder_with_signer.build_typed_data( - order, - is_neg_risk=False, - is_yield_bearing=False, - ) - - signed_order = builder_with_signer.sign_typed_data_order(typed_data) - - # Verify the signed order has a signature - assert signed_order.signature is not None - assert len(signed_order.signature) > 0 - # Verify order details are preserved - assert signed_order.token_id == order.token_id - assert signed_order.maker_amount == order.maker_amount - assert signed_order.taker_amount == order.taker_amount - - @pytest.mark.asyncio - async def test_sign_typed_data_order_async(self, builder_with_signer: OrderBuilder): - """sign_typed_data_order_async should return same result as sync version.""" - order = builder_with_signer.build_order( - "LIMIT", - BuildOrderInput( - side=Side.BUY, - token_id="12345", - maker_amount="1000000000000000000", - taker_amount="2000000000000000000", - fee_rate_bps=100, - ), - ) - - typed_data = builder_with_signer.build_typed_data( - order, - is_neg_risk=False, - is_yield_bearing=False, - ) - - # Get both sync and async results - sync_result = builder_with_signer.sign_typed_data_order(typed_data) - async_result = await builder_with_signer.sign_typed_data_order_async(typed_data) - - # They should produce the same signature - assert sync_result.signature == async_result.signature - assert sync_result.token_id == async_result.token_id - - def test_sign_without_signer_raises( - self, builder_with_signer: OrderBuilder, builder: OrderBuilder - ): - """Signing without a signer should raise MissingSignerError.""" - # Build an order with a signer first (valid) - order = builder_with_signer.build_order( - "LIMIT", - BuildOrderInput( - side=Side.BUY, - token_id="12345", - maker_amount="1000000000000000000", - taker_amount="2000000000000000000", - fee_rate_bps=100, - ), - ) - - typed_data = builder_with_signer.build_typed_data( - order, - is_neg_risk=False, - is_yield_bearing=False, - ) - - # Try to sign with a builder that has no signer - with pytest.raises(MissingSignerError): - builder.sign_typed_data_order(typed_data) - - -class TestContractInteractions: - """Test contract interaction methods.""" - - def test_balance_of_without_signer_raises(self, builder: OrderBuilder): - """balance_of without signer should raise MissingSignerError.""" - with pytest.raises(MissingSignerError): - builder.balance_of() - - def test_set_approvals_without_signer_raises(self, builder: OrderBuilder): - """set_approvals without signer should raise MissingSignerError.""" - with pytest.raises(MissingSignerError): - builder.set_approvals() - - -class TestRedeemPositions: - """Test position redemption functionality.""" - - def test_redeem_positions_without_signer_raises(self, builder: OrderBuilder): - """redeem_positions without signer should raise MissingSignerError.""" - with pytest.raises(MissingSignerError): - builder.redeem_positions( - condition_id="0x" + "0" * 64, - index_set=1, - is_neg_risk=False, - is_yield_bearing=False, - ) - - def test_redeem_positions_neg_risk_requires_amount(self, builder_with_signer: OrderBuilder): - """redeem_positions with is_neg_risk=True but no amount should raise ValueError.""" - # builder_with_signer has no contracts, so it will raise MissingSignerError - # before reaching the amount validation. We need to test the validation - # path when contracts exist but amount is missing. - # For now, test the no-signer case raises MissingSignerError - with pytest.raises(MissingSignerError): - builder_with_signer.redeem_positions( - condition_id="0x" + "0" * 64, - index_set=1, - is_neg_risk=True, - is_yield_bearing=False, - ) - - def test_redeem_positions_neg_risk_without_signer_raises(self, builder: OrderBuilder): - """redeem_positions for NegRisk without signer should raise MissingSignerError.""" - with pytest.raises(MissingSignerError): - builder.redeem_positions( - condition_id="0x" + "0" * 64, - index_set=1, - amount=1000000000000000000, - is_neg_risk=True, - is_yield_bearing=False, - ) - - -class TestMergePositions: - """Test position merging functionality.""" - - def test_merge_positions_without_signer_raises(self, builder: OrderBuilder): - """merge_positions without signer should raise MissingSignerError.""" - with pytest.raises(MissingSignerError): - builder.merge_positions( - condition_id="0x" + "0" * 64, - amount=1000000000000000000, - is_neg_risk=False, - is_yield_bearing=False, - ) - - def test_merge_positions_neg_risk_without_signer_raises(self, builder: OrderBuilder): - """merge_positions for NegRisk without signer should raise MissingSignerError.""" - with pytest.raises(MissingSignerError): - builder.merge_positions( - condition_id="0x" + "0" * 64, - amount=1000000000000000000, - is_neg_risk=True, - is_yield_bearing=False, - ) - - -class TestSplitPositions: - """Test position splitting functionality.""" - - def test_split_positions_without_signer_raises(self, builder: OrderBuilder): - """split_positions without signer should raise MissingSignerError.""" - with pytest.raises(MissingSignerError): - builder.split_positions( - condition_id="0x" + "0" * 64, - amount=1000000000000000000, - is_neg_risk=False, - is_yield_bearing=False, - ) - - def test_split_positions_neg_risk_without_signer_raises(self, builder: OrderBuilder): - """split_positions for NegRisk without signer should raise MissingSignerError.""" - with pytest.raises(MissingSignerError): - builder.split_positions( - condition_id="0x" + "0" * 64, - amount=1000000000000000000, - is_neg_risk=True, - is_yield_bearing=False, - ) - - def test_split_positions_yield_bearing_without_signer_raises(self, builder: OrderBuilder): - """split_positions for yield-bearing without signer should raise MissingSignerError.""" - with pytest.raises(MissingSignerError): - builder.split_positions( - condition_id="0x" + "0" * 64, - amount=1000000000000000000, - is_neg_risk=False, - is_yield_bearing=True, - ) - - def test_split_positions_neg_risk_yield_bearing_without_signer_raises( - self, builder: OrderBuilder - ): - """split_positions for NegRisk yield-bearing without signer should raise MissingSignerError.""" - with pytest.raises(MissingSignerError): - builder.split_positions( - condition_id="0x" + "0" * 64, - amount=1000000000000000000, - is_neg_risk=True, - is_yield_bearing=True, - ) - - -class TestConvertPositions: - """Test position converting functionality.""" - - def test_convert_positions_without_signer_raises(self, builder: OrderBuilder): - """convert_positions without signer should raise MissingSignerError.""" - with pytest.raises(MissingSignerError): - builder.convert_positions( - neg_risk_on_chain_id="0x" + "0" * 64, - index_set=0b011, - amount=1000000000000000000, - is_yield_bearing=False, - ) - - def test_convert_positions_yield_bearing_without_signer_raises(self, builder: OrderBuilder): - """convert_positions for yield-bearing without signer should raise MissingSignerError.""" - with pytest.raises(MissingSignerError): - builder.convert_positions( - neg_risk_on_chain_id="0x" + "0" * 64, - index_set=0b011, - amount=1000000000000000000, - is_yield_bearing=True, - ) - - -class TestTypedDataCombinations: - """Test EIP-712 typed data generation for all market type combinations.""" - - @pytest.mark.parametrize( - "is_neg_risk,is_yield_bearing", - [ - (False, False), - (False, True), - (True, False), - (True, True), - ], - ) - def test_build_typed_data_combinations( - self, - builder_with_signer: OrderBuilder, - is_neg_risk: bool, - is_yield_bearing: bool, - ): - """Build typed data for all combinations of is_neg_risk and is_yield_bearing.""" - order = builder_with_signer.build_order( - "LIMIT", - BuildOrderInput( - side=Side.BUY, - token_id="12345", - maker_amount="1000000000000000000", - taker_amount="2000000000000000000", - fee_rate_bps=100, - ), - ) - - typed_data = builder_with_signer.build_typed_data( - order, - is_neg_risk=is_neg_risk, - is_yield_bearing=is_yield_bearing, - ) - - assert typed_data.primary_type == "Order" - assert typed_data.domain["name"] == "predict.fun CTF Exchange" - assert typed_data.domain["version"] == "1" - assert typed_data.domain["chainId"] == ChainId.BNB_MAINNET - assert typed_data.domain["verifyingContract"] is not None - assert "Order" in typed_data.types - assert "EIP712Domain" in typed_data.types - - @pytest.mark.parametrize( - "is_neg_risk,is_yield_bearing", - [ - (False, False), - (False, True), - (True, False), - (True, True), - ], - ) - def test_build_typed_data_hash_combinations( - self, - builder_with_signer: OrderBuilder, - is_neg_risk: bool, - is_yield_bearing: bool, - ): - """Build typed data hash for all combinations.""" - order = builder_with_signer.build_order( - "LIMIT", - BuildOrderInput( - side=Side.BUY, - token_id="12345", - maker_amount="1000000000000000000", - taker_amount="2000000000000000000", - fee_rate_bps=100, - ), - ) - - typed_data = builder_with_signer.build_typed_data( - order, - is_neg_risk=is_neg_risk, - is_yield_bearing=is_yield_bearing, - ) - - hash_result = builder_with_signer.build_typed_data_hash(typed_data) - - assert hash_result.startswith("0x") - assert len(hash_result) == 66 - assert all(c in "0123456789abcdef" for c in hash_result[2:]) - - -class TestCancelOrders: - """Test order cancellation functionality.""" + assert order.salt == "0" + def test_build_order_with_expiration(self, builder_with_signer: OrderBuilder): + """Build order with custom expiration.""" + future_date = datetime(2100, 1, 1, tzinfo=timezone.utc) + order = builder_with_signer.build_order( + "LIMIT", + BuildOrderInput( + side=Side.BUY, + token_id="12345", + maker_amount="1000000000000000000", + taker_amount="2000000000000000000", + fee_rate_bps=100, + expires_at=future_date, + ), + ) + + assert order.expiration == str(int(future_date.timestamp())) + + def test_build_order_past_expiration_raises(self, builder_with_signer: OrderBuilder): + """Building a LIMIT order with past expiration should raise.""" + past_date = datetime(2000, 1, 1, tzinfo=timezone.utc) + with pytest.raises(InvalidExpirationError): + builder_with_signer.build_order( + "LIMIT", + BuildOrderInput( + side=Side.BUY, + token_id="12345", + maker_amount="1000000000000000000", + taker_amount="2000000000000000000", + fee_rate_bps=100, + expires_at=past_date, + ), + ) + + def test_build_order_without_signer_raises(self, builder: OrderBuilder): + """Building an order without signer should raise MissingSignerError.""" + with pytest.raises(MissingSignerError): + builder.build_order( + "LIMIT", + BuildOrderInput( + side=Side.BUY, + token_id="12345", + maker_amount="1000000000000000000", + taker_amount="2000000000000000000", + fee_rate_bps=100, + ), + ) + + +class TestLimitOrderAmounts: + """Test limit order amount calculations.""" + + def test_buy_order_amounts(self, builder: OrderBuilder): + """Calculate amounts for a buy order.""" + amounts = builder.get_limit_order_amounts( + LimitHelperInput( + side=Side.BUY, + price_per_share_wei=400000000000000000, # 0.4 + quantity_wei=10000000000000000000, # 10 shares + ) + ) + + # BUY: makerAmount = price * qty / precision + # 0.4 * 10 = 4 USDT + assert amounts.maker_amount == 4000000000000000000 + assert amounts.taker_amount == 10000000000000000000 + assert amounts.price_per_share == 400000000000000000 + + def test_sell_order_amounts(self, builder: OrderBuilder): + """Calculate amounts for a sell order.""" + amounts = builder.get_limit_order_amounts( + LimitHelperInput( + side=Side.SELL, + price_per_share_wei=600000000000000000, # 0.6 + quantity_wei=5000000000000000000, # 5 shares + ) + ) + + # SELL: takerAmount = price * qty / precision + # 0.6 * 5 = 3 USDT + assert amounts.maker_amount == 5000000000000000000 # shares offered + assert amounts.taker_amount == 3000000000000000000 # USDT to receive + assert amounts.price_per_share == 600000000000000000 + + def test_invalid_quantity_raises(self, builder: OrderBuilder): + """Raise error for invalid quantity.""" + with pytest.raises(InvalidQuantityError): + builder.get_limit_order_amounts( + LimitHelperInput( + side=Side.BUY, + price_per_share_wei=400000000000000000, + quantity_wei=1000, # Too small (< 1e16) + ) + ) + + def test_significant_digit_truncation(self, builder: OrderBuilder): + """Test that values are truncated to significant digits.""" + # Price should be truncated to 3 significant digits + # Quantity should be truncated to 5 significant digits + amounts = builder.get_limit_order_amounts( + LimitHelperInput( + side=Side.BUY, + price_per_share_wei=123456789000000000, # Should truncate to 123000000000000000 + quantity_wei=12345678900000000000, # Should truncate to 12345000000000000000 + ) + ) + + # Verify truncation happened + # The exact values depend on retainSignificantDigits implementation + assert amounts.price_per_share == 123000000000000000 + assert amounts.taker_amount == 12345000000000000000 + + +class TestTypedData: + """Test EIP-712 typed data generation.""" + + def test_build_typed_data_hash_matches_ts_sdk(self, builder_with_signer: OrderBuilder): + """Hash should match the TS SDK's TypedDataEncoder.hash() output.""" + from predict_sdk import EIP712TypedData + + # Static typed data with known expected hash from TS SDK + typed_data = EIP712TypedData( + primary_type="Order", + types={ + "EIP712Domain": [ + {"name": "name", "type": "string"}, + {"name": "version", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"}, + ], + "Order": [ + {"name": "salt", "type": "uint256"}, + {"name": "maker", "type": "address"}, + {"name": "signer", "type": "address"}, + {"name": "taker", "type": "address"}, + {"name": "tokenId", "type": "uint256"}, + {"name": "makerAmount", "type": "uint256"}, + {"name": "takerAmount", "type": "uint256"}, + {"name": "expiration", "type": "uint256"}, + {"name": "nonce", "type": "uint256"}, + {"name": "feeRateBps", "type": "uint256"}, + {"name": "side", "type": "uint8"}, + {"name": "signatureType", "type": "uint8"}, + ], + }, + domain={ + "name": "predict.fun CTF Exchange", + "version": "1", + "chainId": 56, + "verifyingContract": "0x8BC070BEdAB741406F4B1Eb65A72bee27894B689", + }, + message={ + "salt": "123456789", + "maker": "0x1234567890123456789012345678901234567890", + "signer": "0x1234567890123456789012345678901234567890", + "taker": "0x0000000000000000000000000000000000000000", + "tokenId": "12345", + "makerAmount": "1000000000000000000", + "takerAmount": "2000000000000000000", + "expiration": "4102444800", + "nonce": "0", + "feeRateBps": "100", + "side": 0, + "signatureType": 0, + }, + ) + + hash_result = builder_with_signer.build_typed_data_hash(typed_data) + + # Expected hash from TS SDK's TypedDataEncoder.hash() + expected_hash = "0x814000c89efa61ae42a2bcc4c98e06e90c11480b95a12edea00e3411ec76821d" + assert hash_result == expected_hash, ( + f"Hash mismatch: got {hash_result}, expected {expected_hash}" + ) + + def test_build_typed_data_hash_has_0x_prefix(self, builder_with_signer: OrderBuilder): + """Hash from build_typed_data_hash should have 0x prefix.""" + order = builder_with_signer.build_order( + "LIMIT", + BuildOrderInput( + side=Side.BUY, + token_id="12345", + maker_amount="1000000000000000000", + taker_amount="2000000000000000000", + fee_rate_bps=100, + ), + ) + + typed_data = builder_with_signer.build_typed_data( + order, + is_neg_risk=False, + is_yield_bearing=False, + ) + + hash_result = builder_with_signer.build_typed_data_hash(typed_data) + + # Hash should start with 0x prefix + assert hash_result.startswith("0x"), f"Hash should start with '0x', got: {hash_result}" + # Hash should be 66 characters (0x + 64 hex chars) + assert len(hash_result) == 66, f"Hash should be 66 chars, got: {len(hash_result)}" + # Remaining chars should be valid hex + assert all(c in "0123456789abcdef" for c in hash_result[2:]) + + def test_build_typed_data(self, builder_with_signer: OrderBuilder): + """Build typed data for an order.""" + order = builder_with_signer.build_order( + "LIMIT", + BuildOrderInput( + side=Side.BUY, + token_id="12345", + maker_amount="1000000000000000000", + taker_amount="2000000000000000000", + fee_rate_bps=100, + ), + ) + + typed_data = builder_with_signer.build_typed_data( + order, + is_neg_risk=False, + is_yield_bearing=False, + ) + + assert typed_data.primary_type == "Order" + assert typed_data.domain["name"] == "predict.fun CTF Exchange" + assert typed_data.domain["version"] == "1" + assert typed_data.domain["chainId"] == ChainId.BNB_MAINNET + assert "Order" in typed_data.types + assert "EIP712Domain" in typed_data.types + + def test_build_typed_data_neg_risk(self, builder_with_signer: OrderBuilder): + """Build typed data for a NegRisk order.""" + order = builder_with_signer.build_order( + "LIMIT", + BuildOrderInput( + side=Side.BUY, + token_id="12345", + maker_amount="1000000000000000000", + taker_amount="2000000000000000000", + fee_rate_bps=100, + ), + ) + + typed_data = builder_with_signer.build_typed_data( + order, + is_neg_risk=True, + is_yield_bearing=False, + ) + + # NegRisk should use a different verifying contract + assert typed_data.domain["verifyingContract"] is not None + + +class TestSignature: + """Test order signing functionality.""" + + def test_sign_typed_data_order_returns_signed_order(self, builder_with_signer: OrderBuilder): + """sign_typed_data_order should return a SignedOrder with signature.""" + order = builder_with_signer.build_order( + "LIMIT", + BuildOrderInput( + side=Side.BUY, + token_id="12345", + maker_amount="1000000000000000000", + taker_amount="2000000000000000000", + fee_rate_bps=100, + ), + ) + + typed_data = builder_with_signer.build_typed_data( + order, + is_neg_risk=False, + is_yield_bearing=False, + ) + + signed_order = builder_with_signer.sign_typed_data_order(typed_data) + + # Verify the signed order has a signature + assert signed_order.signature is not None + assert len(signed_order.signature) > 0 + # Verify order details are preserved + assert signed_order.token_id == order.token_id + assert signed_order.maker_amount == order.maker_amount + assert signed_order.taker_amount == order.taker_amount + + @pytest.mark.asyncio + async def test_sign_typed_data_order_async(self, builder_with_signer: OrderBuilder): + """sign_typed_data_order_async should return same result as sync version.""" + order = builder_with_signer.build_order( + "LIMIT", + BuildOrderInput( + side=Side.BUY, + token_id="12345", + maker_amount="1000000000000000000", + taker_amount="2000000000000000000", + fee_rate_bps=100, + ), + ) + + typed_data = builder_with_signer.build_typed_data( + order, + is_neg_risk=False, + is_yield_bearing=False, + ) + + # Get both sync and async results + sync_result = builder_with_signer.sign_typed_data_order(typed_data) + async_result = await builder_with_signer.sign_typed_data_order_async(typed_data) + + # They should produce the same signature + assert sync_result.signature == async_result.signature + assert sync_result.token_id == async_result.token_id + + def test_sign_without_signer_raises( + self, builder_with_signer: OrderBuilder, builder: OrderBuilder + ): + """Signing without a signer should raise MissingSignerError.""" + # Build an order with a signer first (valid) + order = builder_with_signer.build_order( + "LIMIT", + BuildOrderInput( + side=Side.BUY, + token_id="12345", + maker_amount="1000000000000000000", + taker_amount="2000000000000000000", + fee_rate_bps=100, + ), + ) + + typed_data = builder_with_signer.build_typed_data( + order, + is_neg_risk=False, + is_yield_bearing=False, + ) + + # Try to sign with a builder that has no signer + with pytest.raises(MissingSignerError): + builder.sign_typed_data_order(typed_data) + + +class TestContractInteractions: + """Test contract interaction methods.""" + + def test_balance_of_without_signer_raises(self, builder: OrderBuilder): + """balance_of without signer should raise MissingSignerError.""" + with pytest.raises(MissingSignerError): + builder.balance_of() + + def test_set_approvals_without_signer_raises(self, builder: OrderBuilder): + """set_approvals without signer should raise MissingSignerError.""" + with pytest.raises(MissingSignerError): + builder.set_approvals() + + +class TestRedeemPositions: + """Test position redemption functionality.""" + + def test_redeem_positions_without_signer_raises(self, builder: OrderBuilder): + """redeem_positions without signer should raise MissingSignerError.""" + with pytest.raises(MissingSignerError): + builder.redeem_positions( + condition_id="0x" + "0" * 64, + index_set=1, + is_neg_risk=False, + is_yield_bearing=False, + ) + + def test_redeem_positions_neg_risk_requires_amount(self, builder_with_signer: OrderBuilder): + """redeem_positions with is_neg_risk=True but no amount should raise ValueError.""" + # builder_with_signer has no contracts, so it will raise MissingSignerError + # before reaching the amount validation. We need to test the validation + # path when contracts exist but amount is missing. + # For now, test the no-signer case raises MissingSignerError + with pytest.raises(MissingSignerError): + builder_with_signer.redeem_positions( + condition_id="0x" + "0" * 64, + index_set=1, + is_neg_risk=True, + is_yield_bearing=False, + ) + + def test_redeem_positions_neg_risk_without_signer_raises(self, builder: OrderBuilder): + """redeem_positions for NegRisk without signer should raise MissingSignerError.""" + with pytest.raises(MissingSignerError): + builder.redeem_positions( + condition_id="0x" + "0" * 64, + index_set=1, + amount=1000000000000000000, + is_neg_risk=True, + is_yield_bearing=False, + ) + + +class TestMergePositions: + """Test position merging functionality.""" + + def test_merge_positions_without_signer_raises(self, builder: OrderBuilder): + """merge_positions without signer should raise MissingSignerError.""" + with pytest.raises(MissingSignerError): + builder.merge_positions( + condition_id="0x" + "0" * 64, + amount=1000000000000000000, + is_neg_risk=False, + is_yield_bearing=False, + ) + + def test_merge_positions_neg_risk_without_signer_raises(self, builder: OrderBuilder): + """merge_positions for NegRisk without signer should raise MissingSignerError.""" + with pytest.raises(MissingSignerError): + builder.merge_positions( + condition_id="0x" + "0" * 64, + amount=1000000000000000000, + is_neg_risk=True, + is_yield_bearing=False, + ) + + +class TestSplitPositions: + """Test position splitting functionality.""" + + def test_split_positions_without_signer_raises(self, builder: OrderBuilder): + """split_positions without signer should raise MissingSignerError.""" + with pytest.raises(MissingSignerError): + builder.split_positions( + condition_id="0x" + "0" * 64, + amount=1000000000000000000, + is_neg_risk=False, + is_yield_bearing=False, + ) + + def test_split_positions_neg_risk_without_signer_raises(self, builder: OrderBuilder): + """split_positions for NegRisk without signer should raise MissingSignerError.""" + with pytest.raises(MissingSignerError): + builder.split_positions( + condition_id="0x" + "0" * 64, + amount=1000000000000000000, + is_neg_risk=True, + is_yield_bearing=False, + ) + + def test_split_positions_yield_bearing_without_signer_raises(self, builder: OrderBuilder): + """split_positions for yield-bearing without signer should raise MissingSignerError.""" + with pytest.raises(MissingSignerError): + builder.split_positions( + condition_id="0x" + "0" * 64, + amount=1000000000000000000, + is_neg_risk=False, + is_yield_bearing=True, + ) + + def test_split_positions_neg_risk_yield_bearing_without_signer_raises( + self, builder: OrderBuilder + ): + """split_positions for NegRisk yield-bearing without signer should raise MissingSignerError.""" + with pytest.raises(MissingSignerError): + builder.split_positions( + condition_id="0x" + "0" * 64, + amount=1000000000000000000, + is_neg_risk=True, + is_yield_bearing=True, + ) + + +class TestConvertPositions: + """Test position converting functionality.""" + + def test_convert_positions_without_signer_raises(self, builder: OrderBuilder): + """convert_positions without signer should raise MissingSignerError.""" + with pytest.raises(MissingSignerError): + builder.convert_positions( + neg_risk_on_chain_id="0x" + "0" * 64, + index_set=0b011, + amount=1000000000000000000, + is_yield_bearing=False, + ) + + def test_convert_positions_yield_bearing_without_signer_raises(self, builder: OrderBuilder): + """convert_positions for yield-bearing without signer should raise MissingSignerError.""" + with pytest.raises(MissingSignerError): + builder.convert_positions( + neg_risk_on_chain_id="0x" + "0" * 64, + index_set=0b011, + amount=1000000000000000000, + is_yield_bearing=True, + ) + + +class TestTypedDataCombinations: + """Test EIP-712 typed data generation for all market type combinations.""" + + @pytest.mark.parametrize( + "is_neg_risk,is_yield_bearing", + [ + (False, False), + (False, True), + (True, False), + (True, True), + ], + ) + def test_build_typed_data_combinations( + self, + builder_with_signer: OrderBuilder, + is_neg_risk: bool, + is_yield_bearing: bool, + ): + """Build typed data for all combinations of is_neg_risk and is_yield_bearing.""" + order = builder_with_signer.build_order( + "LIMIT", + BuildOrderInput( + side=Side.BUY, + token_id="12345", + maker_amount="1000000000000000000", + taker_amount="2000000000000000000", + fee_rate_bps=100, + ), + ) + + typed_data = builder_with_signer.build_typed_data( + order, + is_neg_risk=is_neg_risk, + is_yield_bearing=is_yield_bearing, + ) + + assert typed_data.primary_type == "Order" + assert typed_data.domain["name"] == "predict.fun CTF Exchange" + assert typed_data.domain["version"] == "1" + assert typed_data.domain["chainId"] == ChainId.BNB_MAINNET + assert typed_data.domain["verifyingContract"] is not None + assert "Order" in typed_data.types + assert "EIP712Domain" in typed_data.types + + @pytest.mark.parametrize( + "is_neg_risk,is_yield_bearing", + [ + (False, False), + (False, True), + (True, False), + (True, True), + ], + ) + def test_build_typed_data_hash_combinations( + self, + builder_with_signer: OrderBuilder, + is_neg_risk: bool, + is_yield_bearing: bool, + ): + """Build typed data hash for all combinations.""" + order = builder_with_signer.build_order( + "LIMIT", + BuildOrderInput( + side=Side.BUY, + token_id="12345", + maker_amount="1000000000000000000", + taker_amount="2000000000000000000", + fee_rate_bps=100, + ), + ) + + typed_data = builder_with_signer.build_typed_data( + order, + is_neg_risk=is_neg_risk, + is_yield_bearing=is_yield_bearing, + ) + + hash_result = builder_with_signer.build_typed_data_hash(typed_data) + + assert hash_result.startswith("0x") + assert len(hash_result) == 66 + assert all(c in "0123456789abcdef" for c in hash_result[2:]) + + +class TestCancelOrders: + """Test order cancellation functionality.""" + def test_cancel_orders_without_signer_raises(self, builder: OrderBuilder): """cancel_orders without signer should raise MissingSignerError.""" - from predict_sdk import CancelOrdersOptions, Order - # Create a mock order mock_order = Order( salt="123", @@ -695,213 +712,246 @@ def test_cancel_orders_without_signer_raises(self, builder: OrderBuilder): ), ) - -class TestSetApprovals: - """Test approval orchestration (both tracks) and on-chain idempotency.""" - @pytest.mark.asyncio - async def test_set_approvals_covers_both_tracks_by_default( + async def test_cancel_orders_rejects_invalid_token_ids_before_submission( self, builder_with_signer: OrderBuilder ): - """set_approvals() with no args must approve standard AND yield-bearing contracts.""" - from predict_sdk.types import TransactionSuccess - + """cancel_orders should validate token IDs before submitting a transaction.""" builder = builder_with_signer - builder.set_ctf_exchange_approval_async = AsyncMock( # type: ignore[method-assign] - return_value=TransactionSuccess(success=True) - ) - builder.set_ctf_exchange_allowance_async = AsyncMock( # type: ignore[method-assign] - return_value=TransactionSuccess(success=True) - ) - builder.set_neg_risk_adapter_approval_async = AsyncMock( # type: ignore[method-assign] - return_value=TransactionSuccess(success=True) - ) - - result = await builder.set_approvals_async() - - assert result.success is True - assert len(result.transactions) == 10 - - approval_combos = { - (c.kwargs["is_neg_risk"], c.kwargs["is_yield_bearing"]) - for c in builder.set_ctf_exchange_approval_async.call_args_list - } - allowance_combos = { - (c.kwargs["is_neg_risk"], c.kwargs["is_yield_bearing"]) - for c in builder.set_ctf_exchange_allowance_async.call_args_list - } - adapter_tracks = { - c.kwargs["is_yield_bearing"] - for c in builder.set_neg_risk_adapter_approval_async.call_args_list - } - - assert approval_combos == {(False, False), (True, False), (False, True), (True, True)} - assert allowance_combos == {(False, False), (True, False), (False, True), (True, True)} - assert adapter_tracks == {False, True} - - @pytest.mark.asyncio - async def test_set_approvals_single_track(self, builder_with_signer: OrderBuilder): - """Passing is_yield_bearing limits the run to that one track (5 operations).""" - from predict_sdk.types import TransactionSuccess - - builder = builder_with_signer - builder.set_ctf_exchange_approval_async = AsyncMock( # type: ignore[method-assign] - return_value=TransactionSuccess(success=True) - ) - builder.set_ctf_exchange_allowance_async = AsyncMock( # type: ignore[method-assign] - return_value=TransactionSuccess(success=True) - ) - builder.set_neg_risk_adapter_approval_async = AsyncMock( # type: ignore[method-assign] - return_value=TransactionSuccess(success=True) - ) - - result = await builder.set_approvals_async(is_yield_bearing=True) - - assert result.success is True - assert len(result.transactions) == 5 - assert all( - c.kwargs["is_yield_bearing"] is True - for c in builder.set_ctf_exchange_approval_async.call_args_list - ) - assert all( - c.kwargs["is_yield_bearing"] is True - for c in builder.set_ctf_exchange_allowance_async.call_args_list - ) - - @pytest.mark.asyncio - async def test_allowance_skipped_when_already_sufficient( - self, builder_with_signer: OrderBuilder - ): - """An existing allowance that covers the amount must not send a transaction.""" - from predict_sdk.constants import MAX_UINT256 - from predict_sdk.types import TransactionSuccess - - builder = builder_with_signer - contracts = MagicMock() - contracts.usdt.functions.allowance.return_value.call.return_value = MAX_UINT256 - builder._contracts = contracts - builder._handle_transaction_async = AsyncMock( # type: ignore[method-assign] - return_value=TransactionSuccess(success=True) - ) - - result = await builder.set_ctf_exchange_allowance_async( - is_neg_risk=True, is_yield_bearing=True - ) - - assert result.success is True - builder._handle_transaction_async.assert_not_called() - contracts.usdt.functions.allowance.assert_called_once_with( - builder._signer.address, - builder._addresses.YIELD_BEARING_NEG_RISK_CTF_EXCHANGE, - ) - - @pytest.mark.asyncio - async def test_allowance_sent_when_missing(self, builder_with_signer: OrderBuilder): - """A zero allowance (the bug the integrator hit) must trigger an approve transaction.""" - from predict_sdk.types import TransactionSuccess - - builder = builder_with_signer - contracts = MagicMock() - contracts.usdt.functions.allowance.return_value.call.return_value = 0 - builder._contracts = contracts - builder._handle_transaction_async = AsyncMock( # type: ignore[method-assign] - return_value=TransactionSuccess(success=True) - ) - - result = await builder.set_ctf_exchange_allowance_async( - is_neg_risk=True, is_yield_bearing=True - ) - - assert result.success is True - builder._handle_transaction_async.assert_called_once() - - @pytest.mark.asyncio - async def test_approval_skipped_when_already_approved(self, builder_with_signer: OrderBuilder): - """An ERC-1155 operator already approved must not send a transaction.""" - from predict_sdk.types import TransactionSuccess - - builder = builder_with_signer - builder._contracts = MagicMock() - builder._handle_transaction_async = AsyncMock( # type: ignore[method-assign] - return_value=TransactionSuccess(success=True) - ) - - ct = MagicMock() - ct.functions.isApprovedForAll.return_value.call.return_value = True - - with patch("predict_sdk.order_builder.get_conditional_tokens_contract", return_value=ct): - result = await builder.set_ctf_exchange_approval_async( - is_neg_risk=False, is_yield_bearing=True - ) - - assert result.success is True - builder._handle_transaction_async.assert_not_called() - ct.functions.isApprovedForAll.assert_called_once() - - @pytest.mark.asyncio - async def test_approval_sent_when_not_approved(self, builder_with_signer: OrderBuilder): - """An ERC-1155 operator not yet approved must send a setApprovalForAll transaction.""" - from predict_sdk.types import TransactionSuccess - - builder = builder_with_signer - builder._contracts = MagicMock() - builder._handle_transaction_async = AsyncMock( # type: ignore[method-assign] - return_value=TransactionSuccess(success=True) - ) - - ct = MagicMock() - ct.functions.isApprovedForAll.return_value.call.return_value = False - - with patch("predict_sdk.order_builder.get_conditional_tokens_contract", return_value=ct): - result = await builder.set_ctf_exchange_approval_async( - is_neg_risk=False, is_yield_bearing=True - ) - - assert result.success is True - builder._handle_transaction_async.assert_called_once() - - @pytest.mark.asyncio - async def test_allowance_read_failure_falls_through_to_send( - self, builder_with_signer: OrderBuilder - ): - """A failed allowance read must not raise; it falls through to the send path.""" - from predict_sdk.types import TransactionSuccess - - builder = builder_with_signer - contracts = MagicMock() - contracts.usdt.functions.allowance.return_value.call.side_effect = Exception("rpc down") - builder._contracts = contracts - builder._handle_transaction_async = AsyncMock( # type: ignore[method-assign] - return_value=TransactionSuccess(success=True) - ) - - result = await builder.set_ctf_exchange_allowance_async( - is_neg_risk=False, is_yield_bearing=False - ) - - assert result.success is True - builder._handle_transaction_async.assert_called_once() - - @pytest.mark.asyncio - async def test_approval_read_failure_falls_through_to_send( - self, builder_with_signer: OrderBuilder - ): - """A failed isApprovedForAll read must not raise; it falls through to the send path.""" - from predict_sdk.types import TransactionSuccess - - builder = builder_with_signer - builder._contracts = MagicMock() - builder._handle_transaction_async = AsyncMock( # type: ignore[method-assign] - return_value=TransactionSuccess(success=True) + builder._contracts = object() # type: ignore[assignment] + builder.validate_token_ids_async = AsyncMock(return_value=False) # type: ignore[method-assign] + order = Order( + salt="123", + maker="0x" + "0" * 40, + signer="0x" + "0" * 40, + taker="0x" + "0" * 40, + token_id="12345", + maker_amount="1000000000000000000", + taker_amount="2000000000000000000", + expiration="4102444800", + nonce="0", + fee_rate_bps="100", + side=Side.BUY, + signature_type=SignatureType.EOA, ) - ct = MagicMock() - ct.functions.isApprovedForAll.return_value.call.side_effect = Exception("rpc down") - - with patch("predict_sdk.order_builder.get_conditional_tokens_contract", return_value=ct): - result = await builder.set_ctf_exchange_approval_async( - is_neg_risk=False, is_yield_bearing=True + with pytest.raises(InvalidNegRiskConfig): + await builder.cancel_orders_async( + [order], + CancelOrdersOptions(is_neg_risk=False, is_yield_bearing=False), ) - assert result.success is True - builder._handle_transaction_async.assert_called_once() + builder.validate_token_ids_async.assert_awaited_once_with( + [12345], is_neg_risk=False, is_yield_bearing=False + ) + + +class TestSetApprovals: + """Test approval orchestration (both tracks) and on-chain idempotency.""" + + @pytest.mark.asyncio + async def test_set_approvals_covers_both_tracks_by_default( + self, builder_with_signer: OrderBuilder + ): + """set_approvals() with no args must approve standard AND yield-bearing contracts.""" + from predict_sdk.types import TransactionSuccess + + builder = builder_with_signer + builder.set_ctf_exchange_approval_async = AsyncMock( # type: ignore[method-assign] + return_value=TransactionSuccess(success=True) + ) + builder.set_ctf_exchange_allowance_async = AsyncMock( # type: ignore[method-assign] + return_value=TransactionSuccess(success=True) + ) + builder.set_neg_risk_adapter_approval_async = AsyncMock( # type: ignore[method-assign] + return_value=TransactionSuccess(success=True) + ) + + result = await builder.set_approvals_async() + + assert result.success is True + assert len(result.transactions) == 10 + + approval_combos = { + (c.kwargs["is_neg_risk"], c.kwargs["is_yield_bearing"]) + for c in builder.set_ctf_exchange_approval_async.call_args_list + } + allowance_combos = { + (c.kwargs["is_neg_risk"], c.kwargs["is_yield_bearing"]) + for c in builder.set_ctf_exchange_allowance_async.call_args_list + } + adapter_tracks = { + c.kwargs["is_yield_bearing"] + for c in builder.set_neg_risk_adapter_approval_async.call_args_list + } + + assert approval_combos == {(False, False), (True, False), (False, True), (True, True)} + assert allowance_combos == {(False, False), (True, False), (False, True), (True, True)} + assert adapter_tracks == {False, True} + + @pytest.mark.asyncio + async def test_set_approvals_single_track(self, builder_with_signer: OrderBuilder): + """Passing is_yield_bearing limits the run to that one track (5 operations).""" + from predict_sdk.types import TransactionSuccess + + builder = builder_with_signer + builder.set_ctf_exchange_approval_async = AsyncMock( # type: ignore[method-assign] + return_value=TransactionSuccess(success=True) + ) + builder.set_ctf_exchange_allowance_async = AsyncMock( # type: ignore[method-assign] + return_value=TransactionSuccess(success=True) + ) + builder.set_neg_risk_adapter_approval_async = AsyncMock( # type: ignore[method-assign] + return_value=TransactionSuccess(success=True) + ) + + result = await builder.set_approvals_async(is_yield_bearing=True) + + assert result.success is True + assert len(result.transactions) == 5 + assert all( + c.kwargs["is_yield_bearing"] is True + for c in builder.set_ctf_exchange_approval_async.call_args_list + ) + assert all( + c.kwargs["is_yield_bearing"] is True + for c in builder.set_ctf_exchange_allowance_async.call_args_list + ) + + @pytest.mark.asyncio + async def test_allowance_skipped_when_already_sufficient( + self, builder_with_signer: OrderBuilder + ): + """An existing allowance that covers the amount must not send a transaction.""" + from predict_sdk.constants import MAX_UINT256 + from predict_sdk.types import TransactionSuccess + + builder = builder_with_signer + contracts = MagicMock() + contracts.usdt.functions.allowance.return_value.call.return_value = MAX_UINT256 + builder._contracts = contracts + builder._handle_transaction_async = AsyncMock( # type: ignore[method-assign] + return_value=TransactionSuccess(success=True) + ) + + result = await builder.set_ctf_exchange_allowance_async( + is_neg_risk=True, is_yield_bearing=True + ) + + assert result.success is True + builder._handle_transaction_async.assert_not_called() + contracts.usdt.functions.allowance.assert_called_once_with( + builder._signer.address, + builder._addresses.YIELD_BEARING_NEG_RISK_CTF_EXCHANGE, + ) + + @pytest.mark.asyncio + async def test_allowance_sent_when_missing(self, builder_with_signer: OrderBuilder): + """A zero allowance (the bug the integrator hit) must trigger an approve transaction.""" + from predict_sdk.types import TransactionSuccess + + builder = builder_with_signer + contracts = MagicMock() + contracts.usdt.functions.allowance.return_value.call.return_value = 0 + builder._contracts = contracts + builder._handle_transaction_async = AsyncMock( # type: ignore[method-assign] + return_value=TransactionSuccess(success=True) + ) + + result = await builder.set_ctf_exchange_allowance_async( + is_neg_risk=True, is_yield_bearing=True + ) + + assert result.success is True + builder._handle_transaction_async.assert_called_once() + + @pytest.mark.asyncio + async def test_approval_skipped_when_already_approved(self, builder_with_signer: OrderBuilder): + """An ERC-1155 operator already approved must not send a transaction.""" + from predict_sdk.types import TransactionSuccess + + builder = builder_with_signer + builder._contracts = MagicMock() + builder._handle_transaction_async = AsyncMock( # type: ignore[method-assign] + return_value=TransactionSuccess(success=True) + ) + + ct = MagicMock() + ct.functions.isApprovedForAll.return_value.call.return_value = True + + with patch("predict_sdk.order_builder.get_conditional_tokens_contract", return_value=ct): + result = await builder.set_ctf_exchange_approval_async( + is_neg_risk=False, is_yield_bearing=True + ) + + assert result.success is True + builder._handle_transaction_async.assert_not_called() + ct.functions.isApprovedForAll.assert_called_once() + + @pytest.mark.asyncio + async def test_approval_sent_when_not_approved(self, builder_with_signer: OrderBuilder): + """An ERC-1155 operator not yet approved must send a setApprovalForAll transaction.""" + from predict_sdk.types import TransactionSuccess + + builder = builder_with_signer + builder._contracts = MagicMock() + builder._handle_transaction_async = AsyncMock( # type: ignore[method-assign] + return_value=TransactionSuccess(success=True) + ) + + ct = MagicMock() + ct.functions.isApprovedForAll.return_value.call.return_value = False + + with patch("predict_sdk.order_builder.get_conditional_tokens_contract", return_value=ct): + result = await builder.set_ctf_exchange_approval_async( + is_neg_risk=False, is_yield_bearing=True + ) + + assert result.success is True + builder._handle_transaction_async.assert_called_once() + + @pytest.mark.asyncio + async def test_allowance_read_failure_falls_through_to_send( + self, builder_with_signer: OrderBuilder + ): + """A failed allowance read must not raise; it falls through to the send path.""" + from predict_sdk.types import TransactionSuccess + + builder = builder_with_signer + contracts = MagicMock() + contracts.usdt.functions.allowance.return_value.call.side_effect = Exception("rpc down") + builder._contracts = contracts + builder._handle_transaction_async = AsyncMock( # type: ignore[method-assign] + return_value=TransactionSuccess(success=True) + ) + + result = await builder.set_ctf_exchange_allowance_async( + is_neg_risk=False, is_yield_bearing=False + ) + + assert result.success is True + builder._handle_transaction_async.assert_called_once() + + @pytest.mark.asyncio + async def test_approval_read_failure_falls_through_to_send( + self, builder_with_signer: OrderBuilder + ): + """A failed isApprovedForAll read must not raise; it falls through to the send path.""" + from predict_sdk.types import TransactionSuccess + + builder = builder_with_signer + builder._contracts = MagicMock() + builder._handle_transaction_async = AsyncMock( # type: ignore[method-assign] + return_value=TransactionSuccess(success=True) + ) + + ct = MagicMock() + ct.functions.isApprovedForAll.return_value.call.side_effect = Exception("rpc down") + + with patch("predict_sdk.order_builder.get_conditional_tokens_contract", return_value=ct): + result = await builder.set_ctf_exchange_approval_async( + is_neg_risk=False, is_yield_bearing=True + ) + + assert result.success is True + builder._handle_transaction_async.assert_called_once()