From 3784b288cd9c61033914df886702274c0e90121c Mon Sep 17 00:00:00 2001 From: jaxperro Date: Fri, 10 Jul 2026 18:43:22 -0400 Subject: [PATCH] hardening trio: exit-retry (1.6), RTDS shadow ledger, boot clone-guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - failed mirror-exits queue for up to 10 heartbeat retries (recovered exits ping Discord; exhaustion pages ⚠ EXIT STUCK and the position rides knowingly; in-play holds hand to the pending registry). 3 stub paths pass incl. the 1.6 spec's fail-fail-fill. - every RTDS Set E detection appends a durable shadow-ledger row (lat_s + which trigger won) that rides the publish commit — the 24h go/no-go data for flipping RTDS on the live app. - start.sh verifies the boot clone's HEAD against the GitHub API and re-clones stale replicas (bit twice today). Co-Authored-By: Claude Fable 5 --- HANDOFF.md | 22 ++++++++-- config.live.example.json | 3 +- copybot.py | 88 +++++++++++++++++++++++++++++++++++++++- copytrade.py | 17 +++++++- host/start.sh | 30 ++++++++++++++ 5 files changed, 154 insertions(+), 6 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index 0b017943..3951453c 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -186,12 +186,28 @@ that; the 5-min backstop poll covers detection meanwhile). ## User to-dos (security/hygiene) -- Revoke the polymarket.us API key `f03d9eb5…` (leaked in a screenshot) if - not already done. -- Rotate the Discord webhook committed pre-2026-07-09 (spam risk only). +- ~~Revoke the polymarket.us API key~~ DONE (user, 2026-07-10). +- ~~Decide the leftover $32 on polymarket.us~~ RESOLVED (user moved it, + 2026-07-10). +- Rotate the Discord webhook committed pre-2026-07-09 (spam risk only) — + STILL OPEN. - Optional: clear the 9 inert builder keys at polymarket.com/settings?tab=builder. +## Hardening trio (2026-07-10 late — all stub-tested) + +- **Exit-retry (LIVE_ROLLOUT 1.6 built)**: failed mirror-exits queue in + `state.exit_retries`; the heartbeat re-attempts up to 10 ticks (recovered + exits ping Discord), then pages **⚠ EXIT STUCK** and lets the position + ride knowingly. In-play holds hand off to the pending registry. +- **RTDS shadow ledger**: every Set E detection appends to + `rtds_shadow.jsonl` (live: `rtds_shadow.live.jsonl`) — ts, lat_s, and + whether another trigger saw the tx first. Rides the publish commit; + capped ~2000 lines. This file is the 24h go/no-go data for RTDS_DETECT=1. +- **Boot stale-clone guard (start.sh)**: clone HEAD verified against the + GitHub API (re-clone up to 4×) — kills the stale-replica boots that hit + twice on 2026-07-10. + ## Operational quick-reference - Follow-set change: edit live/copybot.paper.json → `./live/deploy_bot.sh`; diff --git a/config.live.example.json b/config.live.example.json index ab88557d..c3cddaaa 100644 --- a/config.live.example.json +++ b/config.live.example.json @@ -89,5 +89,6 @@ } ], "feed_path": "live/copybot_live_real.json", - "fill_log": "copybot_fills.live.jsonl" + "fill_log": "copybot_fills.live.jsonl", + "shadow_log": "rtds_shadow.live.jsonl" } \ No newline at end of file diff --git a/copybot.py b/copybot.py index 06ed51b0..d1c60946 100644 --- a/copybot.py +++ b/copybot.py @@ -561,6 +561,25 @@ class RtdsListener: log(f"rtds: {name} {p.get('side')} {p.get('size')} @ {p.get('price')}" f" · {str(p.get('title'))[:40]}" f"{f' · lat {lat:.1f}s' if lat is not None else ''}") + # shadow ledger — the durable record the RTDS-vs-Alchemy comparison + # reads (Fly logs are ephemeral; this file rides the publish commit). + # `seen` = another trigger already processed this tx before RTDS won. + try: + tx = p.get("transactionHash") + with open(os.path.join(self.bot.here, self.bot.shadow_log), "a") as fh: + fh.write(json.dumps({ + "ts": round(time.time(), 2), "trade_ts": round(ts, 2), + "lat_s": round(lat, 2) if lat is not None else None, + "wallet": w, "name": name, "side": p.get("side"), + "size": p.get("size"), "price": p.get("price"), + "title": str(p.get("title") or "")[:60], "tx": tx, + "seen": bool(tx and tx in self.bot.engine.seen)}) + "\n") + if self.hits % 500 == 0: # keep the committed file bounded + path = os.path.join(self.bot.here, self.bot.shadow_log) + lines = open(path).readlines()[-2000:] + open(path, "w").writelines(lines) + except Exception: + pass try: # same funnel as the Alchemy push — self.bot.on_wallet_activity(w) # locks internally, never raises out except Exception as e: @@ -586,6 +605,7 @@ class Copybot: self.here = os.path.dirname(os.path.abspath(__file__)) self.feed_path = cfg.get("feed_path", FEED) self.fill_log = cfg.get("fill_log", FILL_LOG) + self.shadow_log = cfg.get("shadow_log", "rtds_shadow.jsonl") # persisted across restarts via the engine's state file self.conds = engine.state.setdefault("conds", {}) # token_id -> conditionId (open positions) engine.state.setdefault("cash", cfg["bankroll_usd"]) # free cash (recycles on sell/resolution) @@ -1070,7 +1090,8 @@ class Copybot: capture_output=True).returncode != 0 if unchanged and not untracked: return - paths = [p for p in (self.feed_path, self.engine.state_path, self.fill_log) + paths = [p for p in (self.feed_path, self.engine.state_path, + self.fill_log, self.shadow_log) if os.path.exists(os.path.join(repo, p))] subprocess.run(["git", "-C", repo, "add", "-f"] + paths, capture_output=True) if subprocess.run(["git", "-C", repo, "diff", "--cached", "--quiet"], @@ -1242,6 +1263,69 @@ class Copybot: st["pending_orders"] = keep self.engine.persist() + MAX_EXIT_RETRIES = 10 + + def retry_stuck_exits(self): + """LIVE_ROLLOUT 1.6: re-attempt failed mirror-exits each heartbeat. + A thin/no-bid book at copy time must not silently turn a scalp into + a hold-to-resolution — retry up to MAX_EXIT_RETRIES ticks, then page + Discord (⚠ EXIT STUCK) and let the position ride knowingly.""" + st = self.engine.state + retries = st.get("exit_retries") or [] + if not retries: + return + keep = [] + for r in retries: + tok = r["token"] + mine = st["my_pos"].get(tok) + if not mine or mine.get("shares", 0) <= 0.01: + continue # settled/sold meanwhile — done + shares = min(r["shares"], mine["shares"]) + price = self.engine._live_price(tok, "sell") + if price is None: + res = {"ok": False, "resp": "no bid side"} + else: + res = self.engine.ex.sell(tok, shares, price, {}) + if res["ok"]: + frac = min(1.0, res["filled_shares"] / mine["shares"]) \ + if mine["shares"] else 1.0 + mine["cost"] *= (1 - frac) + mine["shares"] -= res["filled_shares"] + if mine["shares"] <= 0.01: + st["my_pos"].pop(tok, None) + self._drain_fills() + self.engine.alert( + f"EXIT RECOVERED · {r['label'][:46]} — sold " + f"{res['filled_shares']:.2f} @ {res['price']:.3f} " + f"(attempt {r['attempts'] + 1})", + discord_text=(f"🔴 **EXIT recovered** [LIVE]\n{r['label'][:60]}\n" + f"sold {res['filled_shares']:.2f} @ " + f"{res['price']:.3f} on retry " + f"{r['attempts'] + 1}")) + continue + if res.get("pending"): # in-play hold — registry owns it now + st.setdefault("pending_orders", []).append({ + **res["pending"], "token": tok, "side": "SELL", + "wallet": mine.get("wallet", ""), "title": mine.get("title", ""), + "outcome": mine.get("outcome", ""), "event": mine.get("event"), + "cond": mine.get("cond"), "their_price": price, + "their_ts": None, "price": price, "is_add": False, + "stake": shares * price, "ts": time.time(), "ttl_s": 600}) + continue + r["attempts"] += 1 + if r["attempts"] >= self.MAX_EXIT_RETRIES: + self.engine.alert( + f"⚠ EXIT STUCK · {r['label'][:46]} — {r['attempts']} retries " + "failed; position rides to resolution", + discord_text=(f"🚨 **EXIT STUCK** [LIVE]\n{r['label'][:60]}\n" + f"{r['attempts']} sell retries failed " + f"(last: {str(res.get('resp'))[:60]}) — " + "position will ride to resolution")) + continue + keep.append(r) + st["exit_retries"] = keep + self.engine.persist() + _chain_bal = (0.0, None) # (checked_at, usdc) — cached; poll ≤1/min def chain_cash_gap(self): @@ -2034,6 +2118,7 @@ def main(): try: while True: bot.resolve_pendings() # adopt/expire in-play held orders + bot.retry_stuck_exits() # LIVE_ROLLOUT 1.6 bot.settle_resolved() # recycle capital at resolution if cycle % 5 == 0: bot.reconcile_exits() @@ -2071,6 +2156,7 @@ def main(): cycle += 1 try: bot.resolve_pendings() # adopt/expire in-play held orders + bot.retry_stuck_exits() # LIVE_ROLLOUT 1.6 bot.settle_resolved() if cycle % 5 == 0: bot.reconcile_exits() diff --git a/copytrade.py b/copytrade.py index 5501bc9a..7f4c26f5 100644 --- a/copytrade.py +++ b/copytrade.py @@ -566,7 +566,22 @@ class CopyTrader: self.log(f"EXIT {label} — PENDING (in-play hold)") self.persist() return - self.log(f"EXIT {label} — ORDER FAILED: {res.get('resp')}") + # LIVE_ROLLOUT 1.6 (built 2026-07-10): a failed mirror-exit must + # not silently ride to resolution — queue a bounded retry; the + # heartbeat re-attempts each tick and alerts ⚠ EXIT STUCK when + # exhausted. One entry per token; a repeat failure refreshes it. + retries = self.state.setdefault("exit_retries", []) + for r in retries: + if r["token"] == token: + r["shares"] = max(r["shares"], sell_shares) + break + else: + retries.append({"token": token, "shares": sell_shares, + "label": label, "attempts": 0, + "ts": int(time.time())}) + self.log(f"EXIT {label} — ORDER FAILED: {str(res.get('resp'))[:80]}" + " · queued for retry") + self.persist() return proceeds = res["filled_shares"] * res["price"] # reduce position; release cost proportionally diff --git a/host/start.sh b/host/start.sh index 8a06401b..2a204123 100755 --- a/host/start.sh +++ b/host/start.sh @@ -61,6 +61,36 @@ DIR="${COPYBOT_DIR:-/tmp/wwf}" rm -rf "$DIR" git clone --depth 20 "$REPO_URL" "$DIR" cd "$DIR" + +# stale-clone guard (2026-07-10): a boot seconds after a push can clone a +# stale GitHub replica (gotcha 15) — it bit twice today (a 4h run on +# pre-push config). Verify the clone's HEAD against the API's view of main +# and re-clone until they agree (bounded; proceeds with a warning if the +# API is unreachable — never blocks the boot forever). +for try in 1 2 3 4; do + LOCAL_SHA=$(git rev-parse HEAD) + REMOTE_SHA=$(curl -sf --max-time 10 \ + -H "Authorization: token ${GITHUB_TOKEN}" \ + -H "Accept: application/vnd.github.sha" \ + https://api.github.com/repos/jaxperro/winning-wallet-finder/commits/main \ + || true) + if [ -z "$REMOTE_SHA" ]; then + echo "[clone-guard] GitHub API unreachable — proceeding with $LOCAL_SHA" + break + fi + if [ "$LOCAL_SHA" = "$REMOTE_SHA" ]; then + echo "[clone-guard] clone verified @ ${LOCAL_SHA:0:10}" + break + fi + echo "[clone-guard] STALE clone (${LOCAL_SHA:0:10} != ${REMOTE_SHA:0:10}) — re-cloning (try $try)" + cd / + rm -rf "$DIR" + sleep 5 + git clone --depth 20 "$REPO_URL" "$DIR" + cd "$DIR" + [ "$try" = "4" ] && echo "[clone-guard] ⚠ still stale after 4 tries — proceeding; verify the first heartbeat" +done + git config user.name "copybot[bot]" git config user.email "copybot@users.noreply.github.com"