polynode 0.7.3__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.
- {polynode-0.7.3 → polynode-0.9.2}/PKG-INFO +11 -3
- {polynode-0.7.3 → polynode-0.9.2}/README.md +10 -2
- polynode-0.9.2/polynode/_version.py +1 -0
- polynode-0.9.2/polynode/trading/V2_ORDER_FLOW.md +264 -0
- {polynode-0.7.3 → polynode-0.9.2}/polynode/trading/clob_api.py +59 -1
- {polynode-0.7.3 → polynode-0.9.2}/polynode/trading/constants.py +18 -1
- {polynode-0.7.3 → polynode-0.9.2}/polynode/trading/eip712.py +2 -0
- {polynode-0.7.3 → polynode-0.9.2}/polynode/trading/escrow.py +50 -8
- {polynode-0.7.3 → polynode-0.9.2}/polynode/trading/onboarding.py +34 -11
- polynode-0.9.2/polynode/trading/relayer.py +303 -0
- {polynode-0.7.3 → polynode-0.9.2}/polynode/trading/trader.py +167 -54
- {polynode-0.7.3 → polynode-0.9.2}/polynode/trading/types.py +3 -0
- {polynode-0.7.3 → polynode-0.9.2}/pyproject.toml +1 -1
- {polynode-0.7.3 → polynode-0.9.2}/tests/test_trading.py +13 -4
- polynode-0.7.3/polynode/_version.py +0 -1
- {polynode-0.7.3 → polynode-0.9.2}/.gitignore +0 -0
- {polynode-0.7.3 → polynode-0.9.2}/polynode/__init__.py +0 -0
- {polynode-0.7.3 → polynode-0.9.2}/polynode/cache/__init__.py +0 -0
- {polynode-0.7.3 → polynode-0.9.2}/polynode/client.py +0 -0
- {polynode-0.7.3 → polynode-0.9.2}/polynode/engine.py +0 -0
- {polynode-0.7.3 → polynode-0.9.2}/polynode/errors.py +0 -0
- {polynode-0.7.3 → polynode-0.9.2}/polynode/orderbook.py +0 -0
- {polynode-0.7.3 → polynode-0.9.2}/polynode/orderbook_state.py +0 -0
- {polynode-0.7.3 → polynode-0.9.2}/polynode/redemption_watcher.py +0 -0
- {polynode-0.7.3 → polynode-0.9.2}/polynode/short_form.py +0 -0
- {polynode-0.7.3 → polynode-0.9.2}/polynode/subscription.py +0 -0
- {polynode-0.7.3 → polynode-0.9.2}/polynode/testing.py +0 -0
- {polynode-0.7.3 → polynode-0.9.2}/polynode/trading/__init__.py +0 -0
- {polynode-0.7.3 → polynode-0.9.2}/polynode/trading/cosigner.py +0 -0
- {polynode-0.7.3 → polynode-0.9.2}/polynode/trading/position_management.py +0 -0
- {polynode-0.7.3 → polynode-0.9.2}/polynode/trading/privy.py +0 -0
- {polynode-0.7.3 → polynode-0.9.2}/polynode/trading/signer.py +0 -0
- {polynode-0.7.3 → polynode-0.9.2}/polynode/trading/sqlite_backend.py +0 -0
- {polynode-0.7.3 → polynode-0.9.2}/polynode/types/__init__.py +0 -0
- {polynode-0.7.3 → polynode-0.9.2}/polynode/types/enums.py +0 -0
- {polynode-0.7.3 → polynode-0.9.2}/polynode/types/events.py +0 -0
- {polynode-0.7.3 → polynode-0.9.2}/polynode/types/orderbook.py +0 -0
- {polynode-0.7.3 → polynode-0.9.2}/polynode/types/rest.py +0 -0
- {polynode-0.7.3 → polynode-0.9.2}/polynode/types/short_form.py +0 -0
- {polynode-0.7.3 → polynode-0.9.2}/polynode/types/ws.py +0 -0
- {polynode-0.7.3 → polynode-0.9.2}/polynode/ws.py +0 -0
- {polynode-0.7.3 → polynode-0.9.2}/tests/__init__.py +0 -0
- {polynode-0.7.3 → polynode-0.9.2}/tests/conftest.py +0 -0
- {polynode-0.7.3 → polynode-0.9.2}/tests/test_client.py +0 -0
- {polynode-0.7.3 → polynode-0.9.2}/tests/test_orderbook.py +0 -0
- {polynode-0.7.3 → 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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
71
|
-
|
|
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":
|
|
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
|
|
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
|
|
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=
|
|
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
|
)
|