patly 0.1.0__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.
patly-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: patly
3
+ Version: 0.1.0
4
+ Summary: Gasless Polymarket position redemption — Redeem as a Service
5
+ License: MIT
6
+ Project-URL: Homepage, https://patly.online
7
+ Project-URL: Documentation, https://docs.patly.online
8
+ Requires-Python: >=3.11
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: requests>=2.31
11
+ Requires-Dist: web3>=6.0
12
+ Requires-Dist: eth-account>=0.10
13
+ Requires-Dist: python-dotenv>=1.0
@@ -0,0 +1,4 @@
1
+ from .client import Patly, init, redeem_if_needed
2
+
3
+ __all__ = ["Patly", "init", "redeem_if_needed"]
4
+ __version__ = "0.1.0"
@@ -0,0 +1,362 @@
1
+ """
2
+ patly/client.py
3
+
4
+ Your private key is used ONLY to:
5
+ 1. Sign the redeemPositions() transaction calldata (local, cryptographic)
6
+ 2. Sign the $0.01 USDC payment authorization (local, cryptographic)
7
+
8
+ In both cases only the SIGNATURE is transmitted — never the key itself.
9
+ This is identical to how MetaMask works. You can verify this with any
10
+ network packet inspector (e.g. Wireshark, Charles Proxy).
11
+
12
+ Usage:
13
+ from patly import Patly
14
+
15
+ p = Patly(api_key="your_key", pk="your_private_key")
16
+ p.redeem("btc-updown-15m-1234567890", "YES")
17
+ """
18
+
19
+ import base64
20
+ import json
21
+ import logging
22
+ import os
23
+ import secrets
24
+ import time
25
+ from typing import Optional
26
+
27
+ import requests
28
+ from eth_account import Account
29
+ from web3 import Web3
30
+
31
+ log = logging.getLogger("patly")
32
+
33
+ _DEFAULT_URL = "https://patly.duckdns.org"
34
+ _POLYGON_RPC = "https://polygon.drpc.org/"
35
+
36
+ _USDC = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
37
+ _CTF = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"
38
+
39
+ _USDC_DOMAIN = {
40
+ "name": "USD Coin", "version": "2",
41
+ "chainId": 137,
42
+ "verifyingContract": _USDC,
43
+ }
44
+ _TRANSFER_TYPE = {
45
+ "TransferWithAuthorization": [
46
+ {"name": "from", "type": "address"},
47
+ {"name": "to", "type": "address"},
48
+ {"name": "value", "type": "uint256"},
49
+ {"name": "validAfter", "type": "uint256"},
50
+ {"name": "validBefore", "type": "uint256"},
51
+ {"name": "nonce", "type": "bytes32"},
52
+ ]
53
+ }
54
+
55
+ _CTF_ABI = [{
56
+ "inputs": [
57
+ {"name": "collateralToken", "type": "address"},
58
+ {"name": "parentCollectionId", "type": "bytes32"},
59
+ {"name": "conditionId", "type": "bytes32"},
60
+ {"name": "indexSets", "type": "uint256[]"},
61
+ ],
62
+ "name": "redeemPositions", "outputs": [],
63
+ "stateMutability": "nonpayable", "type": "function",
64
+ }, {
65
+ "inputs": [{"name": "conditionId", "type": "bytes32"}],
66
+ "name": "payoutDenominator",
67
+ "outputs": [{"name": "", "type": "uint256"}],
68
+ "stateMutability": "view", "type": "function",
69
+ }]
70
+
71
+
72
+ class Patly:
73
+ """
74
+ Patly — gasless Polymarket position redemption.
75
+
76
+ Your private key (pk) is used only for local cryptographic signing.
77
+ It is never transmitted, logged, or stored outside your machine.
78
+
79
+ Args:
80
+ api_key: Your Patly API key (from patly.duckdns.org/register)
81
+ pk: Private key of your Polymarket wallet
82
+ url: Patly service URL (default: https://patly.duckdns.org)
83
+ """
84
+
85
+ def __init__(self, api_key: str, pk: str, url: str = _DEFAULT_URL):
86
+ self._api_key = api_key
87
+ self._account = Account.from_key(pk)
88
+ self._wallet = self._account.address
89
+ self._url = url.rstrip("/")
90
+ self._headers = {"x-api-key": api_key, "Content-Type": "application/json"}
91
+ self._w3 = Web3(Web3.HTTPProvider(_POLYGON_RPC))
92
+
93
+ # Lazy-load SDK components
94
+ self._signer = None
95
+ self._config = None
96
+ self._safe_fn = None
97
+ self._derive = None
98
+ self._build_fn = None
99
+
100
+ def _init_sdk(self):
101
+ """Lazy-load the Safe signing SDK."""
102
+ if self._signer is not None:
103
+ return
104
+ from py_builder_relayer_client.signer import Signer
105
+ from py_builder_relayer_client.config import get_contract_config
106
+ from py_builder_relayer_client.builder.safe import build_safe_transaction_request
107
+ from py_builder_relayer_client.builder.derive import derive
108
+ from py_builder_relayer_client.models import (
109
+ SafeTransaction, SafeTransactionArgs, OperationType
110
+ )
111
+ self._signer = Signer(self._account.key.hex(), 137)
112
+ self._config = get_contract_config(137)
113
+ self._build_fn = build_safe_transaction_request
114
+ self._derive = derive
115
+ self._SafeTx = SafeTransaction
116
+ self._SafeArgs = SafeTransactionArgs
117
+ self._OpType = OperationType
118
+
119
+ def _get_nonce(self) -> int:
120
+ """Fetch current Safe nonce from Patly service."""
121
+ r = requests.get(
122
+ f"{self._url}/nonce",
123
+ params={"wallet": self._wallet},
124
+ headers=self._headers,
125
+ timeout=10,
126
+ )
127
+ if not r.ok:
128
+ raise RuntimeError(f"Could not get nonce: {r.text}")
129
+ return r.json().get("nonce", 0)
130
+
131
+ def _fetch_condition_id(self, slug: str) -> Optional[str]:
132
+ """Fetch conditionId for a market slug."""
133
+ r = requests.get(
134
+ f"{self._url}/condition",
135
+ params={"slug": slug},
136
+ headers=self._headers,
137
+ timeout=10,
138
+ )
139
+ if not r.ok:
140
+ return None
141
+ return r.json().get("condition_id")
142
+
143
+ def _is_resolved(self, condition_id: str) -> bool:
144
+ """Check on-chain if market is resolved."""
145
+ try:
146
+ ctf = self._w3.eth.contract(
147
+ address=Web3.to_checksum_address(_CTF), abi=_CTF_ABI
148
+ )
149
+ cid = bytes.fromhex(condition_id.replace("0x", ""))
150
+ return ctf.functions.payoutDenominator(cid).call() > 0
151
+ except Exception:
152
+ return False
153
+
154
+ def _build_signed_tx(self, condition_id: str, won_side: str) -> dict:
155
+ """
156
+ Build and sign a redeemPositions() Safe transaction.
157
+ Signing happens entirely on this machine using the provided PK.
158
+ Only the resulting signature is included in the output — not the PK.
159
+ """
160
+ self._init_sdk()
161
+
162
+ ctf = self._w3.eth.contract(
163
+ address=Web3.to_checksum_address(_CTF), abi=_CTF_ABI
164
+ )
165
+ cid_bytes = bytes.fromhex(condition_id.replace("0x", ""))
166
+ index_sets = [1] if won_side == "YES" else [2]
167
+
168
+ calldata = ctf.encode_abi(
169
+ "redeemPositions",
170
+ args=[Web3.to_checksum_address(_USDC), b'\x00' * 32, cid_bytes, index_sets]
171
+ )
172
+
173
+ nonce = self._get_nonce()
174
+
175
+ safe_tx = self._SafeTx(
176
+ to=_CTF,
177
+ operation=self._OpType.Call,
178
+ data=calldata,
179
+ value="0",
180
+ )
181
+ safe_args = self._SafeArgs(
182
+ from_address=self._signer.address(),
183
+ nonce=nonce,
184
+ chain_id=137,
185
+ transactions=[safe_tx],
186
+ )
187
+ tx_request = self._build_fn(
188
+ signer=self._signer,
189
+ args=safe_args,
190
+ config=self._config,
191
+ metadata=f"Redeem {slug}",
192
+ )
193
+ return tx_request.to_dict()
194
+
195
+ def _sign_payment(self, pay_to: str, amount: int) -> str:
196
+ """
197
+ Sign a USDC transferWithAuthorization (EIP-3009).
198
+ Signs locally, returns base64-encoded payment header.
199
+ PK is used only for signing — not transmitted.
200
+ """
201
+ now = int(time.time())
202
+ nonce = secrets.token_bytes(32)
203
+ msg = {
204
+ "from": self._wallet,
205
+ "to": Web3.to_checksum_address(pay_to),
206
+ "value": amount,
207
+ "validAfter": now - 60,
208
+ "validBefore": now + 300,
209
+ "nonce": nonce,
210
+ }
211
+ signed = self._account.sign_typed_data(_USDC_DOMAIN, _TRANSFER_TYPE, msg)
212
+
213
+ payload = {
214
+ "scheme": "exact",
215
+ "network": "eip155:137",
216
+ "payload": {
217
+ "from": self._wallet,
218
+ "to": Web3.to_checksum_address(pay_to),
219
+ "value": str(amount),
220
+ "validAfter": str(msg["validAfter"]),
221
+ "validBefore": str(msg["validBefore"]),
222
+ "nonce": "0x" + nonce.hex(),
223
+ "v": signed.v,
224
+ "r": "0x" + signed.r.to_bytes(32, "big").hex(),
225
+ "s": "0x" + signed.s.to_bytes(32, "big").hex(),
226
+ }
227
+ }
228
+ return base64.b64encode(json.dumps(payload).encode()).decode()
229
+
230
+ def _handle_402(self, response: requests.Response, body: dict) -> requests.Response:
231
+ """Parse 402 response and retry with payment signature."""
232
+ pr_raw = (
233
+ response.headers.get("X-PAYMENT-REQUIRED") or
234
+ response.headers.get("PAYMENT-REQUIRED", "")
235
+ )
236
+ try:
237
+ pr = json.loads(pr_raw)
238
+ except Exception:
239
+ pr = json.loads(base64.b64decode(pr_raw))
240
+
241
+ accepts = pr.get("accepts", [])
242
+ option = next(
243
+ (a for a in accepts if "137" in str(a.get("network", ""))),
244
+ accepts[0] if accepts else None
245
+ )
246
+ if not option:
247
+ raise RuntimeError("No Polygon payment option in 402 response")
248
+
249
+ pay_to = option.get("payTo") or option.get("pay_to", "")
250
+ raw = option.get("amount") or option.get("price", "0.01")
251
+ amount = int(float(str(raw).replace("$", "")) * 1_000_000)
252
+
253
+ payment_header = self._sign_payment(pay_to, amount)
254
+
255
+ return requests.post(
256
+ f"{self._url}/redeem",
257
+ headers={**self._headers, "X-PAYMENT": payment_header},
258
+ json=body,
259
+ timeout=30,
260
+ )
261
+
262
+ # ── Public API ────────────────────────────────────────────────────────────
263
+
264
+ def redeem(self, slug: str, won_side: str) -> dict:
265
+ """
266
+ Redeem a winning Polymarket position.
267
+
268
+ Signs the transaction locally using your private key.
269
+ Your private key is NEVER transmitted — only the signature is sent.
270
+ Costs $0.01 USDC, paid automatically.
271
+
272
+ Args:
273
+ slug: Market slug e.g. "btc-updown-15m-1234567890"
274
+ won_side: "YES" or "NO"
275
+
276
+ Returns:
277
+ {"status": "redeemed", "tx_hash": "0x...", "polygonscan": "..."}
278
+ """
279
+ won_side = won_side.upper()
280
+
281
+ # Get conditionId
282
+ condition_id = self._fetch_condition_id(slug)
283
+ if not condition_id:
284
+ raise ValueError(f"Market not found or conditionId unavailable: {slug}")
285
+
286
+ # Check resolution
287
+ if not self._is_resolved(condition_id):
288
+ raise ValueError(f"Market not yet resolved on-chain: {slug}")
289
+
290
+ # Build and sign tx locally
291
+ signed_tx = self._build_signed_tx(condition_id, won_side)
292
+
293
+ body = {"signed_tx": signed_tx, "slug": slug, "won_side": won_side}
294
+
295
+ # POST to Patly
296
+ r = requests.post(f"{self._url}/redeem", headers=self._headers, json=body, timeout=30)
297
+
298
+ if r.status_code == 200:
299
+ return r.json()
300
+
301
+ if r.status_code == 402:
302
+ r2 = self._handle_402(r, body)
303
+ if r2.status_code == 200:
304
+ return r2.json()
305
+ raise RuntimeError(f"Payment failed: {r2.status_code} {r2.text[:120]}")
306
+
307
+ if r.status_code == 409:
308
+ return {"status": "already_redeemed", "slug": slug}
309
+
310
+ raise RuntimeError(f"Redeem failed: {r.status_code} {r.text[:120]}")
311
+
312
+ def status(self) -> dict:
313
+ """Check your account status and redeem stats."""
314
+ r = requests.get(f"{self._url}/status", headers=self._headers, timeout=10)
315
+ r.raise_for_status()
316
+ return r.json()
317
+
318
+ def history(self, limit: int = 50) -> list:
319
+ """Get your redeem history."""
320
+ r = requests.get(
321
+ f"{self._url}/redeems",
322
+ headers=self._headers,
323
+ params={"limit": limit},
324
+ timeout=10,
325
+ )
326
+ r.raise_for_status()
327
+ return r.json()
328
+
329
+
330
+ # ── Drop-in for bot.py ────────────────────────────────────────────────────────
331
+
332
+ _instance: Optional[Patly] = None
333
+
334
+
335
+ def init(api_key: str, pk: str, url: str = _DEFAULT_URL):
336
+ """
337
+ Initialize Patly. Call once at bot startup.
338
+
339
+ Example:
340
+ import patly
341
+ patly.init(api_key=os.getenv("PATLY_API_KEY"), pk=os.getenv("PK"))
342
+ """
343
+ global _instance
344
+ _instance = Patly(api_key=api_key, pk=pk, url=url)
345
+ log.info(f"[patly] initialized | wallet={_instance._wallet}")
346
+
347
+
348
+ def redeem_if_needed(slug: str, won: bool, won_side: str = None):
349
+ """
350
+ Drop-in replacement for redeem_if_needed() in live.py.
351
+ Identical signature — no other changes needed.
352
+ """
353
+ if not won or not won_side:
354
+ return
355
+ if _instance is None:
356
+ log.error("[patly] Call patly.init() at startup before using redeem_if_needed()")
357
+ return
358
+ try:
359
+ result = _instance.redeem(slug, won_side)
360
+ log.info(f"[patly] ✅ {slug}: {result.get('tx_hash', result.get('status', ''))}")
361
+ except Exception as e:
362
+ log.error(f"[patly] ❌ {slug}: {e}")
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: patly
3
+ Version: 0.1.0
4
+ Summary: Gasless Polymarket position redemption — Redeem as a Service
5
+ License: MIT
6
+ Project-URL: Homepage, https://patly.online
7
+ Project-URL: Documentation, https://docs.patly.online
8
+ Requires-Python: >=3.11
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: requests>=2.31
11
+ Requires-Dist: web3>=6.0
12
+ Requires-Dist: eth-account>=0.10
13
+ Requires-Dist: python-dotenv>=1.0
@@ -0,0 +1,8 @@
1
+ pyproject.toml
2
+ patly/__init__.py
3
+ patly/client.py
4
+ patly.egg-info/PKG-INFO
5
+ patly.egg-info/SOURCES.txt
6
+ patly.egg-info/dependency_links.txt
7
+ patly.egg-info/requires.txt
8
+ patly.egg-info/top_level.txt
@@ -0,0 +1,4 @@
1
+ requests>=2.31
2
+ web3>=6.0
3
+ eth-account>=0.10
4
+ python-dotenv>=1.0
@@ -0,0 +1 @@
1
+ patly
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "patly"
7
+ version = "0.1.0"
8
+ description = "Gasless Polymarket position redemption — Redeem as a Service"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "MIT" }
12
+ dependencies = [
13
+ "requests>=2.31",
14
+ "web3>=6.0",
15
+ "eth-account>=0.10",
16
+ "python-dotenv>=1.0",
17
+ ]
18
+
19
+ [project.urls]
20
+ Homepage = "https://patly.online"
21
+ Documentation = "https://docs.patly.online"
22
+
23
+ [tool.setuptools.packages.find]
24
+ where = ["."]
25
+ include = ["patly*"]
patly-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+