diff --git a/fly.Dockerfile b/fly.Dockerfile new file mode 100644 index 00000000..c73b72d1 --- /dev/null +++ b/fly.Dockerfile @@ -0,0 +1,10 @@ +# Fly.io image for the 24/7 copybot worker (host/start.sh clones the repo +# fresh at boot and persists state via GitHub commits — see host/start.sh). +# Named fly.Dockerfile so Railway's pinned NIXPACKS builder ignores it. +FROM python:3.11-slim +RUN apt-get update \ + && apt-get install -y --no-install-recommends git ca-certificates \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY host/ /app/host/ +CMD ["bash", "host/start.sh"] diff --git a/fly.toml b/fly.toml new file mode 100644 index 00000000..4eb45419 --- /dev/null +++ b/fly.toml @@ -0,0 +1,25 @@ +# Fly.io config for the copybot worker — region must be a country Polymarket +# does NOT restrict (https://docs.polymarket.com/developers/CLOB/geoblock). +# Surprises on the restricted list: US, UK, France, Germany, Italy, NL, +# Poland, Singapore, Australia, Ontario (yyz), and BRAZIL (no gru). Of Fly's +# 2026 region set that leaves arn (Stockholm), jnb (Johannesburg), bom +# (Mumbai). arn wins: allowed + ~25ms from the CLOB primaries (eu-west-2 +# London), so copy lag improves vs any LatAm box. +app = "wwf-copybot" +primary_region = "arn" + +[build] + dockerfile = "fly.Dockerfile" + +# public HTTPS endpoint for the Alchemy push webhook (POST /alchemy); +# the bot binds $PORT or 8080. An always-on worker: never auto-stop. +[http_service] + internal_port = 8080 + force_https = true + auto_stop_machines = "off" + auto_start_machines = true + min_machines_running = 1 + +[[vm]] + size = "shared-cpu-1x" + memory = "512mb" diff --git a/host/geocheck.py b/host/geocheck.py new file mode 100644 index 00000000..8a1a9ea3 --- /dev/null +++ b/host/geocheck.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Can this box place Polymarket orders? (IP geo-gate probe, no keys needed) + +Polymarket geoblocks TRADING by IP (reads are open everywhere): an order POST +from a restricted country gets 403 "Trading restricted in your region" before +auth is even checked, so an unauthenticated dummy order cleanly separates +geo-blocked (403 geo message) from allowed (400/401 auth/validation error). +Restricted list is long and includes surprises — US, UK, France, Germany, +Brazil (so no Fly gru!), Netherlands, Singapore, Australia: +https://docs.polymarket.com/developers/CLOB/geoblock + +Probes, in order: + 1. ipinfo.io — where does the internet think this box is + 2. polymarket geoblock — Polymarket's own verdict for this IP + 3. clob POST /order — the gate that actually matters + +Usage: + python3 host/geocheck.py # print verdict, exit 0 = tradable + python3 host/geocheck.py --idle # then sleep forever (test deploys: + # keeps the machine up for fly ssh) +""" +import json +import ssl +import sys +import time +import urllib.request + +CLOB = "https://clob.polymarket.com" +# macOS framework Pythons often ship without CA certs (same workaround as the +# rest of the repo); the verdict is informational, so unverified is acceptable +_CTX = [None, ssl._create_unverified_context()] + + +def get(url, method="GET", data=None): + req = urllib.request.Request(url, method=method, data=data, + headers={"User-Agent": "Mozilla/5.0", + "Content-Type": "application/json"}) + err = None + for ctx in _CTX: + try: + r = urllib.request.urlopen(req, timeout=20, context=ctx) + return r.status, r.read().decode(errors="replace")[:300] + except urllib.error.HTTPError as e: + return e.code, e.read().decode(errors="replace")[:300] + except Exception as e: + err = e + return None, str(err) + + +def main(): + ok = True + + st, body = get("https://ipinfo.io/json") + import re + loc = dict(re.findall(r'"(country|city|org)":\s*"([^"]*)"', body or "")) + if loc: + print(f"[geo] ip location : {loc.get('country')} {loc.get('city')} ({loc.get('org')})") + else: + print(f"[geo] ip location : lookup failed ({st} {(body or '')[:80]})") + + st, body = get("https://polymarket.com/api/geoblock") + print(f"[geo] pm geoblock : {st} {body[:160]}") + + st, body = get(f"{CLOB}/order", method="POST", data=b"{}") + geo_blocked = (st == 403 and "restricted" in body.lower()) + print(f"[geo] clob order : {st} {body[:160]}") + + if geo_blocked: + ok = False + print("[geo] VERDICT: BLOCKED — this box cannot place Polymarket orders") + elif st is None: + ok = False + print("[geo] VERDICT: UNKNOWN — clob unreachable from this box") + else: + print("[geo] VERDICT: TRADABLE — order endpoint reachable past the geo-gate") + + if "--idle" in sys.argv: + print("[geo] --idle: sleeping so the machine stays up for inspection…", flush=True) + while True: + time.sleep(3600) + sys.exit(0 if ok else 1) + + +if __name__ == "__main__": + main() diff --git a/host/start.sh b/host/start.sh index 3d17210f..4c620920 100755 --- a/host/start.sh +++ b/host/start.sh @@ -28,6 +28,17 @@ # launchctl unload ~/Library/LaunchAgents/com.jaxperro.copybot.plist set -euo pipefail +# geo-gate probe first (host/geocheck.py, no keys needed). GEOCHECK_ONLY=1 -> +# probe and idle WITHOUT starting the bot: lets a new host/region prove it +# passes Polymarket's IP geoblock while the old deployment is still the book's +# single writer. Otherwise the probe is informational — the PAPER bot only +# reads, so a blocked region only matters once real orders are on the table. +if [ -n "${GEOCHECK_ONLY:-}" ]; then + exec python3 "$(dirname "$0")/geocheck.py" --idle +fi +python3 "$(dirname "$0")/geocheck.py" \ + || echo "⚠ geo-gate BLOCKED/unknown here — fine for paper, do NOT go live from this box" + : "${GITHUB_TOKEN:?set GITHUB_TOKEN (PAT with repo contents read+write)}" REPO_URL="https://x-access-token:${GITHUB_TOKEN}@github.com/jaxperro/winning-wallet-finder.git" DIR="${COPYBOT_DIR:-/tmp/wwf}" diff --git a/live/deploy_bot.sh b/live/deploy_bot.sh index 1086f86e..98eef639 100755 --- a/live/deploy_bot.sh +++ b/live/deploy_bot.sh @@ -8,8 +8,10 @@ # 2. ./live/deploy_bot.sh # # Validates the config (a malformed JSON = crash-looping container), previews -# the follow set, commits+pushes just the config, redeploys the Railway worker, -# and waits for the fresh boot banner. +# the follow set, commits+pushes just the config, restarts the Fly.io worker +# (wwf-copybot, Stockholm — start.sh clones main at boot, so a restart IS the +# deploy for config changes; `flyctl deploy` only needed when host/ or +# fly.toml change), and waits for the fresh boot banner. set -e cd "$(dirname "$0")" @@ -52,19 +54,30 @@ fi # push mode: keep the Alchemy webhook's address list matched to the follow set python3 sync_webhook.py || echo "⚠ webhook sync failed — update the address list manually" -railway redeploy --service copybot --yes -echo "redeploy triggered — waiting for the new container…" -for i in $(seq 1 15); do - sleep 20 - tail5=$(railway logs --service copybot 2>/dev/null | tail -5) - if echo "$tail5" | grep -qE "\[[123]\] "; then - railway logs --service copybot 2>/dev/null | grep -E "watching|floor\[" | tail -12 - echo "$tail5" | tail -1 - echo "✅ bot rebooted with the new follow set" - echo " (Alchemy address list synced above — if it warned, update manually" - echo " at dashboard.alchemy.com → Webhooks → copybot follow set)" - exit 0 - fi -done -echo "⚠ no fresh boot banner within 5min — check: railway logs --service copybot" -exit 1 +flyctl apps restart wwf-copybot +echo "restart triggered — waiting for the new container…" +python3 - <<'PY' +# wait for a boot banner logged AFTER the restart (fly log lines are ANSI- +# colored and ISO-stamped; strip + parse here rather than in bash) +import re, subprocess, sys, time +t0 = time.time() - 30 # restart just happened +for _ in range(15): + time.sleep(20) + raw = subprocess.run(["flyctl", "logs", "--app", "wwf-copybot", "--no-tail"], + capture_output=True, text=True).stdout + lines = [re.sub(r"\x1b\[[0-9;]*m", "", l) for l in raw.splitlines()] + for l in reversed(lines): + if "watching" in l and "wallets" in l: + m = re.match(r"(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})Z", l) + ts = time.mktime(time.strptime(m.group(1), "%Y-%m-%dT%H:%M:%S")) - time.timezone if m else 0 + if ts >= t0: + print("\n".join(x for x in lines[-40:] + if re.search(r"watching|floor\[|push mode|VERDICT", x))) + print("✅ bot rebooted with the new follow set") + print(" (Alchemy address list synced above — if it warned, update" + " manually at dashboard.alchemy.com → Webhooks)") + sys.exit(0) + break +print("⚠ no fresh boot banner within 5min — check: flyctl logs --app wwf-copybot") +sys.exit(1) +PY