diff --git a/HANDOFF.md b/HANDOFF.md index 45d51c29..85bddeb1 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,4 +1,4 @@ -# Session handoff — 2026-07-13 (rev 11: RTDS-seed lag fix, tick-conform prices, floor_pin audit) +# Session handoff — 2026-07-14 (rev 12: chain-seed — fills decoded from the tx receipt) ## Operating boundary set by the user (2026-07-13, "away for a while") **Full autonomy on BOTH bots**; real-money bot **stays ARMED**. Never touch @@ -7,6 +7,31 @@ the private key, never raise/loosen caps, never rotate the Discord webhook DISARM the live bot (`flyctl secrets unset LIVE_CONFIRM`) rather than push through it. +## Shipped since rev 11 (2026-07-14) +- **CHAIN SEED (T0b)**: the missed-bets review found the one remaining + detection leak — RTDS doesn't emit every market (the "Credible public + sale" sweep was missed on BOTH books at 20:18Z despite the rev-11 fix: + RTDS silent + data-api indexer past the 600s window; badaf's detection + misses are the only +EV ones, +$34.60/10 on paper). Now the Alchemy push + carries its tx hashes into `on_wallet_activity(hint_txs=…)`; any hash the + data-api fetch didn't return is decoded straight from the receipt + (`fills_from_tx`: OrderFilled logs, maker==wallet row; gamma lookup for + title/outcome/cond; block ts). Validated 200/200 against shadow-ledger + fills and end-to-end on the actual missed Credible txs. Fail-open on any + RPC/gamma error (backstops unchanged); forged-event-safe (only the two + new-stack exchange contracts 0xe111…996b / 0xe2222…0f59 are trusted + emitters — a dust transfer can trigger the webhook, so this matters). + Tests: tests/test_chainseed.py (6 stub paths). Both boxes have + ALCHEMY_RPC_URL, so the path is live on deploy. Watch for the first + `chainseed:` line — expected on badaf niche markets during RTDS flaps. +- **Missed-bets audit verdict** (what prompted this): price guard skips are + net-NEGATIVE would-be P&L on both books (validated again — leave at 0.05); + old detection misses were -EV and all from since-dropped wallets; conform/ + sub-$1/sub-penny categories all fixed pre-rev-11. Remaining candidates NOT + built: thin-book FAK no-match → resting limit via the pending registry + (small now, scales with stakes), paper thin-book modeling parity (biases the + live-vs-paper ratio optimistic). + ## Shipped since rev 10 (all deployed + verified) - **RTDS-seed lag fix** (the big one): RTDS detected trades at ~0.3s but the copy landed 130-305s later — `on_wallet_activity` threw the RTDS payload diff --git a/copybot.py b/copybot.py index 24ccd73d..7093bba5 100644 --- a/copybot.py +++ b/copybot.py @@ -84,15 +84,19 @@ _RPC_URL = None # resolved once in main() (env/config), stays None wi _PAYOUTS = {} # cond -> [p0, p1], cached once resolved (immutable) -def _eth_call(data): - body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "eth_call", - "params": [{"to": CTF_ADDR, "data": data}, "latest"]}).encode() +def _rpc(method, params): + body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": method, + "params": params}).encode() req = urllib.request.Request(_RPC_URL, data=body, headers={"Content-Type": "application/json"}) with urllib.request.urlopen(req, timeout=15, context=SSL_CTX) as r: return json.loads(r.read())["result"] +def _eth_call(data): + return _rpc("eth_call", [{"to": CTF_ADDR, "data": data}, "latest"]) + + def onchain_payouts(cond): """[payout_outcome0, payout_outcome1] for a resolved condition (order matches the CLOB market's tokens[] order), or None if unresolved / no RPC configured.""" @@ -112,6 +116,100 @@ def onchain_payouts(cond): except Exception: return None + +# ── T0b: fills straight from the tx receipt (Alchemy push) ────────────────── +# RTDS doesn't emit every market (2026-07-13: badaf's "Credible public sale" +# entry was missed on BOTH books — RTDS silent, and the data-api indexer +# lagged past the 600s window, so the ~3s Alchemy push detected a trade it +# couldn't fetch). The push carries the tx hash, and the fill is fully +# decodable from the receipt — so no market's copy speed depends on RTDS +# coverage or indexer freshness. +_ORDER_FILLED = "0xd543adfd945773f1a62f74f0ee55a5e3b9b1a28262980ba90b1a89f2ea84d8ee" +_EXCHANGES = { # new-stack (pUSD) exchange contracts, the only trusted + # OrderFilled emitters — an attacker CAN trigger the webhook with a dust + # transfer, so never decode fills from arbitrary contracts' events. + # Both observed across 200/200 shadow-ledger fills (2026-07-14). + "0xe111180000d2663c0091e4f400237545b87b996b", + "0xe2222d279d744050d28e00520010520000310f59", +} +_TOK_MKT = {} # token id -> (title, outcome, cond), immutable +_TOK_LOCK = threading.Lock() + + +def _token_market(token): + """token id -> (title, outcome, conditionId) via the gamma API, or None. + A failed lookup means the caller drops the seed — the backstop poll still + copies the trade once the indexer catches up (honest, never guessy).""" + with _TOK_LOCK: + if token in _TOK_MKT: + return _TOK_MKT[token] + try: + req = urllib.request.Request( + f"https://gamma-api.polymarket.com/markets?clob_token_ids={token}", + headers={"User-Agent": "Mozilla/5.0"}) + with urllib.request.urlopen(req, timeout=12, context=SSL_CTX) as r: + m = (json.loads(r.read().decode()) or [{}])[0] + toks = json.loads(m["clobTokenIds"]) + got = (m.get("question") or "", + json.loads(m["outcomes"])[toks.index(token)], + m.get("conditionId")) + except Exception: + return None + with _TOK_LOCK: + _TOK_MKT[token] = got + return got + + +def fills_from_tx(tx, wallet): + """The watched wallet's fills decoded from the receipt, as data-api-shaped + trade dicts ([] without an RPC / on any failure — backstops still cover). + OrderFilled topics: (sig, orderHash, maker, taker); the wallet's own order + is the maker==wallet row (aggregate when it took, per-order when it made). + Data words: (assetClassGiven, tokenId, amountGiven, amountTaken, fee…) in + 6dp base units; class 0 = gave cash = BUY, else gave tokens = SELL. + Validated 200/200 against shadow-ledger fills — side, size, price exact.""" + if not _RPC_URL: + return [] + try: + rcpt = _rpc("eth_getTransactionReceipt", [tx]) + except Exception: + return [] + if not rcpt: + return [] + try: # block ts keeps the staleness gate + lag stat honest + ts = int(_rpc("eth_getBlockByNumber", + [rcpt["blockNumber"], False])["timestamp"], 16) + except Exception: + ts = int(time.time()) + w40, out = wallet[2:].lower(), [] + for lg in rcpt.get("logs", []): + t = lg.get("topics") or [] + if (len(t) < 4 or t[0].lower() != _ORDER_FILLED + or (lg.get("address") or "").lower() not in _EXCHANGES + or t[2][-40:].lower() != w40): + continue + d = lg.get("data", "0x")[2:] + wd = [int(d[i:i + 64], 16) for i in range(0, len(d), 64)] + if len(wd) < 4: + continue + side = "BUY" if wd[0] == 0 else "SELL" + token, given, taken = str(wd[1]), wd[2] / 1e6, wd[3] / 1e6 + usd, size = (given, taken) if side == "BUY" else (taken, given) + if not size: + continue + meta = _token_market(token) + if not meta: + log(f"chainseed: no metadata for token …{token[-8:]} " + f"({tx[:10]}…) — left to the backstop poll") + continue + out.append({"transactionHash": tx, "asset": token, "side": side, + "size": size, "price": round(usd / size, 6), + "usdcSize": round(usd, 2), "title": meta[0], + "outcome": meta[1], "conditionId": meta[2], + "timestamp": ts}) + return out + + # follow-filter defaults — merged under cfg["follow"]; permissive so nothing is # silently dropped until you opt in. The engine's risk caps bound everything # regardless of these. @@ -1643,16 +1741,29 @@ class Copybot: cur[wallet.lower()] = max(since, newest) return [t for t in rows if (t.get("timestamp") or 0) > since - 600] - def on_wallet_activity(self, wallet, ignore_stale=False, seed_trade=None): + def on_wallet_activity(self, wallet, ignore_stale=False, seed_trade=None, + hint_txs=None): """A watched wallet just transacted — pull its latest trades and route any new, recent one through the filter and (if it passes) the engine. `seed_trade` (from the RTDS push) is merged into the fetched set so a - lagging data-api indexer can't delay the copy — deduped by tx.""" + lagging data-api indexer can't delay the copy — deduped by tx. + `hint_txs` (from the Alchemy push) are receipt hashes: any hash the + fetch didn't return is decoded straight from the chain (fills_from_tx), + so an RTDS-silent market still copies at push speed.""" name = self.names.get(wallet.lower(), wallet[:10] + "…") trades = self._fetch_since_cursor(wallet) if seed_trade and seed_trade.get("transactionHash") and seed_trade.get("asset"): if seed_trade["transactionHash"] not in {t.get("transactionHash") for t in trades}: trades = trades + [seed_trade] + have = {t.get("transactionHash") for t in trades} + for tx in (hint_txs or []): + if tx in have or tx in self.engine.seen or tx in self.skipped: + continue + have.add(tx) + for f in fills_from_tx(tx, wallet): + log(f"chainseed: {name} {f['side']} {f['size']:g} @ " + f"{f['price']:.3f} · {str(f['title'])[:40]}") + trades = trades + [f] # ONE conviction bet often arrives as several fills — a sweep through # the book or rapid clip entries (gkmg 2026-07-09: a $612 MOUZ entry = # 3×$204 same-second rows, every clip sub-floor while the backtest's @@ -2065,13 +2176,18 @@ def verify(raw, sig, signing_key): def addresses_in_payload(payload, watched): - out = set() + """-> {watched wallet: sorted tx hashes}. The hashes ride along so a + trade the data-api can't return yet is decoded from its receipt instead + of waiting on the indexer (fills_from_tx).""" + out = {} for a in payload.get("event", {}).get("activity", []): for k in ("fromAddress", "toAddress"): - v = a.get(k) - if v and v.lower() in watched: - out.add(v.lower()) - return out + v = (a.get(k) or "").lower() + if v in watched: + txs = out.setdefault(v, set()) + if a.get("hash"): + txs.add(a["hash"]) + return {w: sorted(txs) for w, txs in out.items()} def make_handler(bot, signing_key): @@ -2096,8 +2212,8 @@ def make_handler(bot, signing_key): try: bot.settle_resolved() # recycle any newly-resolved positions payload = json.loads(raw or b"{}") - for w in addresses_in_payload(payload, watched): - bot.on_wallet_activity(w) + for w, txs in addresses_in_payload(payload, watched).items(): + bot.on_wallet_activity(w, hint_txs=txs) bot.write_feed() # refresh + publish the dashboard feed bot.publish_feed() except Exception as e: