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'",
"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]
+11
View File
@@ -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."""
+149 -24
View File
@@ -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
Generated
+33
View File
@@ -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"