synpath 0.1.0__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.
- synpath/__init__.py +183 -0
- synpath/__main__.py +66 -0
- synpath/base.py +723 -0
- synpath/bucket.py +154 -0
- synpath/client.py +356 -0
- synpath/engine/__init__.py +37 -0
- synpath/engine/__main__.py +354 -0
- synpath/engine/alerts.py +170 -0
- synpath/engine/engine.py +888 -0
- synpath/engine/eod.py +154 -0
- synpath/engine/events.py +140 -0
- synpath/engine/fair_values.py +117 -0
- synpath/engine/feeds.py +220 -0
- synpath/engine/journal.py +907 -0
- synpath/engine/ledger.py +353 -0
- synpath/engine/orders/__init__.py +42 -0
- synpath/engine/orders/base.py +441 -0
- synpath/engine/orders/day.py +72 -0
- synpath/engine/orders/iceberg.py +121 -0
- synpath/engine/orders/manager.py +223 -0
- synpath/engine/orders/oco.py +255 -0
- synpath/engine/orders/peg.py +168 -0
- synpath/engine/orders/routed.py +496 -0
- synpath/engine/orders/stop.py +240 -0
- synpath/engine/orders/taker.py +187 -0
- synpath/engine/orders/twap.py +190 -0
- synpath/engine/paper.py +532 -0
- synpath/engine/reconcile.py +279 -0
- synpath/engine/risk.py +403 -0
- synpath/engine/router.py +261 -0
- synpath/errors.py +98 -0
- synpath/history.py +71 -0
- synpath/hosted.py +86 -0
- synpath/hosted_auth.py +201 -0
- synpath/ids.py +61 -0
- synpath/kalshi.py +1378 -0
- synpath/matching.py +86 -0
- synpath/polymarket.py +1004 -0
- synpath/polymarket_us.py +989 -0
- synpath/remote.py +195 -0
- synpath/server/__init__.py +98 -0
- synpath/server/__main__.py +118 -0
- synpath/server/api.py +439 -0
- synpath/server/errors.py +87 -0
- synpath/server/local.py +96 -0
- synpath/server/models.py +75 -0
- synpath/server/serve.py +236 -0
- synpath/server/store.py +363 -0
- synpath/server/trading.py +764 -0
- synpath/trading/__init__.py +79 -0
- synpath/trading/__main__.py +69 -0
- synpath/trading/base.py +126 -0
- synpath/trading/credentials.py +400 -0
- synpath/trading/errors.py +94 -0
- synpath/trading/init.py +233 -0
- synpath/trading/instruments.py +162 -0
- synpath/trading/kalshi.py +957 -0
- synpath/trading/limiter.py +177 -0
- synpath/trading/money.py +172 -0
- synpath/trading/polymarket.py +1362 -0
- synpath/trading/polymarket_signing.py +478 -0
- synpath/trading/polymarket_us.py +705 -0
- synpath/trading/polymarket_us_exchange.py +825 -0
- synpath/trading/types.py +414 -0
- synpath/types.py +608 -0
- synpath/ws/__init__.py +55 -0
- synpath/ws/base.py +544 -0
- synpath/ws/grpc.py +578 -0
- synpath/ws/kalshi.py +418 -0
- synpath/ws/polymarket.py +430 -0
- synpath/ws/polymarket_us.py +299 -0
- synpath/ws/polymarket_us_exchange.py +754 -0
- synpath-0.1.0.dist-info/METADATA +224 -0
- synpath-0.1.0.dist-info/RECORD +77 -0
- synpath-0.1.0.dist-info/WHEEL +4 -0
- synpath-0.1.0.dist-info/entry_points.txt +2 -0
- synpath-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
"""Polymarket CLOB V2 signing, with no network in sight.
|
|
2
|
+
|
|
3
|
+
Everything that turns intent into bytes lives here, so it can be checked
|
|
4
|
+
byte for byte against the venue's own client (`py-clob-client-v2`) without
|
|
5
|
+
a wallet, a balance, or a request:
|
|
6
|
+
|
|
7
|
+
* **L1** -- the `ClobAuth` EIP-712 message that proves control of the signer
|
|
8
|
+
and creates or derives the CLOB API credentials.
|
|
9
|
+
* **L2** -- the HMAC-SHA256 over `timestamp + METHOD + path + body` that
|
|
10
|
+
authenticates every private request.
|
|
11
|
+
* **Orders** -- the V2 `Order` struct (domain version `"2"`, no nonce, no
|
|
12
|
+
fee, a millisecond `timestamp`, `metadata` and `builder` bytes32), its
|
|
13
|
+
amounts rounded exactly as the venue requires, signed directly for an EOA,
|
|
14
|
+
proxy or Safe wallet, or wrapped for ERC-7739 validation for a Deposit
|
|
15
|
+
Wallet.
|
|
16
|
+
* **Wallet calls** -- ABI-encoded approvals, split, merge and redeem, and
|
|
17
|
+
the Deposit Wallet `Batch` the relayer executes gaslessly.
|
|
18
|
+
|
|
19
|
+
Money is `Decimal` throughout. The venue's client computes amounts in
|
|
20
|
+
floating point; the rounding rules are the same, and the vectors in the
|
|
21
|
+
tests pin that both agree on every case they cover.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import base64
|
|
26
|
+
import hashlib
|
|
27
|
+
import hmac
|
|
28
|
+
import secrets
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from decimal import ROUND_CEILING, ROUND_FLOOR, ROUND_HALF_UP, Decimal
|
|
31
|
+
from typing import Any, Literal
|
|
32
|
+
|
|
33
|
+
from .errors import InvalidOrder
|
|
34
|
+
|
|
35
|
+
CHAIN_ID = 137
|
|
36
|
+
|
|
37
|
+
EXCHANGE = "0xE111180000d2663C0091e4f400237545B87B996B"
|
|
38
|
+
NEG_RISK_EXCHANGE = "0xe2222d279d744050d28e00520010520000310F59"
|
|
39
|
+
CONDITIONAL_TOKENS = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"
|
|
40
|
+
PUSD = "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB"
|
|
41
|
+
CTF_COLLATERAL_ADAPTER = "0xAdA100Db00Ca00073811820692005400218FcE1f"
|
|
42
|
+
NEG_RISK_CTF_COLLATERAL_ADAPTER = "0xadA2005600Dec949baf300f4C6120000bDB6eAab"
|
|
43
|
+
DEPOSIT_WALLET_FACTORY = "0x00000000000Fb5C9ADea0298D729A0CB3823Cc07"
|
|
44
|
+
"""Addresses from the venue's contract page, Polygon mainnet, CLOB V2."""
|
|
45
|
+
|
|
46
|
+
ZERO_BYTES32 = "0x" + "00" * 32
|
|
47
|
+
MAX_UINT256 = 2**256 - 1
|
|
48
|
+
|
|
49
|
+
EXCHANGE_DOMAIN_NAME = "Polymarket CTF Exchange"
|
|
50
|
+
EXCHANGE_DOMAIN_VERSION = "2"
|
|
51
|
+
CLOB_AUTH_DOMAIN = {"name": "ClobAuthDomain", "version": "1", "chainId": CHAIN_ID}
|
|
52
|
+
CLOB_AUTH_MESSAGE = "This message attests that I control the given wallet"
|
|
53
|
+
|
|
54
|
+
ORDER_TYPE = (
|
|
55
|
+
"Order(uint256 salt,address maker,address signer,uint256 tokenId,"
|
|
56
|
+
"uint256 makerAmount,uint256 takerAmount,uint8 side,uint8 signatureType,"
|
|
57
|
+
"uint256 timestamp,bytes32 metadata,bytes32 builder)"
|
|
58
|
+
)
|
|
59
|
+
ORDER_FIELDS = [
|
|
60
|
+
{"name": "salt", "type": "uint256"},
|
|
61
|
+
{"name": "maker", "type": "address"},
|
|
62
|
+
{"name": "signer", "type": "address"},
|
|
63
|
+
{"name": "tokenId", "type": "uint256"},
|
|
64
|
+
{"name": "makerAmount", "type": "uint256"},
|
|
65
|
+
{"name": "takerAmount", "type": "uint256"},
|
|
66
|
+
{"name": "side", "type": "uint8"},
|
|
67
|
+
{"name": "signatureType", "type": "uint8"},
|
|
68
|
+
{"name": "timestamp", "type": "uint256"},
|
|
69
|
+
{"name": "metadata", "type": "bytes32"},
|
|
70
|
+
{"name": "builder", "type": "bytes32"},
|
|
71
|
+
]
|
|
72
|
+
DOMAIN_FIELDS = [
|
|
73
|
+
{"name": "name", "type": "string"},
|
|
74
|
+
{"name": "version", "type": "string"},
|
|
75
|
+
{"name": "chainId", "type": "uint256"},
|
|
76
|
+
{"name": "verifyingContract", "type": "address"},
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
EOA, POLY_PROXY, POLY_GNOSIS_SAFE, DEPOSIT_WALLET = 0, 1, 2, 3
|
|
80
|
+
|
|
81
|
+
SCALE = Decimal(10) ** 6
|
|
82
|
+
"""pUSD and outcome tokens both carry six decimals."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _eth():
|
|
86
|
+
try:
|
|
87
|
+
from eth_abi import encode as abi_encode
|
|
88
|
+
from eth_account import Account
|
|
89
|
+
from eth_account.messages import encode_typed_data
|
|
90
|
+
from eth_utils import keccak, to_checksum_address
|
|
91
|
+
except ImportError as exc: # pragma: no cover - depends on the environment
|
|
92
|
+
raise ImportError("Polymarket signing needs eth-account: pip install synpath") from exc
|
|
93
|
+
return abi_encode, Account, encode_typed_data, keccak, to_checksum_address
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
# The signer
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
class WalletSigner:
|
|
101
|
+
"""Holds the private key. Nothing else in the package sees it."""
|
|
102
|
+
|
|
103
|
+
def __init__(self, private_key: str):
|
|
104
|
+
_, Account, _, _, _ = _eth()
|
|
105
|
+
self._account = Account.from_key(private_key)
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def address(self) -> str:
|
|
109
|
+
return self._account.address
|
|
110
|
+
|
|
111
|
+
def sign_typed_data(self, typed: dict[str, Any]) -> str:
|
|
112
|
+
_, Account, encode_typed_data, _, _ = _eth()
|
|
113
|
+
signed = Account.sign_message(encode_typed_data(full_message=typed), private_key=self._account.key)
|
|
114
|
+
return "0x" + signed.signature.hex().removeprefix("0x")
|
|
115
|
+
|
|
116
|
+
def sign_digest(self, digest: bytes) -> bytes:
|
|
117
|
+
_, Account, _, _, _ = _eth()
|
|
118
|
+
return bytes(Account._sign_hash(digest, private_key=self._account.key).signature)
|
|
119
|
+
|
|
120
|
+
def sign_transaction(self, tx: dict[str, Any]) -> str:
|
|
121
|
+
signed = self._account.sign_transaction(tx)
|
|
122
|
+
raw = getattr(signed, "raw_transaction", None) or getattr(signed, "rawTransaction")
|
|
123
|
+
return "0x" + bytes(raw).hex()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
# L1 and L2 authentication
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
def clob_auth_typed_data(address: str, timestamp: int, nonce: int = 0) -> dict[str, Any]:
|
|
131
|
+
return {
|
|
132
|
+
"primaryType": "ClobAuth",
|
|
133
|
+
"types": {
|
|
134
|
+
"EIP712Domain": DOMAIN_FIELDS[:3],
|
|
135
|
+
"ClobAuth": [
|
|
136
|
+
{"name": "address", "type": "address"},
|
|
137
|
+
{"name": "timestamp", "type": "string"},
|
|
138
|
+
{"name": "nonce", "type": "uint256"},
|
|
139
|
+
{"name": "message", "type": "string"},
|
|
140
|
+
],
|
|
141
|
+
},
|
|
142
|
+
"domain": CLOB_AUTH_DOMAIN,
|
|
143
|
+
"message": {"address": address, "timestamp": str(timestamp), "nonce": nonce, "message": CLOB_AUTH_MESSAGE},
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def l1_headers(signer: WalletSigner, timestamp: int, nonce: int = 0) -> dict[str, str]:
|
|
148
|
+
"""Headers for `POST /auth/api-key` and `GET /auth/derive-api-key`."""
|
|
149
|
+
return {
|
|
150
|
+
"POLY_ADDRESS": signer.address,
|
|
151
|
+
"POLY_SIGNATURE": signer.sign_typed_data(clob_auth_typed_data(signer.address, timestamp, nonce)),
|
|
152
|
+
"POLY_TIMESTAMP": str(timestamp),
|
|
153
|
+
"POLY_NONCE": str(nonce),
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def l2_signature(secret: str, timestamp: int, method: str, path: str, body: str | None = None) -> str:
|
|
158
|
+
"""URL-safe base64 of HMAC-SHA256(base64(secret), ts + METHOD + path + body).
|
|
159
|
+
|
|
160
|
+
`path` is the route without its query string; `body` is the exact text
|
|
161
|
+
sent on the wire. The secret decodes as URL-safe base64, which also reads
|
|
162
|
+
the standard alphabet the venue issues.
|
|
163
|
+
"""
|
|
164
|
+
key = base64.urlsafe_b64decode(secret)
|
|
165
|
+
message = f"{timestamp}{method.upper()}{path}{body or ''}"
|
|
166
|
+
digest = hmac.new(key, message.encode("utf-8"), hashlib.sha256).digest()
|
|
167
|
+
return base64.urlsafe_b64encode(digest).decode("utf-8")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def l2_headers(
|
|
171
|
+
*, address: str, api_key: str, secret: str, passphrase: str,
|
|
172
|
+
timestamp: int, method: str, path: str, body: str | None = None,
|
|
173
|
+
) -> dict[str, str]:
|
|
174
|
+
return {
|
|
175
|
+
"POLY_ADDRESS": address,
|
|
176
|
+
"POLY_SIGNATURE": l2_signature(secret, timestamp, method, path, body),
|
|
177
|
+
"POLY_TIMESTAMP": str(timestamp),
|
|
178
|
+
"POLY_API_KEY": api_key,
|
|
179
|
+
"POLY_PASSPHRASE": passphrase,
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ---------------------------------------------------------------------------
|
|
184
|
+
# Order amounts
|
|
185
|
+
# ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
@dataclass(frozen=True)
|
|
188
|
+
class RoundConfig:
|
|
189
|
+
price: int
|
|
190
|
+
size: int
|
|
191
|
+
amount: int
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
ROUNDING: dict[str, RoundConfig] = {
|
|
195
|
+
"0.1": RoundConfig(price=1, size=2, amount=3),
|
|
196
|
+
"0.01": RoundConfig(price=2, size=2, amount=4),
|
|
197
|
+
"0.005": RoundConfig(price=3, size=2, amount=5),
|
|
198
|
+
"0.0025": RoundConfig(price=4, size=2, amount=6),
|
|
199
|
+
"0.001": RoundConfig(price=3, size=2, amount=5),
|
|
200
|
+
"0.0001": RoundConfig(price=4, size=2, amount=6),
|
|
201
|
+
}
|
|
202
|
+
"""The venue's precision table: decimals for price, share size and the
|
|
203
|
+
USD amount at each tick size."""
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def round_config(tick: Decimal) -> RoundConfig:
|
|
207
|
+
key = format(tick.normalize(), "f")
|
|
208
|
+
try:
|
|
209
|
+
return ROUNDING[key]
|
|
210
|
+
except KeyError:
|
|
211
|
+
raise InvalidOrder(f"polymarket: no precision rule for tick size {tick}; known {sorted(ROUNDING)}") from None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _places(value: Decimal) -> int:
|
|
215
|
+
exponent = value.normalize().as_tuple().exponent
|
|
216
|
+
return max(0, -exponent) if isinstance(exponent, int) else 0
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _q(value: Decimal, places: int, rounding: str) -> Decimal:
|
|
220
|
+
return value.quantize(Decimal(1).scaleb(-places), rounding=rounding)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _fit_amount(value: Decimal, config: RoundConfig) -> Decimal:
|
|
224
|
+
"""The venue's rule for the derived USD (or share) amount: if it carries
|
|
225
|
+
more decimals than allowed, round up at four extra places, then down."""
|
|
226
|
+
if _places(value) > config.amount:
|
|
227
|
+
value = _q(value, config.amount + 4, ROUND_CEILING)
|
|
228
|
+
if _places(value) > config.amount:
|
|
229
|
+
value = _q(value, config.amount, ROUND_FLOOR)
|
|
230
|
+
return value
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def to_raw(value: Decimal) -> int:
|
|
234
|
+
"""A six-decimal integer; half-up at the last place, as the venue's client."""
|
|
235
|
+
return int((value * SCALE).quantize(Decimal(1), rounding=ROUND_HALF_UP))
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def limit_amounts(side: Literal["BUY", "SELL"], price: Decimal, size: Decimal, tick: Decimal) -> tuple[int, int]:
|
|
239
|
+
"""`(makerAmount, takerAmount)` for a limit order of `size` shares at `price`.
|
|
240
|
+
|
|
241
|
+
A BUY gives `price * size` USD for `size` shares; a SELL gives `size`
|
|
242
|
+
shares for `price * size` USD. The size is rounded down to two places
|
|
243
|
+
and the price to the tick's places, exactly as the venue does. Callers
|
|
244
|
+
validate first, so neither rounding changes an order that was typed on
|
|
245
|
+
the grid.
|
|
246
|
+
"""
|
|
247
|
+
config = round_config(tick)
|
|
248
|
+
price = _q(price, config.price, ROUND_HALF_UP)
|
|
249
|
+
shares = _q(size, config.size, ROUND_FLOOR)
|
|
250
|
+
usd = _fit_amount(shares * price, config)
|
|
251
|
+
if side == "BUY":
|
|
252
|
+
return to_raw(usd), to_raw(shares)
|
|
253
|
+
return to_raw(shares), to_raw(usd)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# ---------------------------------------------------------------------------
|
|
257
|
+
# Orders
|
|
258
|
+
# ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
def new_salt() -> int:
|
|
261
|
+
"""A random salt that survives a JSON number in JavaScript clients."""
|
|
262
|
+
return secrets.randbelow(2**53 - 1)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def exchange_for(neg_risk: bool) -> str:
|
|
266
|
+
return NEG_RISK_EXCHANGE if neg_risk else EXCHANGE
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def build_order(
|
|
270
|
+
*, maker: str, signer: str, token_id: str, maker_amount: int, taker_amount: int,
|
|
271
|
+
side: Literal["BUY", "SELL"], signature_type: int, timestamp_ms: int, salt: int,
|
|
272
|
+
expiration: int = 0, metadata: str = ZERO_BYTES32, builder: str | None = None,
|
|
273
|
+
) -> dict[str, Any]:
|
|
274
|
+
"""The order as the wire carries it, before the signature."""
|
|
275
|
+
return {
|
|
276
|
+
"salt": int(salt),
|
|
277
|
+
"maker": maker,
|
|
278
|
+
"signer": signer,
|
|
279
|
+
"tokenId": str(token_id),
|
|
280
|
+
"makerAmount": str(maker_amount),
|
|
281
|
+
"takerAmount": str(taker_amount),
|
|
282
|
+
"side": side,
|
|
283
|
+
"expiration": str(expiration),
|
|
284
|
+
"signatureType": int(signature_type),
|
|
285
|
+
"timestamp": str(timestamp_ms),
|
|
286
|
+
"metadata": metadata,
|
|
287
|
+
"builder": builder or ZERO_BYTES32,
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _bytes32(value: str) -> bytes:
|
|
292
|
+
return bytes.fromhex(value.removeprefix("0x").zfill(64))
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def order_typed_data(order: dict[str, Any], *, neg_risk: bool) -> dict[str, Any]:
|
|
296
|
+
return {
|
|
297
|
+
"primaryType": "Order",
|
|
298
|
+
"types": {"EIP712Domain": DOMAIN_FIELDS, "Order": ORDER_FIELDS},
|
|
299
|
+
"domain": {
|
|
300
|
+
"name": EXCHANGE_DOMAIN_NAME, "version": EXCHANGE_DOMAIN_VERSION,
|
|
301
|
+
"chainId": CHAIN_ID, "verifyingContract": exchange_for(neg_risk),
|
|
302
|
+
},
|
|
303
|
+
"message": {
|
|
304
|
+
"salt": int(order["salt"]),
|
|
305
|
+
"maker": order["maker"],
|
|
306
|
+
"signer": order["signer"],
|
|
307
|
+
"tokenId": int(order["tokenId"]),
|
|
308
|
+
"makerAmount": int(order["makerAmount"]),
|
|
309
|
+
"takerAmount": int(order["takerAmount"]),
|
|
310
|
+
"side": 0 if order["side"] == "BUY" else 1,
|
|
311
|
+
"signatureType": int(order["signatureType"]),
|
|
312
|
+
"timestamp": int(order["timestamp"]),
|
|
313
|
+
"metadata": _bytes32(order["metadata"]),
|
|
314
|
+
"builder": _bytes32(order["builder"]),
|
|
315
|
+
},
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def order_hash(order: dict[str, Any], *, neg_risk: bool) -> str:
|
|
320
|
+
"""The EIP-712 digest of the order, which is the id the CLOB gives it.
|
|
321
|
+
|
|
322
|
+
Known before the order is sent, so a journal can record the id first
|
|
323
|
+
and a lost response is recoverable by asking for that id.
|
|
324
|
+
"""
|
|
325
|
+
_, _, encode_typed_data, keccak, _ = _eth()
|
|
326
|
+
message = encode_typed_data(full_message=order_typed_data(order, neg_risk=neg_risk))
|
|
327
|
+
return "0x" + keccak(b"\x19" + message.version + message.header + message.body).hex().removeprefix("0x")
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _app_domain_separator(neg_risk: bool) -> bytes:
|
|
331
|
+
abi_encode, _, _, keccak, _ = _eth()
|
|
332
|
+
return keccak(abi_encode(
|
|
333
|
+
["bytes32", "bytes32", "bytes32", "uint256", "address"],
|
|
334
|
+
[
|
|
335
|
+
keccak(text="EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
|
|
336
|
+
keccak(text=EXCHANGE_DOMAIN_NAME), keccak(text=EXCHANGE_DOMAIN_VERSION),
|
|
337
|
+
CHAIN_ID, exchange_for(neg_risk),
|
|
338
|
+
],
|
|
339
|
+
))
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _order_contents_hash(order: dict[str, Any]) -> bytes:
|
|
343
|
+
abi_encode, _, _, keccak, _ = _eth()
|
|
344
|
+
return keccak(abi_encode(
|
|
345
|
+
["bytes32", "uint256", "address", "address", "uint256", "uint256", "uint256",
|
|
346
|
+
"uint8", "uint8", "uint256", "bytes32", "bytes32"],
|
|
347
|
+
[
|
|
348
|
+
keccak(text=ORDER_TYPE), int(order["salt"]), order["maker"], order["signer"],
|
|
349
|
+
int(order["tokenId"]), int(order["makerAmount"]), int(order["takerAmount"]),
|
|
350
|
+
0 if order["side"] == "BUY" else 1, int(order["signatureType"]), int(order["timestamp"]),
|
|
351
|
+
_bytes32(order["metadata"]), _bytes32(order["builder"]),
|
|
352
|
+
],
|
|
353
|
+
))
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def sign_order(signer: WalletSigner, order: dict[str, Any], *, neg_risk: bool) -> str:
|
|
357
|
+
"""The order's signature for its wallet type.
|
|
358
|
+
|
|
359
|
+
Types 0-2 sign the Exchange `Order` typed data directly. A Deposit Wallet
|
|
360
|
+
(type 3) signs Solady's `TypedDataSign` envelope -- the order nested in
|
|
361
|
+
the wallet's own `DepositWallet` domain -- and the result is wrapped as
|
|
362
|
+
ERC-7739 expects: inner signature, app domain separator, contents hash,
|
|
363
|
+
the contents type string and its two-byte length.
|
|
364
|
+
"""
|
|
365
|
+
if int(order["signatureType"]) != DEPOSIT_WALLET:
|
|
366
|
+
return signer.sign_typed_data(order_typed_data(order, neg_risk=neg_risk))
|
|
367
|
+
abi_encode, _, _, keccak, _ = _eth()
|
|
368
|
+
separator = _app_domain_separator(neg_risk)
|
|
369
|
+
contents = _order_contents_hash(order)
|
|
370
|
+
envelope = keccak(abi_encode(
|
|
371
|
+
["bytes32", "bytes32", "bytes32", "bytes32", "uint256", "address", "bytes32"],
|
|
372
|
+
[
|
|
373
|
+
keccak(text=(
|
|
374
|
+
"TypedDataSign(Order contents,string name,string version,uint256 chainId,"
|
|
375
|
+
"address verifyingContract,bytes32 salt)" + ORDER_TYPE
|
|
376
|
+
)),
|
|
377
|
+
contents, keccak(text="DepositWallet"), keccak(text="1"), CHAIN_ID, order["signer"],
|
|
378
|
+
b"\x00" * 32,
|
|
379
|
+
],
|
|
380
|
+
))
|
|
381
|
+
inner = signer.sign_digest(keccak(b"\x19\x01" + separator + envelope))
|
|
382
|
+
return "0x" + (
|
|
383
|
+
inner + separator + contents + ORDER_TYPE.encode() + len(ORDER_TYPE).to_bytes(2, "big")
|
|
384
|
+
).hex()
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
# ---------------------------------------------------------------------------
|
|
388
|
+
# Wallet calls
|
|
389
|
+
# ---------------------------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
def _call(signature: str, types: list[str], values: list[Any]) -> str:
|
|
392
|
+
abi_encode, _, _, keccak, _ = _eth()
|
|
393
|
+
return "0x" + (keccak(text=signature)[:4] + abi_encode(types, values)).hex()
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def approve_calldata(spender: str, amount: int = MAX_UINT256) -> str:
|
|
397
|
+
return _call("approve(address,uint256)", ["address", "uint256"], [spender, amount])
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def set_approval_for_all_calldata(operator: str, approved: bool = True) -> str:
|
|
401
|
+
return _call("setApprovalForAll(address,bool)", ["address", "bool"], [operator, approved])
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _condition(condition_id: str) -> bytes:
|
|
405
|
+
raw = condition_id.removeprefix("0x")
|
|
406
|
+
if len(raw) != 64:
|
|
407
|
+
raise InvalidOrder(f"polymarket: {condition_id!r} is not a condition id (0x + 64 hex)")
|
|
408
|
+
return bytes.fromhex(raw)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def split_calldata(condition_id: str, amount_raw: int) -> str:
|
|
412
|
+
return _call(
|
|
413
|
+
"splitPosition(address,bytes32,bytes32,uint256[],uint256)",
|
|
414
|
+
["address", "bytes32", "bytes32", "uint256[]", "uint256"],
|
|
415
|
+
[PUSD, b"\x00" * 32, _condition(condition_id), [1, 2], amount_raw],
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def merge_calldata(condition_id: str, amount_raw: int) -> str:
|
|
420
|
+
return _call(
|
|
421
|
+
"mergePositions(address,bytes32,bytes32,uint256[],uint256)",
|
|
422
|
+
["address", "bytes32", "bytes32", "uint256[]", "uint256"],
|
|
423
|
+
[PUSD, b"\x00" * 32, _condition(condition_id), [1, 2], amount_raw],
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def redeem_calldata(condition_id: str) -> str:
|
|
428
|
+
return _call(
|
|
429
|
+
"redeemPositions(address,bytes32,bytes32,uint256[])",
|
|
430
|
+
["address", "bytes32", "bytes32", "uint256[]"],
|
|
431
|
+
[PUSD, b"\x00" * 32, _condition(condition_id), [1, 2]],
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def collateral_adapter_for(neg_risk: bool) -> str:
|
|
436
|
+
return NEG_RISK_CTF_COLLATERAL_ADAPTER if neg_risk else CTF_COLLATERAL_ADAPTER
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def trading_approval_calls(spenders: list[str] | None = None) -> list[dict[str, str]]:
|
|
440
|
+
"""Unlimited pUSD and conditional-token approvals for each spender: by
|
|
441
|
+
default both exchanges (trading) and both collateral adapters (split,
|
|
442
|
+
merge, redeem)."""
|
|
443
|
+
spenders = spenders or [EXCHANGE, NEG_RISK_EXCHANGE, CTF_COLLATERAL_ADAPTER, NEG_RISK_CTF_COLLATERAL_ADAPTER]
|
|
444
|
+
calls = [{"target": PUSD, "value": "0", "data": approve_calldata(spender)} for spender in spenders]
|
|
445
|
+
calls += [{"target": CONDITIONAL_TOKENS, "value": "0", "data": set_approval_for_all_calldata(spender)} for spender in spenders]
|
|
446
|
+
return calls
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def wallet_batch_typed_data(wallet: str, nonce: int, deadline: int, calls: list[dict[str, str]]) -> dict[str, Any]:
|
|
450
|
+
"""The Deposit Wallet `Batch` the relayer executes: every call, in order,
|
|
451
|
+
authorised by one signature that expires at `deadline`."""
|
|
452
|
+
return {
|
|
453
|
+
"primaryType": "Batch",
|
|
454
|
+
"types": {
|
|
455
|
+
"EIP712Domain": DOMAIN_FIELDS,
|
|
456
|
+
"Call": [
|
|
457
|
+
{"name": "target", "type": "address"},
|
|
458
|
+
{"name": "value", "type": "uint256"},
|
|
459
|
+
{"name": "data", "type": "bytes"},
|
|
460
|
+
],
|
|
461
|
+
"Batch": [
|
|
462
|
+
{"name": "wallet", "type": "address"},
|
|
463
|
+
{"name": "nonce", "type": "uint256"},
|
|
464
|
+
{"name": "deadline", "type": "uint256"},
|
|
465
|
+
{"name": "calls", "type": "Call[]"},
|
|
466
|
+
],
|
|
467
|
+
},
|
|
468
|
+
"domain": {"name": "DepositWallet", "version": "1", "chainId": CHAIN_ID, "verifyingContract": wallet},
|
|
469
|
+
"message": {
|
|
470
|
+
"wallet": wallet,
|
|
471
|
+
"nonce": int(nonce),
|
|
472
|
+
"deadline": int(deadline),
|
|
473
|
+
"calls": [
|
|
474
|
+
{"target": c["target"], "value": int(c["value"]), "data": bytes.fromhex(c["data"].removeprefix("0x"))}
|
|
475
|
+
for c in calls
|
|
476
|
+
],
|
|
477
|
+
},
|
|
478
|
+
}
|