redeemer rewritten for the pUSD stack: gasless redeemPositions from the deposit wallet via the executor's SecureClient, measured-delta reporting, calldata proven by on-chain simulation; auto_redeem re-enabled (closes #4)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jaxperro
2026-07-19 16:43:09 -04:00
parent 595a63dc5b
commit 5070c234f2
4 changed files with 98 additions and 81 deletions
+1 -1
View File
@@ -32,7 +32,7 @@
"funder_address": "", "funder_address": "",
"signature_type": 1, "signature_type": 1,
"order_type": "FAK", "order_type": "FAK",
"auto_redeem": false, "auto_redeem": true,
"rpc_url": "" "rpc_url": ""
}, },
"wallets": [ "wallets": [
+7 -3
View File
@@ -2547,11 +2547,15 @@ def main():
if cfg.get("live", {}).get("auto_redeem", True): if cfg.get("live", {}).get("auto_redeem", True):
try: try:
from redeem import Redeemer from redeem import Redeemer
redeemer = Redeemer(cfg) # 2026-07-19 rewrite (closes #4): shares the executor's
log("on-chain auto-redeem ENABLED (resolved winners redeemed to USDC)") # SecureClient — gasless redeemPositions FROM the deposit
# wallet with pUSD collateral (the web3/EOA/USDC.e version
# was a silent no-op on this stack)
redeemer = Redeemer(executor.client)
log("on-chain auto-redeem ENABLED (pUSD, deposit-wallet gasless)")
except Exception as e: except Exception as e:
log(f"⚠ auto-redeem unavailable ({e}) — resolved winners must be " log(f"⚠ auto-redeem unavailable ({e}) — resolved winners must be "
f"redeemed manually in the UI. (pip install web3, set live.private_key)") f"redeemed manually in the UI.")
else: else:
executor = LedgerPaperExecutor() # tracks cash flows for realized-P&L reporting executor = LedgerPaperExecutor() # tracks cash flows for realized-P&L reporting
+69 -77
View File
@@ -1,99 +1,87 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""On-chain redemption of resolved Polymarket positions (live mode, gap 2). """On-chain redemption of resolved Polymarket positions — 2026 pUSD stack.
After a market resolves, winning conditional-token shares are still ERC-1155 REWRITTEN 2026-07-19 (closes #4; audit 3.3). The old web3 version was a
tokens — you must REDEEM them through Gnosis CTF (ConditionalTokens) to turn them silent no-op booby trap on the current stack: it redeemed with USDC.e
back into USDC. The CLOB API doesn't do this; copybot calls this module so a collateral from the EOA, but live positions are pUSD-collateralized
resolved winner's freed capital is actually back in the wallet (in paper mode the (0xC011a7…, README gotcha 16) and live in the DEPOSIT WALLET (0x455e…45a1).
recycle is just a number; live needs the real redemption). `redeemPositions` with the wrong collateral/holder SUCCEEDS doing nothing —
try_redeem returned ok and callers booked proceeds that never arrived.
Covers standard binary markets via CTF.redeemPositions. NEG-RISK markets settle Now: zero web3. The unified SDK's gasless relay executes redeemPositions
through a different adapter and are NOT handled here — copybot warns and you redeem FROM the deposit wallet (same `execute_transaction` path the 07-10 bridge
those in the Polymarket UI. (Neg-risk auto-redeem is a clean follow-up.) wrap used), with pUSD as the collateral token. Selector 0x01b7037c =
keccak4("redeemPositions(address,bytes32,bytes32,uint256[])"), computed and
pinned 2026-07-19. The result reports the MEASURED pUSD delta so callers can
see what actually landed (the balance can move concurrently with trading, so
the delta is evidence, not the ledger entry).
Requires: pip install web3 and a Polygon RPC. RPC comes from config NEG-RISK markets settle through a different adapter — callers must keep
live.rpc_url, else is built from your Alchemy key. Each redeem costs a little POL excluding them (market_neg_risk guard); redeeming them here is a no-op.
(MATIC) in gas — fund the EOA with a few POL.
UNTESTED against live until you run it on a small position — verify one redemption python3 redeem.py <conditionId> # one-shot manual redeem (local config)
manually before trusting the loop:
python3 redeem.py <conditionId> # redeem one resolved market, print tx hash
""" """
import json import json
import os import os
import sys import sys
import time
# Polygon mainnet CTF = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045" # ConditionalTokens
CHAIN_ID = 137 PUSD = "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB" # CollateralToken (2026 stack)
CTF_ADDRESS = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045" # Gnosis ConditionalTokens _SEL_REDEEM = "01b7037c" # redeemPositions(address,bytes32,bytes32,uint256[])
USDC_ADDRESS = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174" # USDC.e (collateral)
ZERO32 = b"\x00" * 32 # parentCollectionId
CTF_ABI = json.loads("""[
{"constant": false,
"inputs": [
{"name": "collateralToken", "type": "address"},
{"name": "parentCollectionId", "type": "bytes32"},
{"name": "conditionId", "type": "bytes32"},
{"name": "indexSets", "type": "uint256[]"}],
"name": "redeemPositions",
"outputs": [], "stateMutability": "nonpayable", "type": "function"}
]""")
def _rpc(cfg): def _w32(v):
url = cfg.get("live", {}).get("rpc_url") if isinstance(v, int):
if url: return hex(v)[2:].rjust(64, "0")
return url h = v[2:] if v.startswith("0x") else v
key = cfg.get("alchemy_key") return h.lower().rjust(64, "0")
if key:
return f"https://polygon-mainnet.g.alchemy.com/v2/{key}"
raise RuntimeError("no Polygon RPC — set live.rpc_url or alchemy_key in config") def redeem_calldata(condition_id, index_sets=(1, 2)):
"""ABI-encode redeemPositions(pUSD, 0x0, cond, indexSets). Head: 3 static
words + the dynamic-array offset (0x80); tail: length + members."""
head = _w32(PUSD) + _w32(0) + _w32(condition_id) + _w32(0x80)
tail = _w32(len(index_sets)) + "".join(_w32(i) for i in index_sets)
return "0x" + _SEL_REDEEM + head + tail
class Redeemer: class Redeemer:
def __init__(self, cfg): """Redeems via an already-authenticated SecureClient (share the live
from web3 import Web3 # imported lazily (live-only dep) executor's — one client, one deposit wallet, no second key path)."""
self.Web3 = Web3
pk = cfg.get("live", {}).get("private_key")
if not pk:
raise RuntimeError("live.private_key required to redeem")
self.w3 = Web3(Web3.HTTPProvider(_rpc(cfg)))
if not self.w3.is_connected():
raise RuntimeError("Polygon RPC not reachable")
self.acct = self.w3.eth.account.from_key(pk)
self.address = self.acct.address
self.ctf = self.w3.eth.contract(
address=Web3.to_checksum_address(CTF_ADDRESS), abi=CTF_ABI)
self.usdc = Web3.to_checksum_address(USDC_ADDRESS)
@staticmethod def __init__(self, client):
def _cond_bytes(condition_id): if client is None:
h = condition_id[2:] if condition_id.startswith("0x") else condition_id raise RuntimeError("Redeemer needs the live executor's SecureClient")
return bytes.fromhex(h) self.client = client
def _collateral(self):
try:
return self.client.get_balance_allowance(asset_type="COLLATERAL").balance
except Exception:
return None
def try_redeem(self, condition_id, index_sets=(1, 2)): def try_redeem(self, condition_id, index_sets=(1, 2)):
"""Redeem all of this wallet's holdings in a resolved binary market. """-> (ok, info). ok = the relayed tx was accepted; info carries the
Returns (ok, tx_hash_or_reason). index_sets [1,2] covers both outcome measured pUSD delta (evidence — concurrent fills can move it too)."""
slots — the winning one pays USDC, the losing one is a no-op."""
try: try:
fn = self.ctf.functions.redeemPositions( from polymarket import calls as pmcalls
self.usdc, ZERO32, self._cond_bytes(condition_id), list(index_sets)) before = self._collateral()
tx = fn.build_transaction({ call = pmcalls.TransactionCall(to=CTF,
"from": self.address, data=redeem_calldata(condition_id,
"nonce": self.w3.eth.get_transaction_count(self.address), index_sets))
"chainId": CHAIN_ID, h = self.client.execute_transaction(
"gasPrice": int(self.w3.eth.gas_price * 1.25), calls=[call], metadata=f"redeem {condition_id[:14]}")
}) out = h.wait() if hasattr(h, "wait") else h
signed = self.acct.sign_transaction(tx) time.sleep(2) # let the relayed state settle
raw = getattr(signed, "raw_transaction", None) or signed.rawTransaction after = self._collateral()
h = self.w3.eth.send_raw_transaction(raw) delta = (after - before) / 1e6 if None not in (before, after) else None
rcpt = self.w3.eth.wait_for_transaction_receipt(h, timeout=180) return True, (f"redeemed · pUSD delta "
hx = self.w3.to_hex(h) f"{f'{delta:+.2f}' if delta is not None else 'unmeasured'}"
return (True, hx) if rcpt.status == 1 else (False, f"reverted {hx}") f" · {str(out)[:60]}")
except Exception as e: except Exception as e:
return False, str(e) return False, f"{type(e).__name__}: {str(e)[:80]}"
def main(): def main():
@@ -101,8 +89,12 @@ def main():
sys.exit("usage: python3 redeem.py <conditionId>") sys.exit("usage: python3 redeem.py <conditionId>")
cfg = json.load(open(os.path.join(os.path.dirname(os.path.abspath(__file__)), cfg = json.load(open(os.path.join(os.path.dirname(os.path.abspath(__file__)),
"config.json"))) "config.json")))
r = Redeemer(cfg) from polymarket import SecureClient
print(f"redeeming {sys.argv[1]} from {r.address}") pk = (os.environ.get("LIVE_PRIVATE_KEY") or "").strip() \
or cfg.get("live", {}).get("private_key")
if not pk:
sys.exit("no live.private_key / LIVE_PRIVATE_KEY")
r = Redeemer(SecureClient.create(private_key=pk))
ok, info = r.try_redeem(sys.argv[1]) ok, info = r.try_redeem(sys.argv[1])
print(("" if ok else "") + info) print(("" if ok else "") + info)
+21
View File
@@ -0,0 +1,21 @@
"""Stub test for the pUSD redeemer's calldata (closes #4 regression pin).
Layout proven on-chain 2026-07-19: a decoded revert ("result not received")
on an unresolved market + clean SUCCESS simulation on a resolved one."""
import os, sys
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
from redeem import redeem_calldata, PUSD, _SEL_REDEEM
COND = "0x" + "ab" * 32
d = redeem_calldata(COND)
fails = []
if not d.startswith("0x" + _SEL_REDEEM): fails.append("selector")
body = d[10:]
words = [body[i:i+64] for i in range(0, len(body), 64)]
if len(words) != 7: fails.append(f"word count {len(words)}")
if words[0][-40:] != PUSD[2:].lower(): fails.append("collateral != pUSD")
if int(words[1], 16) != 0: fails.append("parentCollectionId != 0")
if words[2] != "ab" * 32: fails.append("conditionId")
if int(words[3], 16) != 0x80: fails.append("array offset")
if [int(w, 16) for w in words[4:]] != [2, 1, 2]: fails.append("indexSets")
print("FAILURES:", fails or "none")
sys.exit(1 if fails else 0)