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 +13 -0
- patly-0.1.0/patly/__init__.py +4 -0
- patly-0.1.0/patly/client.py +362 -0
- patly-0.1.0/patly.egg-info/PKG-INFO +13 -0
- patly-0.1.0/patly.egg-info/SOURCES.txt +8 -0
- patly-0.1.0/patly.egg-info/dependency_links.txt +1 -0
- patly-0.1.0/patly.egg-info/requires.txt +4 -0
- patly-0.1.0/patly.egg-info/top_level.txt +1 -0
- patly-0.1.0/pyproject.toml +25 -0
- patly-0.1.0/setup.cfg +4 -0
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,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 @@
|
|
|
1
|
+
|
|
@@ -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