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.
Files changed (77) hide show
  1. synpath/__init__.py +183 -0
  2. synpath/__main__.py +66 -0
  3. synpath/base.py +723 -0
  4. synpath/bucket.py +154 -0
  5. synpath/client.py +356 -0
  6. synpath/engine/__init__.py +37 -0
  7. synpath/engine/__main__.py +354 -0
  8. synpath/engine/alerts.py +170 -0
  9. synpath/engine/engine.py +888 -0
  10. synpath/engine/eod.py +154 -0
  11. synpath/engine/events.py +140 -0
  12. synpath/engine/fair_values.py +117 -0
  13. synpath/engine/feeds.py +220 -0
  14. synpath/engine/journal.py +907 -0
  15. synpath/engine/ledger.py +353 -0
  16. synpath/engine/orders/__init__.py +42 -0
  17. synpath/engine/orders/base.py +441 -0
  18. synpath/engine/orders/day.py +72 -0
  19. synpath/engine/orders/iceberg.py +121 -0
  20. synpath/engine/orders/manager.py +223 -0
  21. synpath/engine/orders/oco.py +255 -0
  22. synpath/engine/orders/peg.py +168 -0
  23. synpath/engine/orders/routed.py +496 -0
  24. synpath/engine/orders/stop.py +240 -0
  25. synpath/engine/orders/taker.py +187 -0
  26. synpath/engine/orders/twap.py +190 -0
  27. synpath/engine/paper.py +532 -0
  28. synpath/engine/reconcile.py +279 -0
  29. synpath/engine/risk.py +403 -0
  30. synpath/engine/router.py +261 -0
  31. synpath/errors.py +98 -0
  32. synpath/history.py +71 -0
  33. synpath/hosted.py +86 -0
  34. synpath/hosted_auth.py +201 -0
  35. synpath/ids.py +61 -0
  36. synpath/kalshi.py +1378 -0
  37. synpath/matching.py +86 -0
  38. synpath/polymarket.py +1004 -0
  39. synpath/polymarket_us.py +989 -0
  40. synpath/remote.py +195 -0
  41. synpath/server/__init__.py +98 -0
  42. synpath/server/__main__.py +118 -0
  43. synpath/server/api.py +439 -0
  44. synpath/server/errors.py +87 -0
  45. synpath/server/local.py +96 -0
  46. synpath/server/models.py +75 -0
  47. synpath/server/serve.py +236 -0
  48. synpath/server/store.py +363 -0
  49. synpath/server/trading.py +764 -0
  50. synpath/trading/__init__.py +79 -0
  51. synpath/trading/__main__.py +69 -0
  52. synpath/trading/base.py +126 -0
  53. synpath/trading/credentials.py +400 -0
  54. synpath/trading/errors.py +94 -0
  55. synpath/trading/init.py +233 -0
  56. synpath/trading/instruments.py +162 -0
  57. synpath/trading/kalshi.py +957 -0
  58. synpath/trading/limiter.py +177 -0
  59. synpath/trading/money.py +172 -0
  60. synpath/trading/polymarket.py +1362 -0
  61. synpath/trading/polymarket_signing.py +478 -0
  62. synpath/trading/polymarket_us.py +705 -0
  63. synpath/trading/polymarket_us_exchange.py +825 -0
  64. synpath/trading/types.py +414 -0
  65. synpath/types.py +608 -0
  66. synpath/ws/__init__.py +55 -0
  67. synpath/ws/base.py +544 -0
  68. synpath/ws/grpc.py +578 -0
  69. synpath/ws/kalshi.py +418 -0
  70. synpath/ws/polymarket.py +430 -0
  71. synpath/ws/polymarket_us.py +299 -0
  72. synpath/ws/polymarket_us_exchange.py +754 -0
  73. synpath-0.1.0.dist-info/METADATA +224 -0
  74. synpath-0.1.0.dist-info/RECORD +77 -0
  75. synpath-0.1.0.dist-info/WHEEL +4 -0
  76. synpath-0.1.0.dist-info/entry_points.txt +2 -0
  77. 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
+ }