polynode 0.5.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- polynode/__init__.py +41 -0
- polynode/_version.py +1 -0
- polynode/cache/__init__.py +11 -0
- polynode/client.py +635 -0
- polynode/engine.py +201 -0
- polynode/errors.py +35 -0
- polynode/orderbook.py +243 -0
- polynode/orderbook_state.py +77 -0
- polynode/redemption_watcher.py +339 -0
- polynode/short_form.py +321 -0
- polynode/subscription.py +137 -0
- polynode/testing.py +83 -0
- polynode/trading/__init__.py +19 -0
- polynode/trading/clob_api.py +158 -0
- polynode/trading/constants.py +31 -0
- polynode/trading/cosigner.py +86 -0
- polynode/trading/eip712.py +163 -0
- polynode/trading/onboarding.py +242 -0
- polynode/trading/signer.py +91 -0
- polynode/trading/sqlite_backend.py +208 -0
- polynode/trading/trader.py +506 -0
- polynode/trading/types.py +191 -0
- polynode/types/__init__.py +8 -0
- polynode/types/enums.py +51 -0
- polynode/types/events.py +270 -0
- polynode/types/orderbook.py +66 -0
- polynode/types/rest.py +376 -0
- polynode/types/short_form.py +35 -0
- polynode/types/ws.py +38 -0
- polynode/ws.py +278 -0
- polynode-0.5.5.dist-info/METADATA +133 -0
- polynode-0.5.5.dist-info/RECORD +33 -0
- polynode-0.5.5.dist-info/WHEEL +4 -0
polynode/subscription.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Subscription builder and async iterator for PolyNode WebSocket events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
7
|
+
|
|
8
|
+
from .types.enums import EventType, SubscriptionType
|
|
9
|
+
from .types.events import PolyNodeEvent
|
|
10
|
+
from .types.ws import SubscriptionFilters
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from .ws import PolyNodeWS
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SubscriptionBuilder:
|
|
17
|
+
"""Fluent builder for WebSocket subscriptions."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, ws: PolyNodeWS, type: SubscriptionType) -> None:
|
|
20
|
+
self._ws = ws
|
|
21
|
+
self._type = type
|
|
22
|
+
self._filters = SubscriptionFilters()
|
|
23
|
+
|
|
24
|
+
def wallets(self, addresses: list[str]) -> SubscriptionBuilder:
|
|
25
|
+
self._filters.wallets = addresses
|
|
26
|
+
return self
|
|
27
|
+
|
|
28
|
+
def tokens(self, token_ids: list[str]) -> SubscriptionBuilder:
|
|
29
|
+
self._filters.tokens = token_ids
|
|
30
|
+
return self
|
|
31
|
+
|
|
32
|
+
def slugs(self, slugs: list[str]) -> SubscriptionBuilder:
|
|
33
|
+
self._filters.slugs = slugs
|
|
34
|
+
return self
|
|
35
|
+
|
|
36
|
+
def condition_ids(self, ids: list[str]) -> SubscriptionBuilder:
|
|
37
|
+
self._filters.condition_ids = ids
|
|
38
|
+
return self
|
|
39
|
+
|
|
40
|
+
def side(self, side: str) -> SubscriptionBuilder:
|
|
41
|
+
self._filters.side = side
|
|
42
|
+
return self
|
|
43
|
+
|
|
44
|
+
def status(self, status: str) -> SubscriptionBuilder:
|
|
45
|
+
self._filters.status = status
|
|
46
|
+
return self
|
|
47
|
+
|
|
48
|
+
def min_size(self, usd: float) -> SubscriptionBuilder:
|
|
49
|
+
self._filters.min_size = usd
|
|
50
|
+
return self
|
|
51
|
+
|
|
52
|
+
def max_size(self, usd: float) -> SubscriptionBuilder:
|
|
53
|
+
self._filters.max_size = usd
|
|
54
|
+
return self
|
|
55
|
+
|
|
56
|
+
def event_types(self, types: list[EventType]) -> SubscriptionBuilder:
|
|
57
|
+
self._filters.event_types = types
|
|
58
|
+
return self
|
|
59
|
+
|
|
60
|
+
def snapshot_count(self, count: int) -> SubscriptionBuilder:
|
|
61
|
+
self._filters.snapshot_count = count
|
|
62
|
+
return self
|
|
63
|
+
|
|
64
|
+
def feeds(self, feed_names: list[str]) -> SubscriptionBuilder:
|
|
65
|
+
self._filters.feeds = feed_names
|
|
66
|
+
return self
|
|
67
|
+
|
|
68
|
+
async def send(self) -> Subscription:
|
|
69
|
+
return await self._ws._subscribe(self._type, self._filters)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Subscription:
|
|
73
|
+
"""An active WebSocket subscription with event handling and async iteration."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, ws: PolyNodeWS, type: SubscriptionType, filters: SubscriptionFilters) -> None:
|
|
76
|
+
self._ws = ws
|
|
77
|
+
self.type = type
|
|
78
|
+
self.filters = filters
|
|
79
|
+
self._id: str | None = None
|
|
80
|
+
self._handlers: dict[str, set[Callable]] = {}
|
|
81
|
+
self._queue: asyncio.Queue[PolyNodeEvent | None] = asyncio.Queue()
|
|
82
|
+
self._closed = False
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def id(self) -> str | None:
|
|
86
|
+
return self._id
|
|
87
|
+
|
|
88
|
+
def _set_id(self, id: str) -> None:
|
|
89
|
+
self._id = id
|
|
90
|
+
|
|
91
|
+
def on(self, type: str, handler: Callable) -> Subscription:
|
|
92
|
+
if type not in self._handlers:
|
|
93
|
+
self._handlers[type] = set()
|
|
94
|
+
self._handlers[type].add(handler)
|
|
95
|
+
return self
|
|
96
|
+
|
|
97
|
+
def off(self, type: str, handler: Callable) -> Subscription:
|
|
98
|
+
if type in self._handlers:
|
|
99
|
+
self._handlers[type].discard(handler)
|
|
100
|
+
return self
|
|
101
|
+
|
|
102
|
+
def _emit(self, event: PolyNodeEvent) -> None:
|
|
103
|
+
# Typed handlers
|
|
104
|
+
event_type = event.event_type # type: ignore[union-attr]
|
|
105
|
+
handlers = self._handlers.get(event_type)
|
|
106
|
+
if handlers:
|
|
107
|
+
for h in handlers:
|
|
108
|
+
h(event)
|
|
109
|
+
# Catch-all
|
|
110
|
+
star = self._handlers.get("*")
|
|
111
|
+
if star:
|
|
112
|
+
for h in star:
|
|
113
|
+
h(event)
|
|
114
|
+
# Async iterator queue
|
|
115
|
+
self._queue.put_nowait(event)
|
|
116
|
+
|
|
117
|
+
def unsubscribe(self) -> None:
|
|
118
|
+
self._closed = True
|
|
119
|
+
self._queue.put_nowait(None)
|
|
120
|
+
self._ws._unsubscribe(self._id)
|
|
121
|
+
|
|
122
|
+
def __aiter__(self):
|
|
123
|
+
return self
|
|
124
|
+
|
|
125
|
+
async def __anext__(self) -> PolyNodeEvent:
|
|
126
|
+
if self._closed:
|
|
127
|
+
raise StopAsyncIteration
|
|
128
|
+
event = await self._queue.get()
|
|
129
|
+
if event is None:
|
|
130
|
+
raise StopAsyncIteration
|
|
131
|
+
return event
|
|
132
|
+
|
|
133
|
+
async def __aenter__(self) -> Subscription:
|
|
134
|
+
return self
|
|
135
|
+
|
|
136
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
137
|
+
self.unsubscribe()
|
polynode/testing.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Testing utilities for SDK examples and tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
FALLBACK_WALLETS = [
|
|
10
|
+
"0xc2e7800b5af46e6093872b177b7a5e7f0563be51",
|
|
11
|
+
"0x02227b8f5a9636e895607edd3185ed6ee5598ff7",
|
|
12
|
+
"0xefbc5fec8d7b0acdc8911bdd9a98d6964308f9a2",
|
|
13
|
+
"0x2a2c53bd278c04da9962fcf96490e17f3dfb9bc1",
|
|
14
|
+
"0x37c1874a60d348903594a96703e0507c518fc53a",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def get_active_test_wallet(*, fresh: bool = False) -> str:
|
|
19
|
+
if not fresh:
|
|
20
|
+
return FALLBACK_WALLETS[0]
|
|
21
|
+
try:
|
|
22
|
+
traders = await _fetch_top_traders(1)
|
|
23
|
+
return traders[0]["wallet"] if traders else FALLBACK_WALLETS[0]
|
|
24
|
+
except Exception:
|
|
25
|
+
return FALLBACK_WALLETS[0]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def get_active_test_wallets(count: int = 5, *, fresh: bool = False) -> list[str]:
|
|
29
|
+
if not fresh:
|
|
30
|
+
return FALLBACK_WALLETS[:count]
|
|
31
|
+
try:
|
|
32
|
+
traders = await _fetch_top_traders(count)
|
|
33
|
+
if traders:
|
|
34
|
+
return [t["wallet"] for t in traders]
|
|
35
|
+
return FALLBACK_WALLETS[:count]
|
|
36
|
+
except Exception:
|
|
37
|
+
return FALLBACK_WALLETS[:count]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def _fetch_top_traders(count: int) -> list[dict]:
|
|
41
|
+
build_id = await _discover_build_id()
|
|
42
|
+
if not build_id:
|
|
43
|
+
return []
|
|
44
|
+
|
|
45
|
+
url = f"https://polymarket.com/_next/data/{build_id}/en/leaderboard/overall/monthly/profit.json?segment=overall&period=monthly&sort=profit"
|
|
46
|
+
|
|
47
|
+
async with httpx.AsyncClient(timeout=10.0) as http:
|
|
48
|
+
resp = await http.get(url, headers={"User-Agent": "polynode-sdk/testing"})
|
|
49
|
+
if not resp.is_success:
|
|
50
|
+
return []
|
|
51
|
+
data = resp.json()
|
|
52
|
+
|
|
53
|
+
queries = data.get("pageProps", {}).get("dehydratedState", {}).get("queries", [])
|
|
54
|
+
for q in queries:
|
|
55
|
+
key = q.get("queryKey", [])
|
|
56
|
+
if isinstance(key, list) and "profit" in key and "/leaderboard" in str(key):
|
|
57
|
+
records = q.get("state", {}).get("data", [])
|
|
58
|
+
if isinstance(records, list):
|
|
59
|
+
return [
|
|
60
|
+
{
|
|
61
|
+
"wallet": r.get("proxyWallet", ""),
|
|
62
|
+
"name": r.get("pseudonym") or r.get("name", ""),
|
|
63
|
+
"pnl": r.get("pnl") or r.get("amount", 0),
|
|
64
|
+
"volume": r.get("volume", 0),
|
|
65
|
+
}
|
|
66
|
+
for r in records[:count]
|
|
67
|
+
]
|
|
68
|
+
return []
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def _discover_build_id() -> str | None:
|
|
72
|
+
try:
|
|
73
|
+
async with httpx.AsyncClient(timeout=10.0) as http:
|
|
74
|
+
resp = await http.get(
|
|
75
|
+
"https://polymarket.com/",
|
|
76
|
+
headers={"User-Agent": "polynode-sdk/testing"},
|
|
77
|
+
)
|
|
78
|
+
if not resp.is_success:
|
|
79
|
+
return None
|
|
80
|
+
match = re.search(r"/_next/data/([^/]+)/", resp.text)
|
|
81
|
+
return match.group(1) if match else None
|
|
82
|
+
except Exception:
|
|
83
|
+
return None
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Trading module — place orders on Polymarket with local credential custody."""
|
|
2
|
+
|
|
3
|
+
from .types import * # noqa: F401, F403
|
|
4
|
+
from .constants import * # noqa: F401, F403
|
|
5
|
+
from .signer import NormalizedSigner, normalize_signer
|
|
6
|
+
from .cosigner import build_l2_headers, send_via_cosigner
|
|
7
|
+
from .onboarding import derive_safe_address, derive_proxy_address, detect_wallet_type
|
|
8
|
+
from .trader import PolyNodeTrader
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"PolyNodeTrader",
|
|
12
|
+
"NormalizedSigner",
|
|
13
|
+
"normalize_signer",
|
|
14
|
+
"build_l2_headers",
|
|
15
|
+
"send_via_cosigner",
|
|
16
|
+
"derive_safe_address",
|
|
17
|
+
"derive_proxy_address",
|
|
18
|
+
"detect_wallet_type",
|
|
19
|
+
]
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Direct CLOB HTTP calls for order management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .constants import CLOB_HOST
|
|
10
|
+
from .cosigner import build_l2_headers
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def post_order(
|
|
14
|
+
cosigner_url: str,
|
|
15
|
+
polynode_key: str,
|
|
16
|
+
fallback_direct: bool,
|
|
17
|
+
credentials: dict[str, str],
|
|
18
|
+
wallet_address: str,
|
|
19
|
+
order_body: str,
|
|
20
|
+
) -> dict[str, Any]:
|
|
21
|
+
"""Submit a signed order to the CLOB."""
|
|
22
|
+
from .cosigner import send_via_cosigner
|
|
23
|
+
|
|
24
|
+
headers = build_l2_headers(
|
|
25
|
+
credentials["apiKey"],
|
|
26
|
+
credentials["apiSecret"],
|
|
27
|
+
credentials["apiPassphrase"],
|
|
28
|
+
wallet_address,
|
|
29
|
+
"POST",
|
|
30
|
+
"/order",
|
|
31
|
+
order_body,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
return await send_via_cosigner(
|
|
35
|
+
cosigner_url,
|
|
36
|
+
polynode_key,
|
|
37
|
+
fallback_direct,
|
|
38
|
+
{"method": "POST", "path": "/order", "body": order_body, "headers": headers},
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def cancel_order(
|
|
43
|
+
cosigner_url: str,
|
|
44
|
+
polynode_key: str,
|
|
45
|
+
fallback_direct: bool,
|
|
46
|
+
credentials: dict[str, str],
|
|
47
|
+
wallet_address: str,
|
|
48
|
+
order_id: str,
|
|
49
|
+
) -> dict[str, Any]:
|
|
50
|
+
"""Cancel a specific order."""
|
|
51
|
+
from .cosigner import send_via_cosigner
|
|
52
|
+
import json
|
|
53
|
+
|
|
54
|
+
body = json.dumps({"orderID": order_id})
|
|
55
|
+
headers = build_l2_headers(
|
|
56
|
+
credentials["apiKey"],
|
|
57
|
+
credentials["apiSecret"],
|
|
58
|
+
credentials["apiPassphrase"],
|
|
59
|
+
wallet_address,
|
|
60
|
+
"DELETE",
|
|
61
|
+
"/order",
|
|
62
|
+
body,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
return await send_via_cosigner(
|
|
66
|
+
cosigner_url,
|
|
67
|
+
polynode_key,
|
|
68
|
+
fallback_direct,
|
|
69
|
+
{"method": "DELETE", "path": "/order", "body": body, "headers": headers},
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def cancel_all_orders(
|
|
74
|
+
cosigner_url: str,
|
|
75
|
+
polynode_key: str,
|
|
76
|
+
fallback_direct: bool,
|
|
77
|
+
credentials: dict[str, str],
|
|
78
|
+
wallet_address: str,
|
|
79
|
+
market: str | None = None,
|
|
80
|
+
) -> dict[str, Any]:
|
|
81
|
+
"""Cancel all orders, optionally for a specific market."""
|
|
82
|
+
from .cosigner import send_via_cosigner
|
|
83
|
+
import json
|
|
84
|
+
|
|
85
|
+
path = "/cancel-market-orders" if market else "/cancel-all"
|
|
86
|
+
body = json.dumps({"market": market}) if market else None
|
|
87
|
+
headers = build_l2_headers(
|
|
88
|
+
credentials["apiKey"],
|
|
89
|
+
credentials["apiSecret"],
|
|
90
|
+
credentials["apiPassphrase"],
|
|
91
|
+
wallet_address,
|
|
92
|
+
"DELETE",
|
|
93
|
+
path,
|
|
94
|
+
body,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
return await send_via_cosigner(
|
|
98
|
+
cosigner_url,
|
|
99
|
+
polynode_key,
|
|
100
|
+
fallback_direct,
|
|
101
|
+
{"method": "DELETE", "path": path, "body": body, "headers": headers},
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def get_open_orders(
|
|
106
|
+
cosigner_url: str,
|
|
107
|
+
polynode_key: str,
|
|
108
|
+
fallback_direct: bool,
|
|
109
|
+
credentials: dict[str, str],
|
|
110
|
+
wallet_address: str,
|
|
111
|
+
market: str | None = None,
|
|
112
|
+
asset_id: str | None = None,
|
|
113
|
+
) -> list[dict[str, Any]]:
|
|
114
|
+
"""Get open orders from the CLOB."""
|
|
115
|
+
from .cosigner import send_via_cosigner
|
|
116
|
+
|
|
117
|
+
path = "/data/orders"
|
|
118
|
+
params = []
|
|
119
|
+
if market:
|
|
120
|
+
params.append(f"market={market}")
|
|
121
|
+
if asset_id:
|
|
122
|
+
params.append(f"asset_id={asset_id}")
|
|
123
|
+
if params:
|
|
124
|
+
path += "?" + "&".join(params)
|
|
125
|
+
|
|
126
|
+
headers = build_l2_headers(
|
|
127
|
+
credentials["apiKey"],
|
|
128
|
+
credentials["apiSecret"],
|
|
129
|
+
credentials["apiPassphrase"],
|
|
130
|
+
wallet_address,
|
|
131
|
+
"GET",
|
|
132
|
+
path,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
result = await send_via_cosigner(
|
|
136
|
+
cosigner_url,
|
|
137
|
+
polynode_key,
|
|
138
|
+
fallback_direct,
|
|
139
|
+
{"method": "GET", "path": path, "headers": headers},
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
if isinstance(result, list):
|
|
143
|
+
return result
|
|
144
|
+
return result.get("data") or result.get("orders") or []
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
async def fetch_tick_size(token_id: str) -> str:
|
|
148
|
+
"""Fetch tick size for a token from the CLOB."""
|
|
149
|
+
async with httpx.AsyncClient(timeout=5.0) as http:
|
|
150
|
+
resp = await http.get(f"{CLOB_HOST}/tick-size?token_id={token_id}")
|
|
151
|
+
return str(resp.json())
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
async def fetch_neg_risk(token_id: str) -> bool:
|
|
155
|
+
"""Fetch neg-risk flag for a token from the CLOB."""
|
|
156
|
+
async with httpx.AsyncClient(timeout=5.0) as http:
|
|
157
|
+
resp = await http.get(f"{CLOB_HOST}/neg-risk?token_id={token_id}")
|
|
158
|
+
return bool(resp.json())
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Contract addresses and constants for Polymarket on Polygon (chain 137)."""
|
|
2
|
+
|
|
3
|
+
CHAIN_ID = 137
|
|
4
|
+
CLOB_HOST = "https://clob.polymarket.com"
|
|
5
|
+
RELAYER_HOST = "https://relayer-v2.polymarket.com"
|
|
6
|
+
DEFAULT_RPC = "https://polygon-bor-rpc.publicnode.com"
|
|
7
|
+
DEFAULT_COSIGNER = "https://trade.polynode.dev"
|
|
8
|
+
|
|
9
|
+
# Exchange contracts
|
|
10
|
+
CTF_EXCHANGE = "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E"
|
|
11
|
+
NEG_RISK_CTF_EXCHANGE = "0xC5d563A36AE78145C45a50134d48A1215220f80a"
|
|
12
|
+
NEG_RISK_ADAPTER = "0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296"
|
|
13
|
+
|
|
14
|
+
# Token contracts
|
|
15
|
+
USDC = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
|
|
16
|
+
CTF = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"
|
|
17
|
+
|
|
18
|
+
# Safe derivation
|
|
19
|
+
SAFE_FACTORY = "0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b"
|
|
20
|
+
SAFE_MULTISEND = "0xA238CBeb142c10Ef7Ad8442C6D1f9E89e07e7761"
|
|
21
|
+
SAFE_INIT_CODE_HASH = "0x2bce2127ff07fb632d16c8347c4ebf501f4841168bed00d9e6ef715ddb6fcecf"
|
|
22
|
+
|
|
23
|
+
# Proxy derivation
|
|
24
|
+
PROXY_FACTORY = "0xaB45c5A4B0c941a2F231C04C3f49182e1A254052"
|
|
25
|
+
PROXY_INIT_CODE_HASH = "0xd21df8dc65880a8606f09fe0ce3df9b8869287ab0b058be05aa9e8af6330a00b"
|
|
26
|
+
|
|
27
|
+
# All spender contracts that need approval
|
|
28
|
+
SPENDERS = [CTF_EXCHANGE, NEG_RISK_CTF_EXCHANGE, NEG_RISK_ADAPTER]
|
|
29
|
+
|
|
30
|
+
# Metadata cache TTL (5 minutes)
|
|
31
|
+
META_TTL_SECONDS = 300
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Co-signer client — routes orders through the PolyNode builder attribution proxy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import hmac
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from .constants import CLOB_HOST
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_l2_headers(
|
|
17
|
+
api_key: str,
|
|
18
|
+
api_secret: str,
|
|
19
|
+
api_passphrase: str,
|
|
20
|
+
wallet_address: str,
|
|
21
|
+
method: str,
|
|
22
|
+
path: str,
|
|
23
|
+
body: str | None = None,
|
|
24
|
+
) -> dict[str, str]:
|
|
25
|
+
"""Build L2 HMAC headers for Polymarket CLOB authentication."""
|
|
26
|
+
timestamp = str(int(time.time()))
|
|
27
|
+
message = f"{timestamp}{method}{path}"
|
|
28
|
+
if body:
|
|
29
|
+
message += body
|
|
30
|
+
|
|
31
|
+
secret_bytes = base64.b64decode(api_secret)
|
|
32
|
+
sig = base64.b64encode(
|
|
33
|
+
hmac.new(secret_bytes, message.encode(), hashlib.sha256).digest()
|
|
34
|
+
).decode()
|
|
35
|
+
sig = sig.replace("+", "-").replace("/", "_")
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
"POLY_ADDRESS": wallet_address,
|
|
39
|
+
"POLY_SIGNATURE": sig,
|
|
40
|
+
"POLY_TIMESTAMP": timestamp,
|
|
41
|
+
"POLY_API_KEY": api_key,
|
|
42
|
+
"POLY_PASSPHRASE": api_passphrase,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def send_via_cosigner(
|
|
47
|
+
cosigner_url: str,
|
|
48
|
+
polynode_key: str,
|
|
49
|
+
fallback_direct: bool,
|
|
50
|
+
request: dict[str, Any],
|
|
51
|
+
) -> Any:
|
|
52
|
+
"""Send a request through the co-signer with optional fallback to direct CLOB."""
|
|
53
|
+
if cosigner_url:
|
|
54
|
+
try:
|
|
55
|
+
async with httpx.AsyncClient(timeout=10.0) as http:
|
|
56
|
+
resp = await http.post(
|
|
57
|
+
f"{cosigner_url}/submit",
|
|
58
|
+
headers={
|
|
59
|
+
"Content-Type": "application/json",
|
|
60
|
+
"X-PolyNode-Key": polynode_key,
|
|
61
|
+
},
|
|
62
|
+
json=request,
|
|
63
|
+
)
|
|
64
|
+
data = resp.json()
|
|
65
|
+
if resp.status_code >= 500 and fallback_direct:
|
|
66
|
+
return await _send_direct(request)
|
|
67
|
+
return data
|
|
68
|
+
except Exception as e:
|
|
69
|
+
if fallback_direct:
|
|
70
|
+
return await _send_direct(request)
|
|
71
|
+
raise RuntimeError(f"Co-signer unreachable: {e}")
|
|
72
|
+
|
|
73
|
+
return await _send_direct(request)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def _send_direct(request: dict[str, Any]) -> Any:
|
|
77
|
+
"""Send directly to Polymarket CLOB (no builder attribution)."""
|
|
78
|
+
url = f"{CLOB_HOST}{request['path']}"
|
|
79
|
+
async with httpx.AsyncClient(timeout=10.0) as http:
|
|
80
|
+
resp = await http.request(
|
|
81
|
+
request["method"],
|
|
82
|
+
url,
|
|
83
|
+
headers={**request.get("headers", {}), "Content-Type": "application/json"},
|
|
84
|
+
content=request.get("body"),
|
|
85
|
+
)
|
|
86
|
+
return resp.json()
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Pure Python EIP-712 order signing for Polymarket exchange contracts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .constants import CTF_EXCHANGE, NEG_RISK_CTF_EXCHANGE
|
|
9
|
+
from .types import Eip712Payload, SignatureType
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# EIP-712 domain for Polymarket Exchange
|
|
13
|
+
EXCHANGE_DOMAIN = {
|
|
14
|
+
"name": "PolymarketExchange",
|
|
15
|
+
"version": "1",
|
|
16
|
+
"chainId": 137,
|
|
17
|
+
"verifyingContract": CTF_EXCHANGE,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
NEG_RISK_DOMAIN = {
|
|
21
|
+
"name": "PolymarketExchange",
|
|
22
|
+
"version": "1",
|
|
23
|
+
"chainId": 137,
|
|
24
|
+
"verifyingContract": NEG_RISK_CTF_EXCHANGE,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
ORDER_TYPES = {
|
|
28
|
+
"Order": [
|
|
29
|
+
{"name": "salt", "type": "uint256"},
|
|
30
|
+
{"name": "maker", "type": "address"},
|
|
31
|
+
{"name": "signer", "type": "address"},
|
|
32
|
+
{"name": "taker", "type": "address"},
|
|
33
|
+
{"name": "tokenId", "type": "uint256"},
|
|
34
|
+
{"name": "makerAmount", "type": "uint256"},
|
|
35
|
+
{"name": "takerAmount", "type": "uint256"},
|
|
36
|
+
{"name": "expiration", "type": "uint256"},
|
|
37
|
+
{"name": "nonce", "type": "uint256"},
|
|
38
|
+
{"name": "feeRateBps", "type": "uint256"},
|
|
39
|
+
{"name": "side", "type": "uint8"},
|
|
40
|
+
{"name": "signatureType", "type": "uint8"},
|
|
41
|
+
],
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def compute_amounts(
|
|
46
|
+
price: float,
|
|
47
|
+
size: float,
|
|
48
|
+
side: str,
|
|
49
|
+
tick_size: str,
|
|
50
|
+
) -> tuple[int, int]:
|
|
51
|
+
"""Compute maker_amount and taker_amount from price/size/tick_size."""
|
|
52
|
+
decimals = len(tick_size.rstrip("0").split(".")[-1]) if "." in tick_size else 0
|
|
53
|
+
raw_price = round(price * (10 ** decimals))
|
|
54
|
+
raw_size = round(size * 1_000_000) # USDC has 6 decimals
|
|
55
|
+
price_denom = 10 ** decimals
|
|
56
|
+
|
|
57
|
+
if side == "BUY":
|
|
58
|
+
maker_amount = raw_size * raw_price // price_denom
|
|
59
|
+
taker_amount = raw_size
|
|
60
|
+
else:
|
|
61
|
+
maker_amount = raw_size
|
|
62
|
+
taker_amount = raw_size * raw_price // price_denom
|
|
63
|
+
|
|
64
|
+
return maker_amount, taker_amount
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def build_order_payload(
|
|
68
|
+
*,
|
|
69
|
+
maker: str,
|
|
70
|
+
signer: str,
|
|
71
|
+
token_id: str,
|
|
72
|
+
maker_amount: int,
|
|
73
|
+
taker_amount: int,
|
|
74
|
+
side: str,
|
|
75
|
+
signature_type: SignatureType,
|
|
76
|
+
fee_rate_bps: int = 0,
|
|
77
|
+
nonce: int = 0,
|
|
78
|
+
expiration: int = 0,
|
|
79
|
+
neg_risk: bool = False,
|
|
80
|
+
) -> Eip712Payload:
|
|
81
|
+
"""Build an EIP-712 typed data payload for a Polymarket order."""
|
|
82
|
+
import secrets
|
|
83
|
+
|
|
84
|
+
salt = int(secrets.token_hex(32), 16)
|
|
85
|
+
side_int = 0 if side == "BUY" else 1
|
|
86
|
+
|
|
87
|
+
domain = NEG_RISK_DOMAIN if neg_risk else EXCHANGE_DOMAIN
|
|
88
|
+
|
|
89
|
+
message = {
|
|
90
|
+
"salt": salt,
|
|
91
|
+
"maker": maker,
|
|
92
|
+
"signer": signer,
|
|
93
|
+
"taker": "0x0000000000000000000000000000000000000000",
|
|
94
|
+
"tokenId": int(token_id),
|
|
95
|
+
"makerAmount": maker_amount,
|
|
96
|
+
"takerAmount": taker_amount,
|
|
97
|
+
"expiration": expiration,
|
|
98
|
+
"nonce": nonce,
|
|
99
|
+
"feeRateBps": fee_rate_bps,
|
|
100
|
+
"side": side_int,
|
|
101
|
+
"signatureType": int(signature_type),
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return Eip712Payload(
|
|
105
|
+
domain=domain,
|
|
106
|
+
types=ORDER_TYPES,
|
|
107
|
+
primary_type="Order",
|
|
108
|
+
message=message,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def create_signed_order(
|
|
113
|
+
sign_typed_data: Any,
|
|
114
|
+
*,
|
|
115
|
+
signer_address: str,
|
|
116
|
+
funder_address: str,
|
|
117
|
+
token_id: str,
|
|
118
|
+
price: float,
|
|
119
|
+
size: float,
|
|
120
|
+
side: str,
|
|
121
|
+
signature_type: SignatureType,
|
|
122
|
+
tick_size: str = "0.01",
|
|
123
|
+
neg_risk: bool = False,
|
|
124
|
+
fee_rate_bps: int = 0,
|
|
125
|
+
expiration: int = 0,
|
|
126
|
+
nonce: int = 0,
|
|
127
|
+
) -> dict[str, Any]:
|
|
128
|
+
"""Create and sign a Polymarket order. Returns the order dict ready for CLOB submission."""
|
|
129
|
+
maker_amount, taker_amount = compute_amounts(price, size, side, tick_size)
|
|
130
|
+
|
|
131
|
+
payload = build_order_payload(
|
|
132
|
+
maker=funder_address,
|
|
133
|
+
signer=signer_address,
|
|
134
|
+
token_id=token_id,
|
|
135
|
+
maker_amount=maker_amount,
|
|
136
|
+
taker_amount=taker_amount,
|
|
137
|
+
side=side,
|
|
138
|
+
signature_type=signature_type,
|
|
139
|
+
fee_rate_bps=fee_rate_bps,
|
|
140
|
+
nonce=nonce,
|
|
141
|
+
expiration=expiration,
|
|
142
|
+
neg_risk=neg_risk,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
signature = await sign_typed_data(payload)
|
|
146
|
+
if not signature.startswith("0x"):
|
|
147
|
+
signature = f"0x{signature}"
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
"salt": str(payload.message["salt"]),
|
|
151
|
+
"maker": funder_address,
|
|
152
|
+
"signer": signer_address,
|
|
153
|
+
"taker": "0x0000000000000000000000000000000000000000",
|
|
154
|
+
"tokenId": token_id,
|
|
155
|
+
"makerAmount": str(maker_amount),
|
|
156
|
+
"takerAmount": str(taker_amount),
|
|
157
|
+
"expiration": str(expiration),
|
|
158
|
+
"nonce": str(nonce),
|
|
159
|
+
"feeRateBps": str(fee_rate_bps),
|
|
160
|
+
"side": side,
|
|
161
|
+
"signatureType": int(signature_type),
|
|
162
|
+
"signature": signature,
|
|
163
|
+
}
|