release 4 (75796a8c89)
Browse files- CHANGELOG.md +5 -0
- fly_trader/chain/rpc.py +6 -0
- fly_trader/kalshi/client.py +3 -0
- fly_trader/netguard.py +35 -0
- fly_trader/rh/rpc.py +3 -0
CHANGELOG.md
CHANGED
|
@@ -35,6 +35,11 @@ The Streamlit console is replaced by a web dashboard served by the same single p
|
|
| 35 |
- **Fixed:** `tools/publish_release.py` read the current release over TLS without root certificates (python.org's macOS
|
| 36 |
Python) and counted the failure as "no release", so it would have published release 1 again. It now uses certifi and
|
| 37 |
stops on anything but a 404.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
- **Fixed:** a SIGTERM killed the worker threads mid-step: uvicorn re-raises the signal once the server stops. The
|
| 39 |
console now stops them in the app's shutdown and waits for them to flush.
|
| 40 |
|
|
|
|
| 35 |
- **Fixed:** `tools/publish_release.py` read the current release over TLS without root certificates (python.org's macOS
|
| 36 |
Python) and counted the failure as "no release", so it would have published release 1 again. It now uses certifi and
|
| 37 |
stops on anything but a 404.
|
| 38 |
+
- **Fixed, after it burned 0.06 ETH:** a test run could spend real money. While `.env` had `RH_LIVE_ENABLED=1` and the
|
| 39 |
+
real RH key, a vault test that expected signing to be off paid two test claims of 0.03 ETH from the live RH wallet to its
|
| 40 |
+
placeholder address `0x1111…1111` (transactions `0x063cbd4f…` and `0x5fd55d3d…`). Now `fly_trader/netguard.py` refuses
|
| 41 |
+
every Solana or EVM broadcast and every Kalshi order, cancel or transfer from a test run unless it goes to a local test
|
| 42 |
+
chain. Tests never load the operator's signing keys or live switches.
|
| 43 |
- **Fixed:** a SIGTERM killed the worker threads mid-step: uvicorn re-raises the signal once the server stops. The
|
| 44 |
console now stops them in the app's shutdown and waits for them to flush.
|
| 45 |
|
fly_trader/chain/rpc.py
CHANGED
|
@@ -137,11 +137,17 @@ class HttpSolanaRpc:
|
|
| 137 |
|
| 138 |
# ---- generic ----
|
| 139 |
def call(self, method: str, params: list | None = None):
|
|
|
|
|
|
|
|
|
|
| 140 |
payload = {"jsonrpc": "2.0", "id": self._next_id(), "method": method, "params": params or []}
|
| 141 |
return self._post(payload, method, _request_summary(method, params))
|
| 142 |
|
| 143 |
def batch(self, calls: list[tuple[str, list | None]]) -> list:
|
| 144 |
"""Send several calls in one HTTP request; results in call order. Raises on the first error."""
|
|
|
|
|
|
|
|
|
|
| 145 |
if not calls:
|
| 146 |
return []
|
| 147 |
payload = [{"jsonrpc": "2.0", "id": self._next_id(), "method": m, "params": p or []} for m, p in calls]
|
|
|
|
| 137 |
|
| 138 |
# ---- generic ----
|
| 139 |
def call(self, method: str, params: list | None = None):
|
| 140 |
+
if method == "sendTransaction":
|
| 141 |
+
from ..netguard import refuse_broadcast
|
| 142 |
+
refuse_broadcast(self._url, "a Solana transaction") # never from a test run (fly_trader/netguard.py)
|
| 143 |
payload = {"jsonrpc": "2.0", "id": self._next_id(), "method": method, "params": params or []}
|
| 144 |
return self._post(payload, method, _request_summary(method, params))
|
| 145 |
|
| 146 |
def batch(self, calls: list[tuple[str, list | None]]) -> list:
|
| 147 |
"""Send several calls in one HTTP request; results in call order. Raises on the first error."""
|
| 148 |
+
if any(m == "sendTransaction" for m, _ in calls):
|
| 149 |
+
from ..netguard import refuse_broadcast
|
| 150 |
+
refuse_broadcast(self._url, "a Solana transaction") # never from a test run (fly_trader/netguard.py)
|
| 151 |
if not calls:
|
| 152 |
return []
|
| 153 |
payload = [{"jsonrpc": "2.0", "id": self._next_id(), "method": m, "params": p or []} for m, p in calls]
|
fly_trader/kalshi/client.py
CHANGED
|
@@ -122,6 +122,9 @@ class KalshiRest:
|
|
| 122 |
|
| 123 |
def request(self, method: str, path: str, params: dict | None = None, body: dict | None = None, timeout: float = 60.0) -> dict:
|
| 124 |
"""One paced, logged call. Raises ``KalshiApiError`` on HTTP >= 400 (429 is retried with backoff first)."""
|
|
|
|
|
|
|
|
|
|
| 125 |
client = self._ensure()
|
| 126 |
q = {k: v for k, v in (params or {}).items() if v is not None}
|
| 127 |
url = self.base + path + (("?" + urlencode(q)) if q else "")
|
|
|
|
| 122 |
|
| 123 |
def request(self, method: str, path: str, params: dict | None = None, body: dict | None = None, timeout: float = 60.0) -> dict:
|
| 124 |
"""One paced, logged call. Raises ``KalshiApiError`` on HTTP >= 400 (429 is retried with backoff first)."""
|
| 125 |
+
if method.upper() != "GET":
|
| 126 |
+
from ..netguard import refuse_broadcast
|
| 127 |
+
refuse_broadcast(self.base, f"Kalshi {method} {path}") # never from a test run (fly_trader/netguard.py)
|
| 128 |
client = self._ensure()
|
| 129 |
q = {k: v for k, v in (params or {}).items() if v is not None}
|
| 130 |
url = self.base + path + (("?" + urlencode(q)) if q else "")
|
fly_trader/netguard.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""No money moves from a test run: every place that broadcasts a transaction or changes a live account refuses while
|
| 2 |
+
pytest runs, unless it talks to a local test chain (anvil, a fake on 127.0.0.1).
|
| 3 |
+
|
| 4 |
+
Why it exists: on 2026-10-17 the test suite ran while the operator's .env had RH_LIVE_ENABLED=1 and the real RH bot key.
|
| 5 |
+
A vault test expected Robinhood Chain signing to be off; it was on, so the claim processor built the real wallet
|
| 6 |
+
(RhWallet(RhRpc())) and paid two test claims of 0.03 ETH to the test's placeholder address 0x1111…1111 on mainnet —
|
| 7 |
+
0.06 ETH burned. The database already refuses production under pytest (db/connection.py); this does the same for chains
|
| 8 |
+
and exchanges, at the lowest layer, so no configuration or code path above it can get around it.
|
| 9 |
+
|
| 10 |
+
A test run is recognised by PYTEST_CURRENT_TEST (set by pytest for every test's setup, call and teardown) or
|
| 11 |
+
FLY_TRADER_TESTING=1 (set by tests/conftest.py before anything is imported). Neither is ever set in production.
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import os
|
| 16 |
+
from urllib.parse import urlsplit
|
| 17 |
+
|
| 18 |
+
LOOPBACK = {"127.0.0.1", "localhost", "::1"}
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class BroadcastUnderTest(RuntimeError):
|
| 22 |
+
"""A test tried to move real money."""
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def under_test() -> bool:
|
| 26 |
+
return bool(os.environ.get("PYTEST_CURRENT_TEST")) or os.environ.get("FLY_TRADER_TESTING") == "1"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def refuse_broadcast(url: str | None, what: str) -> None:
|
| 30 |
+
"""Raise when a test is about to send ``what`` to a real network (anything but a loopback host)."""
|
| 31 |
+
if not under_test():
|
| 32 |
+
return
|
| 33 |
+
host = urlsplit(url or "").hostname or ""
|
| 34 |
+
if host not in LOOPBACK:
|
| 35 |
+
raise BroadcastUnderTest(f"refusing to send {what} to {host or 'an unknown host'} from a test run: tests never move real money")
|
fly_trader/rh/rpc.py
CHANGED
|
@@ -53,6 +53,9 @@ class RhRpc(EvmRpc):
|
|
| 53 |
def _batch(self, calls: list[tuple[str, list]]) -> list[Any]:
|
| 54 |
if not calls:
|
| 55 |
return []
|
|
|
|
|
|
|
|
|
|
| 56 |
with self._lock:
|
| 57 |
base = self._id + 1; self._id += len(calls)
|
| 58 |
body = [{"jsonrpc": "2.0", "id": base + i, "method": m, "params": p} for i, (m, p) in enumerate(calls)]
|
|
|
|
| 53 |
def _batch(self, calls: list[tuple[str, list]]) -> list[Any]:
|
| 54 |
if not calls:
|
| 55 |
return []
|
| 56 |
+
if any(m == "eth_sendRawTransaction" for m, _ in calls):
|
| 57 |
+
from ..netguard import refuse_broadcast
|
| 58 |
+
refuse_broadcast(self.url, "a transaction") # never from a test run (fly_trader/netguard.py)
|
| 59 |
with self._lock:
|
| 60 |
base = self._id + 1; self._id += len(calls)
|
| 61 |
body = [{"jsonrpc": "2.0", "id": base + i, "method": m, "params": p} for i, (m, p) in enumerate(calls)]
|