Fix merge
This commit is contained in:
+2
-1
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user