polynode 0.6.3__py3-none-any.whl → 0.7.1__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.
@@ -12,6 +12,7 @@ from .privy import PrivySigner, PrivyConfig
12
12
  __all__ = [
13
13
  "PolyNodeTrader",
14
14
  "BuilderCredentials",
15
+ "ExchangeVersion",
15
16
  "PrivySigner",
16
17
  "PrivyConfig",
17
18
  "NormalizedSigner",
@@ -6,8 +6,9 @@ from typing import Any
6
6
 
7
7
  import httpx
8
8
 
9
- from .constants import CLOB_HOST
9
+ from .constants import CLOB_HOST, CLOB_HOST_V2
10
10
  from .cosigner import build_l2_headers
11
+ from .types import ExchangeVersion
11
12
 
12
13
 
13
14
  async def post_order(
@@ -18,6 +19,7 @@ async def post_order(
18
19
  wallet_address: str,
19
20
  order_body: str,
20
21
  builder_credentials: Any | None = None,
22
+ clob_host: str | None = None,
21
23
  ) -> dict[str, Any]:
22
24
  """Submit a signed order to the CLOB."""
23
25
  from .cosigner import send_via_cosigner
@@ -38,6 +40,7 @@ async def post_order(
38
40
  fallback_direct,
39
41
  {"method": "POST", "path": "/order", "body": order_body, "headers": headers},
40
42
  builder_credentials=builder_credentials,
43
+ clob_host=clob_host,
41
44
  )
42
45
 
43
46
 
@@ -49,6 +52,7 @@ async def cancel_order(
49
52
  wallet_address: str,
50
53
  order_id: str,
51
54
  builder_credentials: Any | None = None,
55
+ clob_host: str | None = None,
52
56
  ) -> dict[str, Any]:
53
57
  """Cancel a specific order."""
54
58
  from .cosigner import send_via_cosigner
@@ -71,6 +75,7 @@ async def cancel_order(
71
75
  fallback_direct,
72
76
  {"method": "DELETE", "path": "/order", "body": body, "headers": headers},
73
77
  builder_credentials=builder_credentials,
78
+ clob_host=clob_host,
74
79
  )
75
80
 
76
81
 
@@ -82,6 +87,7 @@ async def cancel_all_orders(
82
87
  wallet_address: str,
83
88
  market: str | None = None,
84
89
  builder_credentials: Any | None = None,
90
+ clob_host: str | None = None,
85
91
  ) -> dict[str, Any]:
86
92
  """Cancel all orders, optionally for a specific market."""
87
93
  from .cosigner import send_via_cosigner
@@ -105,6 +111,7 @@ async def cancel_all_orders(
105
111
  fallback_direct,
106
112
  {"method": "DELETE", "path": path, "body": body, "headers": headers},
107
113
  builder_credentials=builder_credentials,
114
+ clob_host=clob_host,
108
115
  )
109
116
 
110
117
 
@@ -117,6 +124,7 @@ async def get_open_orders(
117
124
  market: str | None = None,
118
125
  asset_id: str | None = None,
119
126
  builder_credentials: Any | None = None,
127
+ clob_host: str | None = None,
120
128
  ) -> list[dict[str, Any]]:
121
129
  """Get open orders from the CLOB."""
122
130
  from .cosigner import send_via_cosigner
@@ -145,6 +153,7 @@ async def get_open_orders(
145
153
  fallback_direct,
146
154
  {"method": "GET", "path": path, "headers": headers},
147
155
  builder_credentials=builder_credentials,
156
+ clob_host=clob_host,
148
157
  )
149
158
 
150
159
  if isinstance(result, list):
@@ -152,17 +161,30 @@ async def get_open_orders(
152
161
  return result.get("data") or result.get("orders") or []
153
162
 
154
163
 
155
- async def fetch_tick_size(token_id: str) -> str:
164
+ def _clob_host(version: ExchangeVersion = ExchangeVersion.V1) -> str:
165
+ """Return the CLOB host URL for the given exchange version."""
166
+ return CLOB_HOST_V2 if version == ExchangeVersion.V2 else CLOB_HOST
167
+
168
+
169
+ async def fetch_tick_size(
170
+ token_id: str,
171
+ version: ExchangeVersion = ExchangeVersion.V1,
172
+ ) -> str:
156
173
  """Fetch tick size for a token from the CLOB."""
174
+ host = _clob_host(version)
157
175
  async with httpx.AsyncClient(timeout=5.0) as http:
158
- resp = await http.get(f"{CLOB_HOST}/tick-size?token_id={token_id}")
176
+ resp = await http.get(f"{host}/tick-size?token_id={token_id}")
159
177
  data = resp.json()
160
178
  return str(data.get("minimum_tick_size", data) if isinstance(data, dict) else data)
161
179
 
162
180
 
163
- async def fetch_neg_risk(token_id: str) -> bool:
181
+ async def fetch_neg_risk(
182
+ token_id: str,
183
+ version: ExchangeVersion = ExchangeVersion.V1,
184
+ ) -> bool:
164
185
  """Fetch neg-risk flag for a token from the CLOB."""
186
+ host = _clob_host(version)
165
187
  async with httpx.AsyncClient(timeout=5.0) as http:
166
- resp = await http.get(f"{CLOB_HOST}/neg-risk?token_id={token_id}")
188
+ resp = await http.get(f"{host}/neg-risk?token_id={token_id}")
167
189
  data = resp.json()
168
190
  return bool(data.get("neg_risk", data) if isinstance(data, dict) else data)
@@ -1,16 +1,22 @@
1
1
  """Contract addresses and constants for Polymarket on Polygon (chain 137)."""
2
2
 
3
3
  CHAIN_ID = 137
4
- CLOB_HOST = "https://clob.polymarket.com"
5
4
  RELAYER_HOST = "https://relayer-v2.polymarket.com"
6
5
  DEFAULT_RPC = "https://polygon-bor-rpc.publicnode.com"
7
6
  DEFAULT_COSIGNER = "https://trade.polynode.dev"
8
7
 
9
- # Exchange contracts
8
+ # ── V1 ──
9
+ CLOB_HOST = "https://clob.polymarket.com"
10
10
  CTF_EXCHANGE = "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E"
11
11
  NEG_RISK_CTF_EXCHANGE = "0xC5d563A36AE78145C45a50134d48A1215220f80a"
12
12
  NEG_RISK_ADAPTER = "0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296"
13
13
 
14
+ # ── V2 ──
15
+ CLOB_HOST_V2 = "https://clob-v2.polymarket.com"
16
+ CTF_EXCHANGE_V2 = "0xe111180000d2663c0091e4f400237545b87b996b"
17
+ NEG_RISK_CTF_EXCHANGE_V2_A = "0xe2222d279d744050d28e00520010520000310f59"
18
+ NEG_RISK_CTF_EXCHANGE_V2_B = "0xe2222d002000ba0053cef3375333610f64600036"
19
+
14
20
  # Token contracts
15
21
  USDC = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
16
22
  CTF = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"
@@ -26,6 +32,15 @@ PROXY_INIT_CODE_HASH = "0xd21df8dc65880a8606f09fe0ce3df9b8869287ab0b058be05aa9e8
26
32
 
27
33
  # All spender contracts that need approval
28
34
  SPENDERS = [CTF_EXCHANGE, NEG_RISK_CTF_EXCHANGE, NEG_RISK_ADAPTER]
35
+ V2_SPENDERS = [CTF_EXCHANGE_V2, NEG_RISK_CTF_EXCHANGE_V2_A, NEG_RISK_CTF_EXCHANGE_V2_B]
36
+
37
+ # PolyUSD wrapping contracts
38
+ POLY_USD = "0xc011a7e12a19f7b1f670d46f03b03f3342e82dfb"
39
+ COLLATERAL_ONRAMP = "0x93070a847efef7f70739046a929d47a521f5b8ee"
40
+ COLLATERAL_OFFRAMP = "0x2957922eb93258b93368531d39facca3b4dc5854"
41
+
42
+ # Fee Escrow
43
+ FEE_ESCROW_ADDRESS = "0xa11D28433B79D0A88F3119b16A090075752258EA"
29
44
 
30
45
  # Metadata cache TTL (5 minutes)
31
46
  META_TTL_SECONDS = 300
@@ -49,6 +49,7 @@ async def send_via_cosigner(
49
49
  fallback_direct: bool,
50
50
  request: dict[str, Any],
51
51
  builder_credentials: Any | None = None,
52
+ clob_host: str | None = None,
52
53
  ) -> Any:
53
54
  """Send a request through the co-signer with optional fallback to direct CLOB."""
54
55
  if cosigner_url:
@@ -70,19 +71,20 @@ async def send_via_cosigner(
70
71
  )
71
72
  data = resp.json()
72
73
  if resp.status_code >= 500 and fallback_direct:
73
- return await _send_direct(request)
74
+ return await _send_direct(request, clob_host=clob_host)
74
75
  return data
75
76
  except Exception as e:
76
77
  if fallback_direct:
77
- return await _send_direct(request)
78
+ return await _send_direct(request, clob_host=clob_host)
78
79
  raise RuntimeError(f"Co-signer unreachable: {e}")
79
80
 
80
- return await _send_direct(request)
81
+ return await _send_direct(request, clob_host=clob_host)
81
82
 
82
83
 
83
- async def _send_direct(request: dict[str, Any]) -> Any:
84
+ async def _send_direct(request: dict[str, Any], clob_host: str | None = None) -> Any:
84
85
  """Send directly to Polymarket CLOB (no builder attribution)."""
85
- url = f"{CLOB_HOST}{request['path']}"
86
+ host = clob_host or CLOB_HOST
87
+ url = f"{host}{request['path']}"
86
88
  async with httpx.AsyncClient(timeout=10.0) as http:
87
89
  resp = await http.request(
88
90
  request["method"],
@@ -5,11 +5,17 @@ from __future__ import annotations
5
5
  import math
6
6
  from typing import Any
7
7
 
8
- from .constants import CTF_EXCHANGE, NEG_RISK_CTF_EXCHANGE
9
- from .types import Eip712Payload, SignatureType
8
+ from .constants import (
9
+ CTF_EXCHANGE,
10
+ CTF_EXCHANGE_V2,
11
+ NEG_RISK_CTF_EXCHANGE,
12
+ NEG_RISK_CTF_EXCHANGE_V2_A,
13
+ )
14
+ from .types import Eip712Payload, ExchangeVersion, SignatureType
10
15
 
11
16
 
12
- # EIP-712 domain for Polymarket Exchange
17
+ # ── V1 EIP-712 domains ──
18
+
13
19
  EXCHANGE_DOMAIN = {
14
20
  "name": "Polymarket CTF Exchange",
15
21
  "version": "1",
@@ -41,6 +47,38 @@ ORDER_TYPES = {
41
47
  ],
42
48
  }
43
49
 
50
+ # ── V2 EIP-712 domains ──
51
+
52
+ EXCHANGE_DOMAIN_V2 = {
53
+ "name": "Polymarket CTF Exchange",
54
+ "version": "2",
55
+ "chainId": 137,
56
+ "verifyingContract": CTF_EXCHANGE_V2,
57
+ }
58
+
59
+ NEG_RISK_DOMAIN_V2 = {
60
+ "name": "Polymarket CTF Exchange",
61
+ "version": "2",
62
+ "chainId": 137,
63
+ "verifyingContract": NEG_RISK_CTF_EXCHANGE_V2_A,
64
+ }
65
+
66
+ ORDER_TYPES_V2 = {
67
+ "Order": [
68
+ {"name": "salt", "type": "uint256"},
69
+ {"name": "maker", "type": "address"},
70
+ {"name": "signer", "type": "address"},
71
+ {"name": "tokenId", "type": "uint256"},
72
+ {"name": "makerAmount", "type": "uint256"},
73
+ {"name": "takerAmount", "type": "uint256"},
74
+ {"name": "side", "type": "uint8"},
75
+ {"name": "signatureType", "type": "uint8"},
76
+ {"name": "timestamp", "type": "uint256"},
77
+ {"name": "metadata", "type": "bytes32"},
78
+ {"name": "builder", "type": "bytes32"},
79
+ ],
80
+ }
81
+
44
82
 
45
83
  def compute_amounts(
46
84
  price: float,
@@ -125,7 +163,7 @@ async def create_signed_order(
125
163
  expiration: int = 0,
126
164
  nonce: int = 0,
127
165
  ) -> dict[str, Any]:
128
- """Create and sign a Polymarket order. Returns the order dict ready for CLOB submission."""
166
+ """Create and sign a V1 Polymarket order. Returns the order dict ready for CLOB submission."""
129
167
  maker_amount, taker_amount = compute_amounts(price, size, side, tick_size)
130
168
 
131
169
  payload = build_order_payload(
@@ -167,3 +205,126 @@ async def create_signed_order(
167
205
  "signatureType": int(signature_type),
168
206
  "signature": signature,
169
207
  }
208
+
209
+
210
+ # ── V2 order building ──
211
+
212
+
213
+ def build_order_payload_v2(
214
+ *,
215
+ maker: str,
216
+ signer: str,
217
+ token_id: str,
218
+ maker_amount: int,
219
+ taker_amount: int,
220
+ side: str,
221
+ signature_type: SignatureType,
222
+ neg_risk: bool = False,
223
+ timestamp_ms: int | None = None,
224
+ metadata: str = "0x" + "00" * 32,
225
+ builder: str = "0x" + "00" * 32,
226
+ ) -> Eip712Payload:
227
+ """Build a V2 EIP-712 typed data payload for a Polymarket order."""
228
+ import time
229
+
230
+ if timestamp_ms is None:
231
+ timestamp_ms = int(time.time() * 1000)
232
+
233
+ salt = timestamp_ms
234
+ side_int = 0 if side == "BUY" else 1
235
+ domain = NEG_RISK_DOMAIN_V2 if neg_risk else EXCHANGE_DOMAIN_V2
236
+
237
+ # Pad metadata/builder to bytes32
238
+ meta_hex = metadata if metadata.startswith("0x") else f"0x{metadata}"
239
+ meta_hex = meta_hex[:2] + meta_hex[2:].ljust(64, "0")
240
+ builder_hex = builder if builder.startswith("0x") else f"0x{builder}"
241
+ builder_hex = builder_hex[:2] + builder_hex[2:].ljust(64, "0")
242
+
243
+ message = {
244
+ "salt": salt,
245
+ "maker": maker,
246
+ "signer": signer,
247
+ "tokenId": int(token_id),
248
+ "makerAmount": maker_amount,
249
+ "takerAmount": taker_amount,
250
+ "side": side_int,
251
+ "signatureType": int(signature_type),
252
+ "timestamp": timestamp_ms,
253
+ "metadata": meta_hex,
254
+ "builder": builder_hex,
255
+ }
256
+
257
+ return Eip712Payload(
258
+ domain=domain,
259
+ types=ORDER_TYPES_V2,
260
+ primary_type="Order",
261
+ message=message,
262
+ )
263
+
264
+
265
+ async def create_signed_order_v2(
266
+ sign_typed_data: Any,
267
+ *,
268
+ signer_address: str,
269
+ funder_address: str,
270
+ token_id: str,
271
+ price: float,
272
+ size: float,
273
+ side: str,
274
+ signature_type: SignatureType,
275
+ tick_size: str = "0.01",
276
+ neg_risk: bool = False,
277
+ timestamp_ms: int | None = None,
278
+ metadata: str = "0x" + "00" * 32,
279
+ builder: str = "0x" + "00" * 32,
280
+ ) -> dict[str, Any]:
281
+ """Create and sign a V2 Polymarket order. Returns the order dict ready for V2 CLOB submission.
282
+
283
+ V2 differences from V1:
284
+ - No taker, expiration, nonce, or feeRateBps fields
285
+ - Adds timestamp (ms), metadata (bytes32), builder (bytes32)
286
+ - Domain version "2" with V2 exchange addresses
287
+ - salt is a number (not string) in the payload
288
+ - side is a string ("BUY"/"SELL") in the payload
289
+ - timestamp is a string in the payload
290
+ """
291
+ maker_amount, taker_amount = compute_amounts(price, size, side, tick_size)
292
+
293
+ payload = build_order_payload_v2(
294
+ maker=funder_address,
295
+ signer=signer_address,
296
+ token_id=token_id,
297
+ maker_amount=maker_amount,
298
+ taker_amount=taker_amount,
299
+ side=side,
300
+ signature_type=signature_type,
301
+ neg_risk=neg_risk,
302
+ timestamp_ms=timestamp_ms,
303
+ metadata=metadata,
304
+ builder=builder,
305
+ )
306
+
307
+ signature = await sign_typed_data(payload)
308
+ if not signature.startswith("0x"):
309
+ signature = f"0x{signature}"
310
+
311
+ # Normalize v to raw recovery id (0/1): CLOB expects v=0/1 for order signatures
312
+ sig_bytes = bytearray(bytes.fromhex(signature[2:]))
313
+ if len(sig_bytes) == 65 and sig_bytes[-1] >= 27:
314
+ sig_bytes[-1] -= 27
315
+ signature = "0x" + sig_bytes.hex()
316
+
317
+ return {
318
+ "salt": payload.message["salt"], # number
319
+ "maker": funder_address,
320
+ "signer": signer_address,
321
+ "tokenId": token_id,
322
+ "makerAmount": str(maker_amount),
323
+ "takerAmount": str(taker_amount),
324
+ "side": side, # string "BUY"/"SELL"
325
+ "signatureType": int(signature_type), # number
326
+ "timestamp": str(payload.message["timestamp"]), # string
327
+ "metadata": payload.message["metadata"],
328
+ "builder": payload.message["builder"],
329
+ "signature": signature,
330
+ }
@@ -0,0 +1,166 @@
1
+ """Fee Escrow — EIP-712 signing, fee calculation, and nonce fetching
2
+ for the on-chain FeeEscrow contract.
3
+
4
+ The escrow is completely optional. When fee_bps=0, none of this code runs.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ import os
11
+ import secrets
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+
15
+ import httpx
16
+
17
+ from .constants import CHAIN_ID, FEE_ESCROW_ADDRESS
18
+ from .types import Eip712Payload
19
+
20
+ # ── EIP-712 Types ──
21
+
22
+ FEE_AUTH_DOMAIN = {
23
+ "name": "PolyNodeFeeEscrow",
24
+ "version": "1",
25
+ "chainId": CHAIN_ID,
26
+ "verifyingContract": FEE_ESCROW_ADDRESS,
27
+ }
28
+
29
+ FEE_AUTH_TYPES = {
30
+ "FeeAuth": [
31
+ {"name": "orderId", "type": "bytes32"},
32
+ {"name": "payer", "type": "address"},
33
+ {"name": "signer", "type": "address"},
34
+ {"name": "feeAmount", "type": "uint256"},
35
+ {"name": "deadline", "type": "uint256"},
36
+ {"name": "nonce", "type": "uint256"},
37
+ ],
38
+ }
39
+
40
+ ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"
41
+
42
+
43
+ # ── Fee Calculation ──
44
+
45
+ def calculate_fee(price: float, size: float, fee_bps: int) -> int:
46
+ """Calculate fee amount in raw USDC (6 decimals).
47
+
48
+ Args:
49
+ price: Order price (e.g. 0.55)
50
+ size: Order size in tokens (e.g. 100)
51
+ fee_bps: Fee in basis points (e.g. 50 = 0.5%)
52
+
53
+ Returns:
54
+ Fee amount as int in raw USDC units
55
+ """
56
+ notional_raw = math.floor(price * size * 1e6)
57
+ fee = math.floor(notional_raw * fee_bps / 10000)
58
+ return max(fee, 0)
59
+
60
+
61
+ # ── Order ID Generation ──
62
+
63
+ def generate_escrow_order_id() -> str:
64
+ """Generate a random bytes32 escrow order ID (0x-prefixed hex)."""
65
+ return "0x" + secrets.token_hex(32)
66
+
67
+
68
+ # ── Nonce Fetching ──
69
+
70
+ async def fetch_escrow_nonce(rpc_url: str, signer_address: str) -> int:
71
+ """Fetch the current nonce for a signer from the FeeEscrow contract via eth_call."""
72
+ # getNonce(address) selector = 0x2d0335ab
73
+ addr = signer_address.lower().replace("0x", "").zfill(64)
74
+ data = "0x2d0335ab" + addr
75
+
76
+ async with httpx.AsyncClient() as client:
77
+ resp = await client.post(
78
+ rpc_url,
79
+ json={
80
+ "jsonrpc": "2.0",
81
+ "id": 1,
82
+ "method": "eth_call",
83
+ "params": [{"to": FEE_ESCROW_ADDRESS, "data": data}, "latest"],
84
+ },
85
+ )
86
+ result = resp.json()
87
+
88
+ if "error" in result:
89
+ raise RuntimeError(f"Failed to fetch escrow nonce: {result['error']}")
90
+ return int(result.get("result", "0x0"), 16)
91
+
92
+
93
+ # ── EIP-712 Signing ──
94
+
95
+ @dataclass
96
+ class FeeAuthRequest:
97
+ escrow_order_id: str
98
+ payer: str
99
+ signer: str
100
+ fee_amount: str
101
+ deadline: int
102
+ nonce: int
103
+ signature: str
104
+ affiliate: str
105
+ affiliate_share_bps: int
106
+
107
+
108
+ async def sign_fee_auth(
109
+ sign_typed_data: Any,
110
+ *,
111
+ escrow_order_id: str,
112
+ payer: str,
113
+ signer_address: str,
114
+ fee_amount: int,
115
+ deadline: int,
116
+ nonce: int,
117
+ affiliate: str | None = None,
118
+ affiliate_share_bps: int | None = None,
119
+ ) -> FeeAuthRequest:
120
+ """Sign a FeeAuth EIP-712 message.
121
+
122
+ Args:
123
+ sign_typed_data: Callable that signs an Eip712Payload and returns a hex signature.
124
+ escrow_order_id: Random bytes32 hex string.
125
+ payer: Safe wallet address (where USDC is pulled from).
126
+ signer_address: EOA address (signs the message).
127
+ fee_amount: Fee in raw USDC (6 decimals).
128
+ deadline: Unix timestamp — authorization expires after this.
129
+ nonce: Signer's current nonce from the escrow contract.
130
+ affiliate: Partner wallet address.
131
+ affiliate_share_bps: Partner's share of fee in bps.
132
+
133
+ Returns:
134
+ FeeAuthRequest ready for the cosigner.
135
+ """
136
+ message = {
137
+ "orderId": escrow_order_id,
138
+ "payer": payer,
139
+ "signer": signer_address,
140
+ "feeAmount": str(fee_amount),
141
+ "deadline": str(deadline),
142
+ "nonce": str(nonce),
143
+ }
144
+
145
+ payload = Eip712Payload(
146
+ domain=FEE_AUTH_DOMAIN,
147
+ types=FEE_AUTH_TYPES,
148
+ primary_type="FeeAuth",
149
+ message=message,
150
+ )
151
+
152
+ signature = await sign_typed_data(payload)
153
+ if not signature.startswith("0x"):
154
+ signature = f"0x{signature}"
155
+
156
+ return FeeAuthRequest(
157
+ escrow_order_id=escrow_order_id,
158
+ payer=payer,
159
+ signer=signer_address,
160
+ fee_amount=str(fee_amount),
161
+ deadline=deadline,
162
+ nonce=nonce,
163
+ signature=signature,
164
+ affiliate=affiliate or ZERO_ADDRESS,
165
+ affiliate_share_bps=affiliate_share_bps or 0,
166
+ )