polynode 0.7.4__tar.gz → 0.9.2__tar.gz

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.
Files changed (46) hide show
  1. {polynode-0.7.4 → polynode-0.9.2}/PKG-INFO +11 -3
  2. {polynode-0.7.4 → polynode-0.9.2}/README.md +10 -2
  3. polynode-0.9.2/polynode/_version.py +1 -0
  4. polynode-0.9.2/polynode/trading/V2_ORDER_FLOW.md +264 -0
  5. {polynode-0.7.4 → polynode-0.9.2}/polynode/trading/clob_api.py +59 -1
  6. {polynode-0.7.4 → polynode-0.9.2}/polynode/trading/constants.py +18 -1
  7. {polynode-0.7.4 → polynode-0.9.2}/polynode/trading/eip712.py +2 -0
  8. {polynode-0.7.4 → polynode-0.9.2}/polynode/trading/escrow.py +50 -8
  9. {polynode-0.7.4 → polynode-0.9.2}/polynode/trading/onboarding.py +34 -11
  10. polynode-0.9.2/polynode/trading/relayer.py +303 -0
  11. {polynode-0.7.4 → polynode-0.9.2}/polynode/trading/trader.py +165 -54
  12. {polynode-0.7.4 → polynode-0.9.2}/polynode/trading/types.py +3 -0
  13. {polynode-0.7.4 → polynode-0.9.2}/pyproject.toml +1 -1
  14. {polynode-0.7.4 → polynode-0.9.2}/tests/test_trading.py +13 -4
  15. polynode-0.7.4/polynode/_version.py +0 -1
  16. {polynode-0.7.4 → polynode-0.9.2}/.gitignore +0 -0
  17. {polynode-0.7.4 → polynode-0.9.2}/polynode/__init__.py +0 -0
  18. {polynode-0.7.4 → polynode-0.9.2}/polynode/cache/__init__.py +0 -0
  19. {polynode-0.7.4 → polynode-0.9.2}/polynode/client.py +0 -0
  20. {polynode-0.7.4 → polynode-0.9.2}/polynode/engine.py +0 -0
  21. {polynode-0.7.4 → polynode-0.9.2}/polynode/errors.py +0 -0
  22. {polynode-0.7.4 → polynode-0.9.2}/polynode/orderbook.py +0 -0
  23. {polynode-0.7.4 → polynode-0.9.2}/polynode/orderbook_state.py +0 -0
  24. {polynode-0.7.4 → polynode-0.9.2}/polynode/redemption_watcher.py +0 -0
  25. {polynode-0.7.4 → polynode-0.9.2}/polynode/short_form.py +0 -0
  26. {polynode-0.7.4 → polynode-0.9.2}/polynode/subscription.py +0 -0
  27. {polynode-0.7.4 → polynode-0.9.2}/polynode/testing.py +0 -0
  28. {polynode-0.7.4 → polynode-0.9.2}/polynode/trading/__init__.py +0 -0
  29. {polynode-0.7.4 → polynode-0.9.2}/polynode/trading/cosigner.py +0 -0
  30. {polynode-0.7.4 → polynode-0.9.2}/polynode/trading/position_management.py +0 -0
  31. {polynode-0.7.4 → polynode-0.9.2}/polynode/trading/privy.py +0 -0
  32. {polynode-0.7.4 → polynode-0.9.2}/polynode/trading/signer.py +0 -0
  33. {polynode-0.7.4 → polynode-0.9.2}/polynode/trading/sqlite_backend.py +0 -0
  34. {polynode-0.7.4 → polynode-0.9.2}/polynode/types/__init__.py +0 -0
  35. {polynode-0.7.4 → polynode-0.9.2}/polynode/types/enums.py +0 -0
  36. {polynode-0.7.4 → polynode-0.9.2}/polynode/types/events.py +0 -0
  37. {polynode-0.7.4 → polynode-0.9.2}/polynode/types/orderbook.py +0 -0
  38. {polynode-0.7.4 → polynode-0.9.2}/polynode/types/rest.py +0 -0
  39. {polynode-0.7.4 → polynode-0.9.2}/polynode/types/short_form.py +0 -0
  40. {polynode-0.7.4 → polynode-0.9.2}/polynode/types/ws.py +0 -0
  41. {polynode-0.7.4 → polynode-0.9.2}/polynode/ws.py +0 -0
  42. {polynode-0.7.4 → polynode-0.9.2}/tests/__init__.py +0 -0
  43. {polynode-0.7.4 → polynode-0.9.2}/tests/conftest.py +0 -0
  44. {polynode-0.7.4 → polynode-0.9.2}/tests/test_client.py +0 -0
  45. {polynode-0.7.4 → polynode-0.9.2}/tests/test_orderbook.py +0 -0
  46. {polynode-0.7.4 → polynode-0.9.2}/tests/test_types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: polynode
3
- Version: 0.7.4
3
+ Version: 0.9.2
4
4
  Summary: Python SDK for the PolyNode real-time prediction market data platform
5
5
  Project-URL: Homepage, https://polynode.dev
6
6
  Project-URL: Documentation, https://docs.polynode.dev
@@ -109,10 +109,15 @@ asyncio.run(main())
109
109
 
110
110
  ```python
111
111
  import asyncio
112
- from polynode.trading import PolyNodeTrader, TraderConfig, OrderParams
112
+ from polynode.trading import PolyNodeTrader, TraderConfig, OrderParams, ExchangeVersion
113
113
 
114
114
  async def main():
115
- trader = PolyNodeTrader(TraderConfig(polynode_key="pn_live_..."))
115
+ # Default is V1 (USDC.e). Pass exchange_version=ExchangeVersion.V2 for the V2 CLOB
116
+ # (pUSD collateral, clob-v2.polymarket.com, builder attribution).
117
+ trader = PolyNodeTrader(TraderConfig(
118
+ polynode_key="pn_live_...",
119
+ exchange_version=ExchangeVersion.V2,
120
+ ))
116
121
  status = await trader.ensure_ready("0xYourPrivateKey...")
117
122
 
118
123
  result = await trader.order(OrderParams(
@@ -120,6 +125,7 @@ async def main():
120
125
  side="BUY",
121
126
  price=0.55,
122
127
  size=100,
128
+ builder="0x<your_builder_code_bytes32>", # V2 only; omit for V1
123
129
  ))
124
130
  print(result)
125
131
 
@@ -128,6 +134,8 @@ async def main():
128
134
  asyncio.run(main())
129
135
  ```
130
136
 
137
+ For the V2 order flow — required approvals, EIP-712 struct, fee math, and common failure modes — see `polynode/trading/V2_ORDER_FLOW.md` in the installed package.
138
+
131
139
  ## Documentation
132
140
 
133
141
  Full docs at [docs.polynode.dev](https://docs.polynode.dev)
@@ -76,10 +76,15 @@ asyncio.run(main())
76
76
 
77
77
  ```python
78
78
  import asyncio
79
- from polynode.trading import PolyNodeTrader, TraderConfig, OrderParams
79
+ from polynode.trading import PolyNodeTrader, TraderConfig, OrderParams, ExchangeVersion
80
80
 
81
81
  async def main():
82
- trader = PolyNodeTrader(TraderConfig(polynode_key="pn_live_..."))
82
+ # Default is V1 (USDC.e). Pass exchange_version=ExchangeVersion.V2 for the V2 CLOB
83
+ # (pUSD collateral, clob-v2.polymarket.com, builder attribution).
84
+ trader = PolyNodeTrader(TraderConfig(
85
+ polynode_key="pn_live_...",
86
+ exchange_version=ExchangeVersion.V2,
87
+ ))
83
88
  status = await trader.ensure_ready("0xYourPrivateKey...")
84
89
 
85
90
  result = await trader.order(OrderParams(
@@ -87,6 +92,7 @@ async def main():
87
92
  side="BUY",
88
93
  price=0.55,
89
94
  size=100,
95
+ builder="0x<your_builder_code_bytes32>", # V2 only; omit for V1
90
96
  ))
91
97
  print(result)
92
98
 
@@ -95,6 +101,8 @@ async def main():
95
101
  asyncio.run(main())
96
102
  ```
97
103
 
104
+ For the V2 order flow — required approvals, EIP-712 struct, fee math, and common failure modes — see `polynode/trading/V2_ORDER_FLOW.md` in the installed package.
105
+
98
106
  ## Documentation
99
107
 
100
108
  Full docs at [docs.polynode.dev](https://docs.polynode.dev)
@@ -0,0 +1 @@
1
+ __version__ = "0.9.1"
@@ -0,0 +1,264 @@
1
+ # V2 Order Flow — Reference
2
+
3
+ End-to-end recipe for placing a Polymarket V2 CLOB order with builder attribution.
4
+ Verified 2026-04-21 with matched order `0xaecd1060f7c978d0ab947eacc12a17106b7a5d087aefe04c275f3d83d2aa6281` (tx `0xb86168562057efd161a1f9ac77ecf3728653cf9668a21567c4923a298cb24d77`).
5
+
6
+ ## TL;DR
7
+
8
+ ```
9
+ 1. Approve pUSD to: CTF_EXCHANGE_V2, NEG_RISK_EXCHANGE_V2_A, NEG_RISK_ADAPTER (V1!)
10
+ 2. setApprovalForAll CTF → same three addresses
11
+ 3. GET /balance-allowance/update — force CLOB cache refresh after approvals
12
+ 4. Sign EIP-712 Order (domain name "Polymarket CTF Exchange", version "2")
13
+ 5. POST /order with L2 HMAC headers
14
+ ```
15
+
16
+ ## Required Approvals (V2 CLOB balance-allowance check)
17
+
18
+ The V2 CLOB checks **three** pUSD allowances for orders:
19
+
20
+ | Address | Name | Why |
21
+ |---|---|---|
22
+ | `0xE111180000d2663C0091e4f400237545B87B996B` | `CTF_EXCHANGE_V2` | Standard market orders |
23
+ | `0xe2222d279d744050d28e00520010520000310F59` | `NEG_RISK_EXCHANGE_V2_A` | Neg-risk market orders |
24
+ | `0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296` | `NegRiskAdapter` (V1) | Settlement delegation |
25
+
26
+ **The V1 `NegRiskAdapter` MUST be approved for pUSD even though it's a V1 contract.** The V2 neg-risk exchange delegates settlement to it. Without this allowance set, the CLOB rejects every order with:
27
+
28
+ ```
29
+ {"error":"not enough balance / allowance"}
30
+ ```
31
+
32
+ ## Order Flow Step-by-Step
33
+
34
+ ### 1. On-chain approvals (gasless via relayer for Safe wallets)
35
+
36
+ For **every** spender above:
37
+ - `pUSD.approve(spender, MAX_UINT256)`
38
+ - `CTF.setApprovalForAll(spender, true)` — only needed for SELL orders, but safe to set
39
+
40
+ Batch all 6 TXs into one Safe multisend via `RelayClient.execute(...)`.
41
+
42
+ ### 2. Refresh CLOB balance/allowance cache
43
+
44
+ ```
45
+ GET https://clob-v2.polymarket.com/balance-allowance/update?asset_type=COLLATERAL&signature_type=2
46
+ Headers: POLY_ADDRESS, POLY_SIGNATURE, POLY_TIMESTAMP, POLY_API_KEY, POLY_PASSPHRASE
47
+ HMAC path: /balance-allowance/update (NO query string in HMAC)
48
+ ```
49
+
50
+ The CLOB caches balance/allowance per-API-key. Approvals on-chain are invisible until this endpoint is pinged. **Call this after every approval change.** Wait ~1s before placing orders.
51
+
52
+ Verify:
53
+ ```
54
+ GET /balance-allowance?asset_type=COLLATERAL&signature_type=2
55
+ ```
56
+ Response:
57
+ ```json
58
+ {
59
+ "balance": "2179236",
60
+ "allowances": {
61
+ "0xE111180000d2663C0091e4f400237545B87B996B": "115792089...",
62
+ "0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296": "115792089...",
63
+ "0xe2222d279d744050d28e00520010520000310F59": "115792089..."
64
+ }
65
+ }
66
+ ```
67
+ All three allowances must be non-zero.
68
+
69
+ ### 3. Determine `neg_risk` and pick exchange address
70
+
71
+ ```
72
+ GET /neg-risk?token_id={tokenId}
73
+ ```
74
+ Response is `{"neg_risk": true|false}`. Do NOT use `!!response` — the **object itself** is always truthy, you need to read the `.neg_risk` field.
75
+
76
+ - `neg_risk: false` → sign against `CTF_EXCHANGE_V2` (`0xe111180000d2663c0091e4f400237545b87b996b`)
77
+ - `neg_risk: true` → sign against `NEG_RISK_EXCHANGE_V2_A` (`0xe2222d279d744050d28e00520010520000310f59`)
78
+
79
+ ### 4. Compute raw amounts (string-based, no floats)
80
+
81
+ V2 CLOB requires clean decimal places:
82
+
83
+ | Order class | maker amount | taker amount |
84
+ |---|---|---|
85
+ | LIMIT BUY (tick 0.01) | ≤ 4 decimals | ≤ 2 decimals |
86
+ | LIMIT SELL (tick 0.01) | ≤ 2 decimals | ≤ 4 decimals |
87
+ | MARKET BUY (FAK/FOK) | ≤ 2 decimals | ≤ 4 decimals |
88
+
89
+ Use string-based conversion to avoid float precision bugs:
90
+
91
+ ```ts
92
+ // BUY 4 tokens at $0.43 (cost $1.72)
93
+ const usdcAmount = (0.43 * 4).toFixed(4); // "1.7200"
94
+ const tokenAmount = (4).toFixed(2); // "4.00"
95
+ // → parseUnits(usdcAmount, 6) = 1720000n (raw makerAmount)
96
+ // → parseUnits(tokenAmount, 6) = 4000000n (raw takerAmount)
97
+ ```
98
+
99
+ `Math.trunc(0.18 * 10 * 1e6)` returns `1799999` (not `1800000`) due to IEEE 754 — DO NOT use float math.
100
+
101
+ ### 5. EIP-712 sign
102
+
103
+ Domain:
104
+ ```json
105
+ {
106
+ "name": "Polymarket CTF Exchange",
107
+ "version": "2",
108
+ "chainId": 137,
109
+ "verifyingContract": "<exchange address from step 3>"
110
+ }
111
+ ```
112
+
113
+ Order struct (uint256 for amounts, uint8 for side/signatureType, bytes32 for metadata/builder):
114
+ ```ts
115
+ Order: [
116
+ { name: 'salt', type: 'uint256' },
117
+ { name: 'maker', type: 'address' },
118
+ { name: 'signer', type: 'address' },
119
+ { name: 'tokenId', type: 'uint256' },
120
+ { name: 'makerAmount', type: 'uint256' },
121
+ { name: 'takerAmount', type: 'uint256' },
122
+ { name: 'side', type: 'uint8' }, // 0 = BUY, 1 = SELL
123
+ { name: 'signatureType', type: 'uint8' }, // 0 = EOA, 1 = PROXY, 2 = SAFE
124
+ { name: 'timestamp', type: 'uint256' },
125
+ { name: 'metadata', type: 'bytes32' },
126
+ { name: 'builder', type: 'bytes32' },
127
+ ]
128
+ ```
129
+
130
+ `taker` and `expiration` are **NOT** in the signed struct — they are post-signing payload fields.
131
+
132
+ ### 6. POST /order
133
+
134
+ Endpoint: `POST https://clob-v2.polymarket.com/order`
135
+
136
+ Exact payload shape (matches `@polymarket/clob-client-v2`):
137
+ ```json
138
+ {
139
+ "order": {
140
+ "salt": 1776743397975,
141
+ "maker": "0xbdc1453718AEc6836F03964c254A5F08bFB3c038",
142
+ "signer": "0x3d5BB69B72E731Ac7510b6ae451D523dAd55555e",
143
+ "tokenId": "102941331082550455210851718765952983636922827683407411724225360467552735563141",
144
+ "makerAmount": "1720000",
145
+ "takerAmount": "4000000",
146
+ "side": "BUY",
147
+ "signatureType": 2,
148
+ "timestamp": "1776743397975",
149
+ "metadata": "0x0000000000000000000000000000000000000000000000000000000000000000",
150
+ "builder": "0x5472fdd700a9b2b6613d103095048c92304e97215a2607f73a9d5aa3701f3f09",
151
+ "signature": "0x9f52a79de5cf438330f8c894a8a111e70de944d165b5e774f91a14906302c71a2b8cb5a1194aef4291827985885d54e201a7574b6c0aa1f002436ffda185344b1b",
152
+ "taker": "0x0000000000000000000000000000000000000000",
153
+ "expiration": "0"
154
+ },
155
+ "owner": "<YOUR_CLOB_API_KEY_UUID>",
156
+ "orderType": "FAK",
157
+ "postOnly": false,
158
+ "deferExec": false
159
+ }
160
+ ```
161
+
162
+ Field notes:
163
+ - `owner` = the API key UUID (not the wallet address, not the apiKey UUID of the BUILDER)
164
+ - `orderType` = `GTC` | `GTD` | `FAK` | `FOK`
165
+ - `taker` = zero address for public orders (private orders set this to the intended counterparty)
166
+ - `expiration` = unix seconds, `"0"` = no expiration
167
+ - `postOnly: true` is incompatible with `FOK`/`FAK`
168
+ - `deferExec: false` is standard — true defers on-chain execution to a later bucket
169
+
170
+ ### 7. L2 HMAC headers
171
+
172
+ ```
173
+ POLY_ADDRESS = EOA that signed the API key (NOT the Safe/funder)
174
+ POLY_API_KEY = UUID from creds.apiKey
175
+ POLY_PASSPHRASE= creds.apiPassphrase
176
+ POLY_TIMESTAMP = unix seconds (string)
177
+ POLY_SIGNATURE = base64url(HMAC-SHA256(base64_decode(secret), timestamp + method + path + body))
178
+ where base64url = base64 with "+" → "-", "/" → "_"
179
+ ```
180
+
181
+ Path in HMAC is `/order` only (no query string).
182
+
183
+ ## Fees and Budget Math
184
+
185
+ Every market has a `base_fee` (in basis points of a 1.0 coefficient):
186
+ ```
187
+ GET /fee-rate?token_id={tokenId} → {"base_fee": 1000} // 10%
188
+ ```
189
+
190
+ Effective fee per trade (from `@polymarket/clob-client-v2`):
191
+ ```
192
+ platformFeeRate = feeRate * (price * (1 - price)) ** feeExponent
193
+ platformFee = (amount / price) * platformFeeRate
194
+ totalCost = amount + platformFee + amount * builderTakerFeeRate
195
+ ```
196
+
197
+ For a BUY of `size` tokens at `price`:
198
+ - `amount = price * size` (raw USDC in 6 decimals)
199
+ - `tokens_out = size`
200
+ - Fee on trade ≈ `(price * size) * feeRate * (price * (1-price))^exponent`
201
+
202
+ **You need `balance >= amount + platformFee`.** The CLOB rejects orders with "not enough balance / allowance" if balance can't cover cost + fee.
203
+
204
+ Example: our 5-tokens-at-$0.42 attempts failed because `5 * 0.42 + fee = $2.15-2.20` and balance was `$2.179`. Dropping to 4 tokens put notional at `$1.72` with $0.07 fee — room to spare.
205
+
206
+ Minimum notional for a MARKETABLE BUY is `$1` (not checked for non-crossing limits). Minimum size `min_order_size` in the book response is a per-market floor that the matching engine enforces at fill time, but the CLOB accepts smaller notional orders (we tested size 4 against a market with `min_order_size: 5`).
207
+
208
+ ## Verifying Builder Attribution
209
+
210
+ After a match, the builder code flows to `/builder/trades`:
211
+ ```
212
+ GET /builder/trades?builder_code=0x5472fdd700a9b2b6613d103095048c92304e97215a2607f73a9d5aa3701f3f09
213
+ ```
214
+
215
+ Response entry confirms the on-chain settlement:
216
+ ```json
217
+ {
218
+ "id": "019dae25-6845-7df6-8811-8e1bae76cf6a",
219
+ "tradeType": "TAKER",
220
+ "takerOrderHash": "0xaecd1060f7c978d0ab947eacc12a17106b7a5d087aefe04c275f3d83d2aa6281",
221
+ "market": "0xd76e7cbb6d6442801ca88e8433d1721c33a34a2f6d85d334e1d8331c76bf6c55",
222
+ "side": "BUY",
223
+ "size": "4.095237",
224
+ "sizeUsdc": "1.719999",
225
+ "price": "0.42",
226
+ "status": "TRADE_STATUS_MATCHED",
227
+ "transactionHash": "0xb86168562057efd161a1f9ac77ecf3728653cf9668a21567c4923a298cb24d77",
228
+ "feeUsdc": "0.07182",
229
+ "builderFee": "0",
230
+ "builderCode": "0x5472fdd700a9b2b6613d103095048c92304e97215a2607f73a9d5aa3701f3f09"
231
+ }
232
+ ```
233
+
234
+ The `builderCode` field ties the on-chain trade to the attribution record — the exchange contract reads it from the signed order struct, so it's cryptographically bound and can't be stripped by any intermediate.
235
+
236
+ ## Common Failure Modes
237
+
238
+ | Error | Cause |
239
+ |---|---|
240
+ | `"not enough balance / allowance"` | (a) V1 NegRiskAdapter not approved for pUSD; (b) balance-allowance cache stale — run `/balance-allowance/update`; (c) balance < notional + fee |
241
+ | `"invalid signature"` | Wrong exchange address in EIP-712 domain. Almost always caused by `neg_risk` misdetection — check the `.neg_risk` field, don't `!!` the response object |
242
+ | `"invalid amounts, ... max accuracy of N decimals"` | Float precision in amount calc. Use `parseUnits(decimalString, 6)`, not `Math.trunc(x * 1e6)` |
243
+ | `"no orders found to match with FAK order"` | The matching engine ran and found no crossing asks. Either (a) book API showing stale asks, or (b) price didn't actually cross — check you're using the correct tick size |
244
+ | `"invalid amount for a marketable BUY order ($0.5), min size: $1"` | Marketable BUY notional is below $1 |
245
+ | `"Unauthorized/Invalid api key"` on GET | HMAC computed over the **base path only** — query string must NOT be in the signing message |
246
+
247
+ ## Working Example
248
+
249
+ See `/home/polygon/trading-lab/test-v2-real-match.mjs` for a full end-to-end example that placed and matched the order above.
250
+
251
+ ## Exchange Constants (Polygon mainnet, chain 137)
252
+
253
+ ```
254
+ pUSD : 0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB
255
+ CTF (ERC-1155) : 0x4D97DCd97eC945f40cF65F87097ACe5EA0476045
256
+ CTF_EXCHANGE_V2 : 0xe111180000d2663c0091e4f400237545b87b996b
257
+ NEG_RISK_EXCHANGE_V2_A : 0xe2222d279d744050d28e00520010520000310f59
258
+ NegRiskAdapter (V1) : 0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296
259
+ CtfCollateralAdapter : 0xADa100874d00e3331D00F2007a9c336a65009718
260
+ NegRiskCtfCollateralAdapter : 0xAdA200001000ef00D07553cEE7006808F895c6F1
261
+
262
+ CLOB_V2_HOST : https://clob-v2.polymarket.com
263
+ RELAYER : https://relayer-v2.polymarket.com
264
+ ```
@@ -187,4 +187,62 @@ async def fetch_neg_risk(
187
187
  async with httpx.AsyncClient(timeout=5.0) as http:
188
188
  resp = await http.get(f"{host}/neg-risk?token_id={token_id}")
189
189
  data = resp.json()
190
- return bool(data.get("neg_risk", data) if isinstance(data, dict) else data)
190
+ if isinstance(data, dict):
191
+ # Read the .neg_risk field directly — `bool(dict)` is always True for non-empty dicts.
192
+ return bool(data.get("neg_risk", False))
193
+ return bool(data)
194
+
195
+
196
+ async def refresh_balance_allowance(
197
+ credentials: dict[str, str],
198
+ wallet_address: str,
199
+ signature_type: int,
200
+ asset_type: str = "COLLATERAL",
201
+ ) -> tuple[bool, int]:
202
+ """Ask the V2 CLOB to refresh its cached balance/allowance view.
203
+
204
+ The CLOB caches on-chain balance + allowance per-API-key. Changes on-chain
205
+ are invisible until this endpoint is pinged. Call after setting approvals.
206
+
207
+ Returns (ok, status_code).
208
+ """
209
+ path = "/balance-allowance/update"
210
+ headers = build_l2_headers(
211
+ credentials["apiKey"],
212
+ credentials["apiSecret"],
213
+ credentials["apiPassphrase"],
214
+ wallet_address,
215
+ "GET",
216
+ path, # HMAC uses base path only (NO query string)
217
+ )
218
+ url = f"{CLOB_HOST_V2}{path}?asset_type={asset_type}&signature_type={signature_type}"
219
+ async with httpx.AsyncClient(timeout=10.0) as http:
220
+ resp = await http.get(url, headers=headers)
221
+ return resp.is_success, resp.status_code
222
+
223
+
224
+ async def get_balance_allowance(
225
+ credentials: dict[str, str],
226
+ wallet_address: str,
227
+ signature_type: int,
228
+ asset_type: str = "COLLATERAL",
229
+ ) -> dict[str, Any] | None:
230
+ """Read the V2 CLOB's current cached view of balance + allowance.
231
+
232
+ Returns dict with {balance, allowances} or None on failure.
233
+ """
234
+ path = "/balance-allowance"
235
+ headers = build_l2_headers(
236
+ credentials["apiKey"],
237
+ credentials["apiSecret"],
238
+ credentials["apiPassphrase"],
239
+ wallet_address,
240
+ "GET",
241
+ path, # HMAC uses base path only
242
+ )
243
+ url = f"{CLOB_HOST_V2}{path}?asset_type={asset_type}&signature_type={signature_type}"
244
+ async with httpx.AsyncClient(timeout=10.0) as http:
245
+ resp = await http.get(url, headers=headers)
246
+ if not resp.is_success:
247
+ return None
248
+ return resp.json()
@@ -32,7 +32,21 @@ PROXY_INIT_CODE_HASH = "0xd21df8dc65880a8606f09fe0ce3df9b8869287ab0b058be05aa9e8
32
32
 
33
33
  # All spender contracts that need approval
34
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]
35
+
36
+ # V2 spenders that need pUSD approval.
37
+ # Order matters: [0..2] are the three spenders the V2 CLOB validates during order placement.
38
+ # [0] CTF_EXCHANGE_V2 — standard market orders
39
+ # [1] NEG_RISK_CTF_EXCHANGE_V2_A — neg-risk market orders
40
+ # [2] NEG_RISK_ADAPTER (V1!) — V2 neg-risk delegates settlement to V1 NRA, so the CLOB
41
+ # requires pUSD allowance here even though it's a V1 contract.
42
+ # Without this, every order fails with "not enough balance / allowance".
43
+ # [3] NEG_RISK_CTF_EXCHANGE_V2_B — reserved variant
44
+ V2_SPENDERS = [
45
+ CTF_EXCHANGE_V2,
46
+ NEG_RISK_CTF_EXCHANGE_V2_A,
47
+ NEG_RISK_ADAPTER,
48
+ NEG_RISK_CTF_EXCHANGE_V2_B,
49
+ ]
36
50
 
37
51
  # PolyUSD wrapping contracts
38
52
  POLY_USD = "0xc011a7e12a19f7b1f670d46f03b03f3342e82dfb"
@@ -40,7 +54,10 @@ COLLATERAL_ONRAMP = "0x93070a847efef7f70739046a929d47a521f5b8ee"
40
54
  COLLATERAL_OFFRAMP = "0x2957922eb93258b93368531d39facca3b4dc5854"
41
55
 
42
56
  # Fee Escrow
57
+ # V1 (USDC.e collateral, paired with the V1 CLOB)
43
58
  FEE_ESCROW_ADDRESS = "0xa11D28433B79D0A88F3119b16A090075752258EA"
59
+ # V2 (pUSD collateral, paired with the V2 CLOB). Deployed 2026-04-24.
60
+ FEE_ESCROW_ADDRESS_V2 = "0x3A43D88ef8Aae4dF5a50B3abf67122CAAeEF7c9F"
44
61
 
45
62
  # Metadata cache TTL (5 minutes)
46
63
  META_TTL_SECONDS = 300
@@ -318,12 +318,14 @@ async def create_signed_order_v2(
318
318
  "salt": payload.message["salt"], # number
319
319
  "maker": funder_address,
320
320
  "signer": signer_address,
321
+ "taker": "0x0000000000000000000000000000000000000000", # zero = public order (post-sign field)
321
322
  "tokenId": token_id,
322
323
  "makerAmount": str(maker_amount),
323
324
  "takerAmount": str(taker_amount),
324
325
  "side": side, # string "BUY"/"SELL"
325
326
  "signatureType": int(signature_type), # number
326
327
  "timestamp": str(payload.message["timestamp"]), # string
328
+ "expiration": "0", # "0" = no expiration (matches @polymarket/clob-client-v2)
327
329
  "metadata": payload.message["metadata"],
328
330
  "builder": payload.message["builder"],
329
331
  "signature": signature,
@@ -14,10 +14,15 @@ from typing import Any
14
14
 
15
15
  import httpx
16
16
 
17
- from .constants import CHAIN_ID, FEE_ESCROW_ADDRESS
18
- from .types import Eip712Payload
17
+ from .constants import CHAIN_ID, FEE_ESCROW_ADDRESS, FEE_ESCROW_ADDRESS_V2
18
+ from .types import Eip712Payload, ExchangeVersion
19
19
 
20
20
  # ── EIP-712 Types ──
21
+ #
22
+ # Both V1 and V2 FeeEscrow share the same FeeAuth struct typehash. They differ only in
23
+ # the EIP-712 domain: V1 is ("PolyNodeFeeEscrow", "1", V1 contract); V2 is
24
+ # ("PolyNodeFeeEscrowV2", "2", V2 contract). Signing against the wrong domain produces
25
+ # a digest the contract won't accept — `pullFee` reverts with `InvalidSignature`.
21
26
 
22
27
  FEE_AUTH_DOMAIN = {
23
28
  "name": "PolyNodeFeeEscrow",
@@ -26,6 +31,13 @@ FEE_AUTH_DOMAIN = {
26
31
  "verifyingContract": FEE_ESCROW_ADDRESS,
27
32
  }
28
33
 
34
+ FEE_AUTH_DOMAIN_V2 = {
35
+ "name": "PolyNodeFeeEscrowV2",
36
+ "version": "2",
37
+ "chainId": CHAIN_ID,
38
+ "verifyingContract": FEE_ESCROW_ADDRESS_V2,
39
+ }
40
+
29
41
  FEE_AUTH_TYPES = {
30
42
  "FeeAuth": [
31
43
  {"name": "orderId", "type": "bytes32"},
@@ -40,6 +52,16 @@ FEE_AUTH_TYPES = {
40
52
  ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"
41
53
 
42
54
 
55
+ def fee_auth_domain_for(version: ExchangeVersion) -> dict:
56
+ """Return the EIP-712 FeeAuth domain for a given exchange version."""
57
+ return FEE_AUTH_DOMAIN_V2 if version == ExchangeVersion.V2 else FEE_AUTH_DOMAIN
58
+
59
+
60
+ def fee_escrow_address_for(version: ExchangeVersion) -> str:
61
+ """Return the FeeEscrow contract address for a given exchange version."""
62
+ return FEE_ESCROW_ADDRESS_V2 if version == ExchangeVersion.V2 else FEE_ESCROW_ADDRESS
63
+
64
+
43
65
  # ── Fee Calculation ──
44
66
 
45
67
  def calculate_fee(price: float, size: float, fee_bps: int) -> int:
@@ -67,8 +89,17 @@ def generate_escrow_order_id() -> str:
67
89
 
68
90
  # ── Nonce Fetching ──
69
91
 
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."""
92
+ async def fetch_escrow_nonce(
93
+ rpc_url: str,
94
+ signer_address: str,
95
+ escrow_address: str = FEE_ESCROW_ADDRESS,
96
+ ) -> int:
97
+ """Fetch the current nonce for a signer from a FeeEscrow contract via eth_call.
98
+
99
+ Pass `fee_escrow_address_for(exchange_version)` for the `escrow_address` argument
100
+ when the caller's exchange version is known (V2 customers must read nonces from
101
+ the V2 contract — nonces on V1 and V2 are tracked independently).
102
+ """
72
103
  # getNonce(address) selector = 0x2d0335ab
73
104
  addr = signer_address.lower().replace("0x", "").zfill(64)
74
105
  data = "0x2d0335ab" + addr
@@ -80,7 +111,7 @@ async def fetch_escrow_nonce(rpc_url: str, signer_address: str) -> int:
80
111
  "jsonrpc": "2.0",
81
112
  "id": 1,
82
113
  "method": "eth_call",
83
- "params": [{"to": FEE_ESCROW_ADDRESS, "data": data}, "latest"],
114
+ "params": [{"to": escrow_address, "data": data}, "latest"],
84
115
  },
85
116
  )
86
117
  result = resp.json()
@@ -103,6 +134,9 @@ class FeeAuthRequest:
103
134
  signature: str
104
135
  affiliate: str
105
136
  affiliate_share_bps: int
137
+ # V2-routing hint for the cosigner. Set when exchange_version == V2 so the
138
+ # cosigner picks the V2 operator; absent for V1 (preserves backwards compat).
139
+ escrow_contract: str | None = None
106
140
 
107
141
 
108
142
  async def sign_fee_auth(
@@ -116,19 +150,22 @@ async def sign_fee_auth(
116
150
  nonce: int,
117
151
  affiliate: str | None = None,
118
152
  affiliate_share_bps: int | None = None,
153
+ exchange_version: ExchangeVersion = ExchangeVersion.V1,
119
154
  ) -> FeeAuthRequest:
120
155
  """Sign a FeeAuth EIP-712 message.
121
156
 
122
157
  Args:
123
158
  sign_typed_data: Callable that signs an Eip712Payload and returns a hex signature.
124
159
  escrow_order_id: Random bytes32 hex string.
125
- payer: Safe wallet address (where USDC is pulled from).
160
+ payer: Safe wallet address (where collateral is pulled from — USDC.e on V1, pUSD on V2).
126
161
  signer_address: EOA address (signs the message).
127
- fee_amount: Fee in raw USDC (6 decimals).
162
+ fee_amount: Fee in raw collateral units (6 decimals).
128
163
  deadline: Unix timestamp — authorization expires after this.
129
164
  nonce: Signer's current nonce from the escrow contract.
130
165
  affiliate: Partner wallet address.
131
166
  affiliate_share_bps: Partner's share of fee in bps.
167
+ exchange_version: V1 (default, USDC.e) or V2 (pUSD). Selects the EIP-712 domain
168
+ and the escrow contract address the cosigner should route to.
132
169
 
133
170
  Returns:
134
171
  FeeAuthRequest ready for the cosigner.
@@ -143,7 +180,7 @@ async def sign_fee_auth(
143
180
  }
144
181
 
145
182
  payload = Eip712Payload(
146
- domain=FEE_AUTH_DOMAIN,
183
+ domain=fee_auth_domain_for(exchange_version),
147
184
  types=FEE_AUTH_TYPES,
148
185
  primary_type="FeeAuth",
149
186
  message=message,
@@ -153,6 +190,10 @@ async def sign_fee_auth(
153
190
  if not signature.startswith("0x"):
154
191
  signature = f"0x{signature}"
155
192
 
193
+ escrow_contract = (
194
+ FEE_ESCROW_ADDRESS_V2 if exchange_version == ExchangeVersion.V2 else None
195
+ )
196
+
156
197
  return FeeAuthRequest(
157
198
  escrow_order_id=escrow_order_id,
158
199
  payer=payer,
@@ -163,4 +204,5 @@ async def sign_fee_auth(
163
204
  signature=signature,
164
205
  affiliate=affiliate or ZERO_ADDRESS,
165
206
  affiliate_share_bps=affiliate_share_bps if affiliate_share_bps is not None else 10000,
207
+ escrow_contract=escrow_contract,
166
208
  )