diff --git a/pyproject.toml b/pyproject.toml index d0dcdff..c5f8768 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ dependencies = [ "uvloop>=0.21 ; sys_platform != 'win32'", "socksio>=1.0", "python-socks>=2.4", + "py-builder-relayer-client>=0.0.2", ] [project.scripts] @@ -65,7 +66,7 @@ warn_return_any = true disallow_untyped_defs = true # third-party libs without stubs [[tool.mypy.overrides]] -module = ["py_clob_client_v2.*", "sortedcontainers.*", "web3.*", "eth_account.*"] +module = ["py_clob_client_v2.*", "sortedcontainers.*", "web3.*", "eth_account.*", "py_builder_relayer_client.*", "py_builder_signing_sdk.*"] ignore_missing_imports = true [tool.pytest.ini_options] diff --git a/src/polymaker/config.py b/src/polymaker/config.py index 4ef448f..3f0af35 100644 --- a/src/polymaker/config.py +++ b/src/polymaker/config.py @@ -168,11 +168,22 @@ class Secrets(BaseSettings): browser_address: str = Field(default="", alias="BROWSER_ADDRESS") polygon_rpc: str | None = Field(default=None, alias="POLYGON_RPC") alert_webhook_url: str | None = Field(default=None, alias="ALERT_WEBHOOK_URL") + # Polymarket builder API creds (self-generated via L2 auth: clob.create_builder_api_key) + # + relayer URL — needed to merge a V2 DepositWallet (sig_type 1/3), whose execute() + # only accepts calls from its factory/relayer. See merge.py. + builder_key: str | None = Field(default=None, alias="POLY_BUILDER_KEY") + builder_secret: str | None = Field(default=None, alias="POLY_BUILDER_SECRET") + builder_passphrase: str | None = Field(default=None, alias="POLY_BUILDER_PASSPHRASE") + relayer_url: str = Field(default="https://relayer-v2.polymarket.com", alias="POLY_RELAYER_URL") @property def has_wallet(self) -> bool: return bool(self.pk and self.browser_address) + @property + def has_builder_creds(self) -> bool: + return bool(self.builder_key and self.builder_secret and self.builder_passphrase) + class Config(BaseModel): """Fully-resolved configuration tree.""" diff --git a/src/polymaker/merge.py b/src/polymaker/merge.py index 8a003f2..85d93e6 100644 --- a/src/polymaker/merge.py +++ b/src/polymaker/merge.py @@ -3,16 +3,14 @@ When we hold both YES and NO in the same market, merging the pair returns collateral (1 USDC/pUSD per pair) — a maker-only exit with zero market impact. -Two execution paths: - * EOA wallet (signature_type=0): direct contract call, fully implemented here. - * Proxy/Safe wallet (signature_type 1/2): the merge tx must be routed through - the Safe. That path is gated on the Phase-2 wallet spike (see the README) — until - then merging is skipped (logged), and inventory is exited via limit sells - instead. The bot is fully functional without it; merging just frees capital - sooner. - -The V2/pUSD collateral question (does the CTF collateral resolve to pUSD post- -migration?) is also spike-gated; addresses are config-driven, not baked in. +Execution paths by wallet type (config.wallet.signature_type): + * EOA (0): direct contract call (`_merge_eoa`). + * Gnosis Safe (2): wrapped in the Safe's `execTransaction`, owner eth_sign (`_merge_safe`). + * Polymarket V2 DepositWallet (1/3): `_merge_deposit_wallet` — the wallet's `execute()` + ONLY accepts calls from its factory (driven by Polymarket's relayer), so we sign an + EIP-712 batch (owner) and submit it via the builder relayer (gasless — relayer pays). + Verified live 2026-07-09 (LeBron neg-risk merge, tx 0x4d2a2064). Needs self-generated + builder API creds (clob.create_builder_api_key) in .env. See memory merge-deposit-wallet. """ from __future__ import annotations @@ -56,8 +54,27 @@ _NEG_RISK_ABI = [ "outputs": [], } ] - - +# Gnosis Safe (Polymarket proxy wallet) — the merge tx is routed through the Safe's +# execTransaction, owner-signed. Ported from the old poly_merger/safe-helpers.js. +_ZERO = "0x0000000000000000000000000000000000000000" +_SAFE_ABI = [ + {"name": "nonce", "type": "function", "stateMutability": "view", "inputs": [], + "outputs": [{"type": "uint256"}]}, + {"name": "getTransactionHash", "type": "function", "stateMutability": "view", + "inputs": [{"name": "to", "type": "address"}, {"name": "value", "type": "uint256"}, + {"name": "data", "type": "bytes"}, {"name": "operation", "type": "uint8"}, + {"name": "safeTxGas", "type": "uint256"}, {"name": "baseGas", "type": "uint256"}, + {"name": "gasPrice", "type": "uint256"}, {"name": "gasToken", "type": "address"}, + {"name": "refundReceiver", "type": "address"}, {"name": "_nonce", "type": "uint256"}], + "outputs": [{"type": "bytes32"}]}, + {"name": "execTransaction", "type": "function", "stateMutability": "payable", + "inputs": [{"name": "to", "type": "address"}, {"name": "value", "type": "uint256"}, + {"name": "data", "type": "bytes"}, {"name": "operation", "type": "uint8"}, + {"name": "safeTxGas", "type": "uint256"}, {"name": "baseGas", "type": "uint256"}, + {"name": "gasPrice", "type": "uint256"}, {"name": "gasToken", "type": "address"}, + {"name": "refundReceiver", "type": "address"}, {"name": "signatures", "type": "bytes"}], + "outputs": [{"type": "bool"}]}, +] class Merger: def __init__(self, cfg: Config) -> None: self._cfg = cfg @@ -79,23 +96,25 @@ class Merger: @property def can_merge(self) -> bool: - """EOA can merge directly today; Safe/proxy is spike-gated.""" - return self._cfg.wallet.signature_type == 0 + """EOA (0) and Gnosis Safe (2) merge on-chain directly. The V2 DepositWallet + (1/3) merges via the builder relayer — possible only when builder creds are + configured (self-generate once with clob.create_builder_api_key).""" + st = self._cfg.wallet.signature_type + if st in (0, 2): + return True + return self._cfg.secrets.has_builder_creds def merge(self, condition_id: str, amount_raw: int, neg_risk: bool) -> str | None: """Merge `amount_raw` (6-dec) YES+NO pairs. Returns tx hash or None.""" - if amount_raw <= 0: - return None - if not self.can_merge: - log.info( - "merge_skipped_safe_wallet", - condition=condition_id[:12], - amount=amount_raw, - note="Safe merge is spike-gated; inventory exits via limit sells", - ) + if amount_raw <= 0 or not self.can_merge: return None try: - return self._merge_eoa(condition_id, amount_raw, neg_risk) + st = self._cfg.wallet.signature_type + if st == 0: + return self._merge_eoa(condition_id, amount_raw, neg_risk) + if st == 2: # POLY_GNOSIS_SAFE + return self._merge_safe(condition_id, amount_raw, neg_risk) + return self._merge_deposit_wallet(condition_id, amount_raw, neg_risk) # 1/3 except Exception as exc: # noqa: BLE001 log.error("merge_failed", condition=condition_id[:12], err=str(exc)) return None @@ -136,6 +155,112 @@ class Merger: log.info("merge_sent", condition=condition_id[:12], amount=amount_raw, tx=h[:14]) return h + def _inner_merge_call(self, condition_id: str, amount_raw: int, neg_risk: bool) -> tuple[str, str]: + """Build the CTF/adapter mergePositions call → (to, calldata-hex).""" + w3 = self._w3 + cond = _to_bytes32(condition_id) + if neg_risk: + to = w3.to_checksum_address(NEG_RISK_ADAPTER) + c = w3.eth.contract(address=to, abi=_NEG_RISK_ABI) + fn = c.functions.mergePositions(cond, amount_raw) + else: + to = w3.to_checksum_address(CONDITIONAL_TOKENS) + c = w3.eth.contract(address=to, abi=_CTF_ABI) + fn = c.functions.mergePositions( + w3.to_checksum_address(USDC_COLLATERAL), b"\x00" * 32, cond, [1, 2], amount_raw) + # encode calldata offline (explicit gas/nonce -> no RPC estimation) + data = fn.build_transaction( + {"gas": 0, "gasPrice": 0, "nonce": 0, "chainId": self._cfg.wallet.chain_id})["data"] + return to, data + + def _merge_safe(self, condition_id: str, amount_raw: int, neg_risk: bool) -> str: + """Route the merge through the Polymarket proxy/Gnosis Safe: build the + mergePositions call, wrap it in the Safe's execTransaction, and owner-sign + the Safe tx hash with the eth_sign flavor (v += 4). Ported from the old + poly_merger/safe-helpers.js.""" + from eth_account.messages import encode_defunct + + self._ensure_web3() + w3, signer = self._w3, self._account + to, data = self._inner_merge_call(condition_id, amount_raw, neg_risk) + + safe_addr = w3.to_checksum_address(self._cfg.secrets.browser_address) + safe = w3.eth.contract(address=safe_addr, abi=_SAFE_ABI) + s_nonce = safe.functions.nonce().call() + # Safe tx: value=0, operation=CALL(0), all gas/refund fields 0 (owner pays gas) + safe_tx_hash = safe.functions.getTransactionHash( + to, 0, data, 0, 0, 0, 0, _ZERO, _ZERO, s_nonce).call() + + sig = signer.sign_message(encode_defunct(primitive=bytes(safe_tx_hash))) + raw = bytes(sig.signature) # r(32)+s(32)+v(1), v in {27,28} + packed = raw[:64] + bytes([raw[64] + 4]) # Safe eth_sign signature: v += 4 + + tx = safe.functions.execTransaction( + to, 0, data, 0, 0, 0, 0, _ZERO, _ZERO, packed + ).build_transaction({ + "from": signer.address, + "nonce": w3.eth.get_transaction_count(signer.address), + "chainId": self._cfg.wallet.chain_id, + "gas": 600_000, + "maxFeePerGas": w3.eth.gas_price * 2, + "maxPriorityFeePerGas": w3.to_wei(30, "gwei"), + }) + signed = signer.sign_transaction(tx) + tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction) + receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=180) + h = str(receipt["transactionHash"].hex()) + status = receipt.get("status", 1) + log.info("merge_sent_safe", condition=condition_id[:12], amount=amount_raw, + tx=h[:14], status=status) + if status != 1: + raise RuntimeError(f"safe merge reverted: {h}") + return h + + def _merge_deposit_wallet(self, condition_id: str, amount_raw: int, neg_risk: bool) -> str: + """Merge via Polymarket's V2 DepositWallet + builder relayer (gasless). + + The wallet's execute() only accepts calls from its factory (driven by the + relayer), so we can't self-submit. Instead sign an EIP-712 batch (owner) with + the mergePositions call and POST it to the relayer, which submits on-chain and + pays the gas. Requests route through cfg.proxy (Polymarket geo-blocks). Verified + live 2026-07-09 (neg-risk merge, tx 0x4d2a2064).""" + import os + import time as _time + + from py_builder_relayer_client.client import RelayClient + from py_builder_relayer_client.models import DepositWalletCall + from py_builder_signing_sdk.config import BuilderConfig + from py_builder_signing_sdk.sdk_types import BuilderApiKeyCreds + + self._ensure_web3() + w3, sec = self._w3, self._cfg.secrets + # the relayer client uses bare `requests`, which only honors a proxy via env vars + if self._cfg.proxy: + for k in ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", + "http_proxy", "https_proxy", "all_proxy"): + os.environ[k] = self._cfg.proxy + to, data = self._inner_merge_call(condition_id, amount_raw, neg_risk) + + creds = BuilderApiKeyCreds( + key=sec.builder_key, secret=sec.builder_secret, passphrase=sec.builder_passphrase) + client = RelayClient( + sec.relayer_url, self._cfg.wallet.chain_id, private_key=sec.pk, + builder_config=BuilderConfig(local_builder_creds=creds)) + signer = self._account.address + nonce = client.get_nonce(signer, "WALLET")["nonce"] + deadline = str(int(_time.time()) + 3600) + call = DepositWalletCall(target=w3.to_checksum_address(to), value="0", data=data) + resp = client.execute_deposit_wallet_batch( + [call], w3.to_checksum_address(sec.browser_address), nonce, deadline) + h = str(getattr(resp, "transaction_hash", None) or getattr(resp, "hash", None)) + receipt = w3.eth.wait_for_transaction_receipt(h, timeout=180) + status = receipt.get("status", 1) + log.info("merge_sent_deposit_wallet", condition=condition_id[:12], + amount=amount_raw, tx=h[:14], status=status) + if status != 1: + raise RuntimeError(f"deposit-wallet merge reverted: {h}") + return h + def _to_bytes32(hex_str: str) -> bytes: s = hex_str[2:] if hex_str.startswith("0x") else hex_str diff --git a/uv.lock b/uv.lock index 8b3e707..7449c12 100644 --- a/uv.lock +++ b/uv.lock @@ -1118,6 +1118,7 @@ version = "2.0.0" source = { editable = "." } dependencies = [ { name = "httpx" }, + { name = "py-builder-relayer-client" }, { name = "py-clob-client-v2" }, { name = "pydantic" }, { name = "pydantic-settings" }, @@ -1147,6 +1148,7 @@ dev = [ requires-dist = [ { name = "httpx", specifier = ">=0.28" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.14" }, + { name = "py-builder-relayer-client", specifier = ">=0.0.2" }, { name = "py-clob-client-v2", specifier = "==1.0.2" }, { name = "pydantic", specifier = ">=2.10" }, { name = "pydantic-settings", specifier = ">=2.7" }, @@ -1262,6 +1264,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, ] +[[package]] +name = "py-builder-relayer-client" +version = "0.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "eth-abi" }, + { name = "eth-account" }, + { name = "eth-utils" }, + { name = "hexbytes" }, + { name = "py-builder-signing-sdk" }, + { name = "python-dotenv" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/85/bca9addadc53e186fcb302e56955f08fabbe8db8d8f3f9da1beb2f1e212e/py_builder_relayer_client-0.0.2.tar.gz", hash = "sha256:6e570b0cbcd59115745daad3dfb4196c1ae4713771e7b66000731a836bf0e73c", size = 21038, upload-time = "2026-05-29T14:49:48.786Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/9a/166109f2023d16715fcac8581893619303632280d84f8d71aa2509bcada1/py_builder_relayer_client-0.0.2-py3-none-any.whl", hash = "sha256:a5c34fff4aff412c4cec46139f89b9df4ce8719a39a167b0c18521ac90ac9463", size = 31910, upload-time = "2026-05-29T14:49:47.67Z" }, +] + +[[package]] +name = "py-builder-signing-sdk" +version = "0.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dotenv" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/e3/fab3253021a4d766345f491f00e40d89c7c5640bf6d20d9f5a3182b482de/py_builder_signing_sdk-0.0.2.tar.gz", hash = "sha256:27fa4401944220c51809f549a263c6e53b5cd981fca98eda7ef6f21944e6bf94", size = 6411, upload-time = "2025-10-20T22:17:56.658Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/fb/23c68c8f6356a50f959e2df2ae80e8344c3ff8ccca92943848a57a495928/py_builder_signing_sdk-0.0.2-py3-none-any.whl", hash = "sha256:114b9d57bec228177d759ce15c475589f47db2252ed1fd67cac3c9b0640abe76", size = 9082, upload-time = "2025-10-20T22:17:55.629Z" }, +] + [[package]] name = "py-clob-client-v2" version = "1.0.2"