โ† Back
โ˜†
"""CLOB order executor โ€” places limit orders on Polymarket (CLOB V2)"""
import time
from loguru import logger
from config import PRIVATE_KEY, PROXY_ADDRESS, SIGNATURE_TYPE, CHAIN_ID, CLOB_API_URL, DRY_RUN, TRADE_PROXY
import db

MAX_RETRIES = 3
RETRY_BACKOFF = [1, 3, 7]  # seconds between retries
BUILDER_CODE = "0x3c829d5150b70f3ba347670d4b1eda96be3c255b3f64895a7eeef5caea7952d5"

# Lazy init โ€” only connect when needed
_client = None
_proxy_applied = False


def _apply_proxy():
    """
    Configure CLOB HTTP client to use proxy.
    py-clob-client uses a module-level httpx.Client without proxy support,
    so we replace it with a proxy-enabled instance. This is verified after
    patching to catch breaking changes in the library.
    """
    global _proxy_applied
    if _proxy_applied or not TRADE_PROXY:
        return

    try:
        import httpx
        from py_clob_client_v2.http_helpers import helpers

        if not hasattr(helpers, '_http_client'):
            logger.error("py_clob_client internal API changed: helpers._http_client not found. "
                         "Orders will be sent WITHOUT proxy โ€” IP may be exposed!")
            return

        helpers._http_client = httpx.Client(http2=True, proxy=TRADE_PROXY, timeout=30)

        # Verify patch took effect
        patched = getattr(helpers, '_http_client', None)
        if patched is None or not hasattr(patched, '_transport'):
            logger.error("Proxy patch verification failed โ€” proxy may not be active!")
            return

        _proxy_applied = True
        _proxy_host = TRADE_PROXY.split('@')[-1] if '@' in TRADE_PROXY else TRADE_PROXY
        logger.info(f"CLOB proxy configured: {_proxy_host}")

    except Exception as e:
        logger.error(f"Failed to configure trade proxy: {e}. Orders will be sent WITHOUT proxy!")


def get_client():
    """Lazy init py-clob-client with proxy"""
    global _client
    if _client is not None:
        return _client

    if not PRIVATE_KEY or PRIVATE_KEY == "your_polygon_wallet_private_key_here":
        logger.warning("No PRIVATE_KEY configured โ€” executor disabled")
        return None

    # Apply proxy BEFORE creating client (client init may make HTTP calls)
    _apply_proxy()

    try:
        from py_clob_client_v2 import ClobClient

        kwargs = {
            "host": CLOB_API_URL,
            "key": PRIVATE_KEY,
            "chain_id": CHAIN_ID,
            "signature_type": SIGNATURE_TYPE,
        }
        if PROXY_ADDRESS and SIGNATURE_TYPE == 2:
            kwargs["funder"] = PROXY_ADDRESS

        _client = ClobClient(**kwargs)
        _client.set_api_creds(_client.create_or_derive_api_key())
        logger.info("CLOB client initialized")
        return _client
    except Exception as e:
        logger.error(f"CLOB client init failed: {e}")
        return None


def _is_safe_to_retry(e):
    """True only if the error proves the POST never reached the server, so
    re-sending cannot duplicate the order. Ambiguous errors (read timeout,
    unknown) may mean the order WAS accepted, so we must not retry them."""
    msg = str(e).lower()
    safe_markers = (
        "connection refused", "failed to establish", "connect timeout",
        "name or service not known", "temporary failure in name resolution",
        "max retries exceeded", "connection aborted",
    )
    return any(m in msg for m in safe_markers)


def place_bet(signal: dict, dry_run: bool = None) -> dict:
    """
    Place a limit order on Polymarket.

    signal: {market_id, side, token_id, price, size, edge, ...}
    dry_run: override config DRY_RUN

    Returns: {success, order_id, message, dry_run}
    """
    if dry_run is None:
        dry_run = DRY_RUN

    result = {
        "success": False,
        "order_id": None,
        "message": "",
        "dry_run": dry_run,
        "actual_cost": 0.0,
    }

    # === Idempotency guard: skip if active trade already exists for this market+side ===
    # live_only: in live mode, ignore old dry-run simulated trades
    active_trades = db.get_active_trades(live_only=not dry_run)
    for t in active_trades:
        if t["market_id"] == signal["market_id"] and t["side"] == signal["side"]:
            result["message"] = f"Duplicate blocked: active trade #{t['id']} already exists for {signal['market_id'][:8]}โ€ฆ/{signal['side']}"
            logger.warning(f"โ›” {result['message']}")
            return result

    # === Cancelled-trade guard: stale cancel + CLOB fill race condition ===
    # Resolver cancels stale pending orders after 30min, but CLOB may have already
    # filled them on-chain. Block new orders until market resolves and reconciliation runs.
    if db.has_cancelled_for_market(signal["market_id"], signal["side"], live_only=not dry_run):
        result["message"] = f"Duplicate blocked: cancelled trade exists for {signal['market_id'][:8]}โ€ฆ/{signal['side']} (possible on-chain fill)"
        logger.warning(f"โ›” {result['message']}")
        return result

    if dry_run:
        # Simulate order
        result["success"] = True
        result["order_id"] = f"dry-{signal['market_id'][:8]}-{int(time.time())}"
        result["actual_cost"] = signal["size"]
        result["message"] = f"DRY RUN: Would {signal['side']} @ ${signal['price']:.2f} x ${signal['size']}"
        logger.info(f"๐Ÿงช {result['message']}")

        # Save trade to DB
        db.save_trade({
            "market_id": signal["market_id"],
            "order_id": result["order_id"],
            "side": signal["side"],
            "token_id": signal["token_id"],
            "price": signal["price"],
            "size": signal["size"],
            "edge": signal["edge"],
            "edge_tier": signal.get("edge_tier"),
            "model_prob": signal["model_prob"],
            "market_prob": signal["market_prob"],
            "status": "simulated",
            "dry_run": 1,
        })
        return result

    # Real order
    client = get_client()
    if not client:
        result["message"] = "CLOB client not available"
        logger.error(result["message"])
        return result

    # Geoblock safety: if a trade proxy is configured but failed to attach, refuse to
    # send the order un-proxied (would expose a geoblocked IP and likely be rejected).
    if TRADE_PROXY and not _proxy_applied:
        result["message"] = "Trade proxy configured but inactive - refusing to send un-proxied order"
        logger.error(result["message"])
        return result

    try:
        from py_clob_client_v2 import OrderArgs, OrderType, Side

        # signal["size"] is dollar amount, OrderArgs size is number of shares
        price = round(signal["price"], 2)
        shares = round(signal["size"] / price, 2) if price > 0 else 0

        # Polymarket minimum ~5 shares, but cap dollar cost to signal size
        MIN_SHARES = 5
        if shares < MIN_SHARES:
            min_cost = MIN_SHARES * price
            if min_cost > signal["size"] * 1.5:
                # Minimum order too expensive relative to intended size โ€” skip
                result["message"] = f"Skipped: min {MIN_SHARES} shares @ ${price:.2f} = ${min_cost:.2f} exceeds budget ${signal['size']:.2f}"
                logger.warning(f"โš ๏ธ {result['message']}")
                return result
            shares = MIN_SHARES

        # Actual dollar cost = shares ร— price (may differ from signal size due to MIN_SHARES)
        actual_cost = round(shares * price, 2)

        order_args = OrderArgs(
            token_id=signal["token_id"],
            price=price,
            size=shares,
            side=Side.BUY,
            builder_code=BUILDER_CODE,
        )

        signed = client.create_order(order_args)

        # === Retry loop for post_order (network-sensitive) ===
        last_error = None
        for attempt in range(MAX_RETRIES):
            try:
                response = client.post_order(signed, OrderType.GTC)
                order_id = response.get("orderID") or response.get("id")

                # A 2xx body without an orderID (or success=False) is a real
                # rejection, not a placed order. Treat as failure so we never
                # record a phantom pending trade that occupies a position slot.
                if not order_id or response.get("success") is False:
                    raise RuntimeError(f"Order rejected (no orderID): {response}")

                result["success"] = True
                result["order_id"] = order_id
                result["actual_cost"] = actual_cost
                result["message"] = f"Order placed: {signal['side']} @ ${price:.2f} x ${actual_cost:.2f} ({shares} shares)"
                logger.info(f"๐Ÿ’ฐ REAL ORDER: {result['message']} (ID: {order_id}, attempt: {attempt+1})")

                # Save trade to DB โ€” use actual_cost (shares ร— price), not signal size
                db.save_trade({
                    "market_id": signal["market_id"],
                    "order_id": order_id,
                    "side": signal["side"],
                    "token_id": signal["token_id"],
                    "price": signal["price"],
                    "size": actual_cost,
                    "edge": signal["edge"],
                    "edge_tier": signal.get("edge_tier"),
                    "model_prob": signal["model_prob"],
                    "market_prob": signal["market_prob"],
                    "status": "pending",
                    "dry_run": 0,
                })
                return result

            except Exception as e:
                last_error = e
                # Retry ONLY connection-level failures (request never sent).
                # Ambiguous errors may mean the order WAS placed: do not retry
                # (double-spend). Let reconciliation handle the unverified case.
                if _is_safe_to_retry(e) and attempt < MAX_RETRIES - 1:
                    wait = RETRY_BACKOFF[attempt]
                    logger.warning(f"post_order attempt {attempt+1} failed (retryable): {e}; retrying in {wait}s")
                    time.sleep(wait)
                else:
                    break

        # Loop ended without a confirmed placement
        result["message"] = f"Order failed: {last_error}"
        logger.error(f"โŒ {result['message']}")

        # Deterministic failures (balance, allowance) โ†’ cancelled (order never placed)
        # Ambiguous failures (timeout, network) โ†’ unverified (may have been placed)
        err_str = str(last_error).lower()
        # Match specific wording variants, not a bare "allowance" substring that
        # could appear in an unrelated error and wrongly mark a placed order cancelled.
        definite_reject_markers = (
            "not enough balance", "insufficient balance", "insufficient funds",
            "not enough allowance", "insufficient allowance",
        )
        is_definite_reject = any(m in err_str for m in definite_reject_markers)
        save_status = "cancelled" if is_definite_reject else "unverified"

        db.save_trade({
            "market_id": signal["market_id"],
            "order_id": None,
            "side": signal["side"],
            "token_id": signal["token_id"],
            "price": signal["price"],
            "size": signal["size"],
            "edge": signal["edge"],
            "edge_tier": signal.get("edge_tier"),
            "model_prob": signal["model_prob"],
            "market_prob": signal["market_prob"],
            "status": save_status,
            "dry_run": 0,
        })
        return result

    except Exception as e:
        result["message"] = f"Order creation failed: {e}"
        logger.error(result["message"])
        return result


def cancel_order(order_id: str) -> bool:
    """Cancel an open order"""
    client = get_client()
    if not client:
        return False
    try:
        from py_clob_client_v2 import OrderPayload
        client.cancel_order(OrderPayload(orderID=order_id))
        logger.info(f"Cancelled order {order_id}")
        return True
    except Exception as e:
        logger.error(f"Cancel failed: {e}")
        return False


def get_ctf_balance(token_id: str) -> float:
    """Get conditional token balance from CTF contract on-chain."""
    try:
        import requests
        CTF = '0x4D97DCd97eC945f40cF65F87097ACe5EA0476045'
        rpc = 'https://polygon.drpc.org'
        tid_hex = hex(int(token_id))[2:].zfill(64)
        proxy_padded = PROXY_ADDRESS[2:].lower().zfill(64)
        # ERC1155 balanceOf(address, uint256)
        data = '0x00fdd58e' + proxy_padded + tid_hex
        r = requests.post(rpc, json={
            'jsonrpc': '2.0', 'method': 'eth_call',
            'params': [{'to': CTF, 'data': data}, 'latest'], 'id': 1
        }, timeout=10, proxies={'http': None, 'https': None})
        return int(r.json()['result'], 16) / 1e6
    except Exception as e:
        logger.error(f"CTF balance check failed: {e}")
        return -1  # unknown


def redeem_position(condition_id: str, neg_risk: bool = True, token_id: str = None) -> dict:
    """
    Redeem a resolved position on Polymarket.
    Burns winning tokens โ†’ returns USDC to exchange.

    Strategy: try neg_risk=False first (partition-based, no amounts needed),
    then neg_risk=True with actual on-chain balance.
    Verifies tokens were actually burned after redeem.
    """
    result = {"success": False, "message": "", "tx_hash": None}

    if DRY_RUN:
        result["success"] = True
        result["message"] = f"DRY RUN: Would redeem {condition_id[:16]}..."
        logger.info(f"๐Ÿงช {result['message']}")
        return result

    # Pre-check: if we know the token_id, verify there are tokens to redeem
    if token_id:
        balance_before = get_ctf_balance(token_id)
        if balance_before == -1:
            # RPC error: balance unknown. Do NOT act on unknown/stale data; skip
            # and let the next resolve cycle retry rather than risk a wrong redeem.
            result["message"] = f"CTF balance unknown (RPC error) for {condition_id[:16]}... skipping redeem, will retry"
            logger.warning(result["message"])
            return result
        if balance_before == 0:
            result["success"] = True
            result["message"] = f"Already redeemed {condition_id[:16]}... (0 balance on CTF)"
            logger.info(f"โœ… {result['message']}")
            return result
        elif balance_before > 0:
            logger.info(f"CTF balance before redeem: {balance_before:.4f} shares for {condition_id[:16]}...")

    try:
        from polymarket_apis import PolymarketWeb3Client

        web3_client = PolymarketWeb3Client(
            private_key=PRIVATE_KEY,
            signature_type=SIGNATURE_TYPE,
        )

        receipt = None

        # Strategy 1: neg_risk=False (uses partition indices [1,2], no amounts needed)
        try:
            receipt = web3_client.redeem_position(
                condition_id=condition_id,
                amounts=[1, 1],  # ignored for non-neg_risk (library uses partition indices)
                neg_risk=False,
            )
            logger.info(f"Redeem neg_risk=False tx sent for {condition_id[:16]}...")
        except Exception as e:
            logger.debug(f"neg_risk=False failed: {e}, trying neg_risk=True...")

        # Verify after strategy 1
        if receipt and token_id:
            time.sleep(2)  # wait for chain confirmation
            balance_after = get_ctf_balance(token_id)
            if balance_after == 0:
                tx_hash = receipt.get("transactionHash", "") if isinstance(receipt, dict) else str(receipt)
                result["success"] = True
                result["tx_hash"] = tx_hash
                result["message"] = f"Redeemed {condition_id[:16]}... (neg_risk=False) โ†’ tx: {str(tx_hash)[:16]}..."
                logger.info(f"๐Ÿ’ฐ REDEEM VERIFIED: {result['message']}")
                return result
            else:
                logger.warning(f"neg_risk=False didn't burn tokens ({balance_after:.4f} remaining)")

        # Strategy 2: neg_risk=True with actual token balance
        if token_id:
            balance = get_ctf_balance(token_id)
            if balance > 0:
                # Try both single-outcome orderings (we don't know which index is
                # ours); each is verified below. Dropped [balance, balance]: it asks
                # to redeem BOTH outcomes while holding one, a guaranteed revert that
                # wastes gas on $3-6 positions.
                for amounts in [[balance, 0], [0, balance]]:
                    try:
                        receipt = web3_client.redeem_position(
                            condition_id=condition_id,
                            amounts=amounts,
                            neg_risk=True,
                        )
                        time.sleep(2)
                        balance_after = get_ctf_balance(token_id)
                        if balance_after == 0:
                            tx_hash = receipt.get("transactionHash", "") if isinstance(receipt, dict) else str(receipt)
                            result["success"] = True
                            result["tx_hash"] = tx_hash
                            result["message"] = f"Redeemed {condition_id[:16]}... (neg_risk=True, amounts={amounts}) โ†’ tx: {str(tx_hash)[:16]}..."
                            logger.info(f"๐Ÿ’ฐ REDEEM VERIFIED: {result['message']}")
                            return result
                        else:
                            logger.debug(f"amounts={amounts} didn't burn tokens ({balance_after:.4f} remaining)")
                    except Exception as e:
                        logger.debug(f"neg_risk=True amounts={amounts} failed: {e}")
                        continue

        # Fallback: if no token_id for verification, trust the receipt
        if receipt and not token_id:
            tx_hash = receipt.get("transactionHash", "") if isinstance(receipt, dict) else str(receipt)
            result["success"] = True
            result["tx_hash"] = tx_hash
            result["message"] = f"Redeemed {condition_id[:16]}... (unverified) โ†’ tx: {str(tx_hash)[:16]}..."
            logger.info(f"๐Ÿ’ฐ REDEEM (unverified): {result['message']}")
            return result

        if not result["success"]:
            result["message"] = f"All redeem strategies failed for {condition_id[:16]}..."
            logger.error(f"โŒ {result['message']}")

    except Exception as e:
        result["message"] = f"Redeem failed for {condition_id[:16]}...: {e}"
        logger.error(f"โŒ {result['message']}")

    return result


def wrap_usdc_to_pusd() -> float:
    """
    Wrap any USDC.e on the Safe wallet into pUSD via Collateral Onramp.
    After CTF redeem, USDC.e lands on Safe but CLOB needs pUSD.
    Returns amount wrapped (0 if nothing to wrap).
    """
    if DRY_RUN:
        return 0

    try:
        from polymarket_apis import PolymarketWeb3Client
        from polymarket_apis.clients.web3_client import (
            get_packed_signature, sign_safe_transaction, ADDRESS_ZERO,
        )

        wc = PolymarketWeb3Client(
            private_key=PRIVATE_KEY,
            signature_type=SIGNATURE_TYPE,
        )
        safe = wc.address
        w3 = wc.w3

        # Check USDC.e balance on Safe
        usdc_balance = wc.get_usdc_balance(safe)
        if usdc_balance < 0.01:
            return 0

        amount_int = int(usdc_balance * 1e6)
        USDC_E = '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174'
        ONRAMP = w3.to_checksum_address('0x93070a847efEf7F70739046A929D47a521F5B8ee')

        # Ensure approval (idempotent โ€” sets max allowance)
        wc.set_collateral_approval(ONRAMP)

        # wrap(address _asset, address _to, uint256 _amount)
        selector = w3.keccak(text='wrap(address,address,uint256)')[:4].hex()
        asset_pad = USDC_E[2:].lower().zfill(64)
        to_pad = safe[2:].lower().zfill(64)
        amt_pad = hex(amount_int)[2:].zfill(64)
        data = '0x' + selector + asset_pad + to_pad + amt_pad

        # Execute through Safe with fixed gas (bypass estimation issues)
        eoa = wc.account
        safe_txn = {'to': ONRAMP, 'data': data, 'operation': 0, 'value': 0}

        # Retry with FULL refresh: re-read the Safe nonce + EOA nonce and re-sign on
        # each attempt. A redeem tx mining between attempts makes the Safe nonce
        # stale (reverting execTransaction); reusing the stale signature wastes gas.
        max_retries = 3
        for attempt in range(1, max_retries + 1):
            try:
                safe_nonce = wc.safe.functions.nonce().call()
                packed_sig = get_packed_signature(
                    sign_safe_transaction(eoa, wc.safe, safe_txn, safe_nonce)
                )
                nonce = w3.eth.get_transaction_count(eoa.address, 'pending')
                txn_data = wc.safe.functions.execTransaction(
                    safe_txn['to'], safe_txn['value'], safe_txn['data'],
                    0, 0, 0, 0, ADDRESS_ZERO, ADDRESS_ZERO, packed_sig,
                ).build_transaction({
                    'from': eoa.address,
                    'nonce': nonce,
                    'gas': 500000,
                    'gasPrice': w3.eth.gas_price,
                    'chainId': 137,
                })

                signed = eoa.sign_transaction(txn_data)
                tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
                receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=60)

                if receipt['status'] == 1:
                    logger.info(f"๐Ÿ’ฑ Wrapped ${usdc_balance:.2f} USDC.e โ†’ pUSD (tx: {tx_hash.hex()[:16]}...)")
                    return usdc_balance
                else:
                    logger.error(f"โŒ Wrap failed (tx reverted): {tx_hash.hex()[:16]}...")
                    return 0
            except Exception as e:
                err_msg = str(e).lower()
                if 'nonce too low' in err_msg and attempt < max_retries:
                    time.sleep(2)
                    nonce = w3.eth.get_transaction_count(eoa.address, 'pending')
                    logger.warning(f"โš ๏ธ Wrap nonce retry {attempt}/{max_retries}, new nonce={nonce}")
                    continue
                raise  # re-raise if not nonce error or last attempt

        return 0  # should not reach here

    except Exception as e:
        logger.error(f"โŒ Wrap USDC.eโ†’pUSD failed: {e}")
        return 0


def get_balance() -> float | None:
    """Get USDC balance from Polymarket exchange contract"""
    client = get_client()
    if not client:
        return None
    try:
        from py_clob_client_v2 import BalanceAllowanceParams, AssetType
        params = BalanceAllowanceParams(asset_type=AssetType.COLLATERAL)  # library bug: params=None crashes
        balance = client.get_balance_allowance(params)
        raw = float(balance.get("balance", 0)) if balance else 0
        # Polymarket CLOB always returns balance in USDC micro-units (6 decimals)
        return raw / 1_000_000
    except Exception as e:
        logger.error(f"Balance check failed: {e}")
        return None


def get_wallet_balance() -> float | None:
    """Get on-chain USDC balance from EOA wallet (where redeems land)"""
    try:
        import requests
        from eth_account import Account
        eoa = Account.from_key(PRIVATE_KEY).address
        addr_padded = eoa[2:].lower().zfill(64)
        USDC_NATIVE = '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359'
        # publicnode works reliably (ankr returns stale data)
        rpc = 'https://polygon-bor-rpc.publicnode.com'
        r = requests.post(rpc, json={
            'jsonrpc': '2.0', 'method': 'eth_call',
            'params': [{'to': USDC_NATIVE, 'data': '0x70a08231' + addr_padded}, 'latest'],
            'id': 1
        }, timeout=5, proxies={'http': None, 'https': None})
        result = r.json().get('result', '0x0')
        return int(result, 16) / 1e6
    except Exception as e:
        logger.error(f"Wallet balance check failed: {e}")
        return None

๐Ÿ“œ Git History

058de34fix(audit): chunk 4 - minor robustness, display, calibration5 weeks ago
3de9313fix(audit): chunk 3 - executor robustness, scheduler, redeem, edge logic5 weeks ago
ddaa0a2fix(audit): chunk 2 - kill switch, auth, config safety5 weeks ago
16f2ea8fix(audit): chunk 1 - critical money/correctness bugs5 weeks ago
8fca132chore: initial commit โ€” version control setup5 weeks ago
Show last diff
Loading...