copybot: migrate Railway (US region, geo-blocked) -> Fly.io arn Stockholm
Polymarket geoblocks order placement by IP and Railway ran the worker in a US region — fine for the paper bot (reads only) but a dead end for going live. Fly arn (Sweden, unrestricted, ~25ms from the CLOB's eu-west-2 primaries) passes: geocheck VERDICT TRADABLE, pm geoblock blocked:false. - host/geocheck.py: 3-probe geo-gate verdict (ipinfo, pm /api/geoblock, unauth CLOB order POST); --idle keeps a test machine up - host/start.sh: geocheck at boot (GEOCHECK_ONLY=1 = probe-and-idle test mode, no second book-writer); warns loudly if a region is blocked - fly.toml/fly.Dockerfile: wwf-copybot, single machine, http_service :8080 for the Alchemy push webhook (now wh_blf4qjjvfdbqs9mc -> fly.dev domain) - deploy_bot.sh: railway redeploy -> flyctl apps restart + banner wait Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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}"
|
||||
|
||||
+31
-18
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user