Fix merge

This commit is contained in:
Daniel Sapkota
2026-07-09 14:47:52 -04:00
parent f35e79030d
commit 4f32103591
4 changed files with 195 additions and 25 deletions
+2 -1
View File
@@ -22,6 +22,7 @@ dependencies = [
"uvloop>=0.21 ; sys_platform != 'win32'", "uvloop>=0.21 ; sys_platform != 'win32'",
"socksio>=1.0", "socksio>=1.0",
"python-socks>=2.4", "python-socks>=2.4",
"py-builder-relayer-client>=0.0.2",
] ]
[project.scripts] [project.scripts]
@@ -65,7 +66,7 @@ warn_return_any = true
disallow_untyped_defs = true disallow_untyped_defs = true
# third-party libs without stubs # third-party libs without stubs
[[tool.mypy.overrides]] [[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 ignore_missing_imports = true
[tool.pytest.ini_options] [tool.pytest.ini_options]
+11
View File
@@ -168,11 +168,22 @@ class Secrets(BaseSettings):
browser_address: str = Field(default="", alias="BROWSER_ADDRESS") browser_address: str = Field(default="", alias="BROWSER_ADDRESS")
polygon_rpc: str | None = Field(default=None, alias="POLYGON_RPC") polygon_rpc: str | None = Field(default=None, alias="POLYGON_RPC")
alert_webhook_url: str | None = Field(default=None, alias="ALERT_WEBHOOK_URL") 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 @property
def has_wallet(self) -> bool: def has_wallet(self) -> bool:
return bool(self.pk and self.browser_address) 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): class Config(BaseModel):
"""Fully-resolved configuration tree.""" """Fully-resolved configuration tree."""
+149 -24
View File
@@ -3,16 +3,14 @@
When we hold both YES and NO in the same market, merging the pair returns 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. collateral (1 USDC/pUSD per pair) — a maker-only exit with zero market impact.
Two execution paths: Execution paths by wallet type (config.wallet.signature_type):
* EOA wallet (signature_type=0): direct contract call, fully implemented here. * EOA (0): direct contract call (`_merge_eoa`).
* Proxy/Safe wallet (signature_type 1/2): the merge tx must be routed through * Gnosis Safe (2): wrapped in the Safe's `execTransaction`, owner eth_sign (`_merge_safe`).
the Safe. That path is gated on the Phase-2 wallet spike (see the README) — until * Polymarket V2 DepositWallet (1/3): `_merge_deposit_wallet` — the wallet's `execute()`
then merging is skipped (logged), and inventory is exited via limit sells ONLY accepts calls from its factory (driven by Polymarket's relayer), so we sign an
instead. The bot is fully functional without it; merging just frees capital EIP-712 batch (owner) and submit it via the builder relayer (gasless — relayer pays).
sooner. 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.
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.
""" """
from __future__ import annotations from __future__ import annotations
@@ -56,8 +54,27 @@ _NEG_RISK_ABI = [
"outputs": [], "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: class Merger:
def __init__(self, cfg: Config) -> None: def __init__(self, cfg: Config) -> None:
self._cfg = cfg self._cfg = cfg
@@ -79,23 +96,25 @@ class Merger:
@property @property
def can_merge(self) -> bool: def can_merge(self) -> bool:
"""EOA can merge directly today; Safe/proxy is spike-gated.""" """EOA (0) and Gnosis Safe (2) merge on-chain directly. The V2 DepositWallet
return self._cfg.wallet.signature_type == 0 (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: 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.""" """Merge `amount_raw` (6-dec) YES+NO pairs. Returns tx hash or None."""
if amount_raw <= 0: if amount_raw <= 0 or not self.can_merge:
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",
)
return None return None
try: 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 except Exception as exc: # noqa: BLE001
log.error("merge_failed", condition=condition_id[:12], err=str(exc)) log.error("merge_failed", condition=condition_id[:12], err=str(exc))
return None return None
@@ -136,6 +155,112 @@ class Merger:
log.info("merge_sent", condition=condition_id[:12], amount=amount_raw, tx=h[:14]) log.info("merge_sent", condition=condition_id[:12], amount=amount_raw, tx=h[:14])
return h 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: def _to_bytes32(hex_str: str) -> bytes:
s = hex_str[2:] if hex_str.startswith("0x") else hex_str s = hex_str[2:] if hex_str.startswith("0x") else hex_str
Generated
+33
View File
@@ -1118,6 +1118,7 @@ version = "2.0.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "httpx" }, { name = "httpx" },
{ name = "py-builder-relayer-client" },
{ name = "py-clob-client-v2" }, { name = "py-clob-client-v2" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
@@ -1147,6 +1148,7 @@ dev = [
requires-dist = [ requires-dist = [
{ name = "httpx", specifier = ">=0.28" }, { name = "httpx", specifier = ">=0.28" },
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.14" }, { 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 = "py-clob-client-v2", specifier = "==1.0.2" },
{ name = "pydantic", specifier = ">=2.10" }, { name = "pydantic", specifier = ">=2.10" },
{ name = "pydantic-settings", specifier = ">=2.7" }, { 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" }, { 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]] [[package]]
name = "py-clob-client-v2" name = "py-clob-client-v2"
version = "1.0.2" version = "1.0.2"