oneshot-python 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.
- oneshot/__init__.py +23 -0
- oneshot/_errors.py +48 -0
- oneshot/client.py +334 -0
- oneshot/x402.py +142 -0
- oneshot_python-0.1.0.dist-info/METADATA +14 -0
- oneshot_python-0.1.0.dist-info/RECORD +7 -0
- oneshot_python-0.1.0.dist-info/WHEEL +4 -0
oneshot/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""oneshot-python — Core Python SDK for the OneShot API."""
|
|
2
|
+
|
|
3
|
+
from oneshot._errors import (
|
|
4
|
+
ContentBlockedError,
|
|
5
|
+
JobError,
|
|
6
|
+
JobTimeoutError,
|
|
7
|
+
OneShotError,
|
|
8
|
+
ToolError,
|
|
9
|
+
ValidationError,
|
|
10
|
+
)
|
|
11
|
+
from oneshot.client import OneShotClient
|
|
12
|
+
from oneshot.x402 import sign_payment_authorization
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"OneShotClient",
|
|
16
|
+
"OneShotError",
|
|
17
|
+
"ToolError",
|
|
18
|
+
"JobError",
|
|
19
|
+
"JobTimeoutError",
|
|
20
|
+
"ValidationError",
|
|
21
|
+
"ContentBlockedError",
|
|
22
|
+
"sign_payment_authorization",
|
|
23
|
+
]
|
oneshot/_errors.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Error classes mirroring the TypeScript OneShot SDK error hierarchy."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class OneShotError(Exception):
|
|
5
|
+
"""Base error for all OneShot operations."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ToolError(OneShotError):
|
|
9
|
+
"""HTTP-level error from the OneShot API."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, message: str, status_code: int, response_body: str) -> None:
|
|
12
|
+
super().__init__(message)
|
|
13
|
+
self.status_code = status_code
|
|
14
|
+
self.response_body = response_body
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class JobError(OneShotError):
|
|
18
|
+
"""Async job completed with an error."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, message: str, job_id: str, job_error: str) -> None:
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.job_id = job_id
|
|
23
|
+
self.job_error = job_error
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class JobTimeoutError(OneShotError):
|
|
27
|
+
"""Async job exceeded the polling timeout."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, job_id: str, elapsed_ms: int) -> None:
|
|
30
|
+
super().__init__(f"Job {job_id} timed out after {elapsed_ms / 1000}s")
|
|
31
|
+
self.job_id = job_id
|
|
32
|
+
self.elapsed_ms = elapsed_ms
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ValidationError(OneShotError):
|
|
36
|
+
"""Client-side input validation failure."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, message: str, field: str) -> None:
|
|
39
|
+
super().__init__(message)
|
|
40
|
+
self.field = field
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ContentBlockedError(OneShotError):
|
|
44
|
+
"""Content safety filter rejected the request."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, message: str, categories: list[str]) -> None:
|
|
47
|
+
super().__init__(message)
|
|
48
|
+
self.categories = categories
|
oneshot/client.py
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"""OneShotClient — HTTP client with x402 payment flow.
|
|
2
|
+
|
|
3
|
+
Ports the ``OneShot`` class from ``libs/agent-sdk/src/index.ts``.
|
|
4
|
+
Handles: quote -> pay -> poll lifecycle for paid tools, and simple
|
|
5
|
+
GET/POST for free endpoints.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import time
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
from eth_account import Account
|
|
17
|
+
|
|
18
|
+
from oneshot._errors import (
|
|
19
|
+
ContentBlockedError,
|
|
20
|
+
JobError,
|
|
21
|
+
JobTimeoutError,
|
|
22
|
+
OneShotError,
|
|
23
|
+
ToolError,
|
|
24
|
+
ValidationError,
|
|
25
|
+
)
|
|
26
|
+
from oneshot.x402 import sign_payment_authorization
|
|
27
|
+
|
|
28
|
+
SDK_VERSION = "0.1.0"
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Environment configuration (mirrors TS SDK)
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
TEST_ENV = {
|
|
35
|
+
"base_url": "https://api-stg.oneshotagent.com",
|
|
36
|
+
"rpc_url": "https://sepolia.base.org",
|
|
37
|
+
"chain_id": 84532,
|
|
38
|
+
"usdc_address": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
PROD_ENV = {
|
|
42
|
+
"base_url": "https://win.oneshotagent.com",
|
|
43
|
+
"rpc_url": "https://mainnet.base.org",
|
|
44
|
+
"chain_id": 8453,
|
|
45
|
+
"usdc_address": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
_POLL_INTERVAL = 2.0 # seconds
|
|
49
|
+
_MAX_POLL_RETRIES = 3
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class OneShotClient:
|
|
53
|
+
"""Synchronous + async HTTP client for the OneShot API.
|
|
54
|
+
|
|
55
|
+
Supports the full x402 quote-then-pay lifecycle for paid tools and
|
|
56
|
+
simple requests for free endpoints.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
private_key: str,
|
|
62
|
+
*,
|
|
63
|
+
test_mode: bool = True,
|
|
64
|
+
base_url: Optional[str] = None,
|
|
65
|
+
debug: bool = False,
|
|
66
|
+
) -> None:
|
|
67
|
+
env = TEST_ENV if test_mode else PROD_ENV
|
|
68
|
+
self.base_url = base_url or env["base_url"]
|
|
69
|
+
self.chain_id: int = env["chain_id"]
|
|
70
|
+
self.usdc_address: str = env["usdc_address"]
|
|
71
|
+
self.test_mode = test_mode
|
|
72
|
+
self.debug = debug
|
|
73
|
+
|
|
74
|
+
# Derive wallet address from private key
|
|
75
|
+
self._private_key = private_key
|
|
76
|
+
acct = Account.from_key(private_key)
|
|
77
|
+
self.address: str = acct.address
|
|
78
|
+
|
|
79
|
+
# ------------------------------------------------------------------
|
|
80
|
+
# Headers
|
|
81
|
+
# ------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
def _headers(self) -> dict[str, str]:
|
|
84
|
+
return {
|
|
85
|
+
"Content-Type": "application/json",
|
|
86
|
+
"X-Agent-ID": self.address,
|
|
87
|
+
"X-OneShot-SDK-Version": SDK_VERSION,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
def _log(self, msg: str) -> None:
|
|
91
|
+
if self.debug:
|
|
92
|
+
print(f"[OneShot] {msg}")
|
|
93
|
+
|
|
94
|
+
# ------------------------------------------------------------------
|
|
95
|
+
# Paid tool flow (POST -> 402 -> sign -> POST w/ payment -> poll)
|
|
96
|
+
# ------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
def call_tool(
|
|
99
|
+
self,
|
|
100
|
+
endpoint: str,
|
|
101
|
+
payload: dict[str, Any],
|
|
102
|
+
*,
|
|
103
|
+
max_cost: Optional[float] = None,
|
|
104
|
+
timeout_sec: int = 120,
|
|
105
|
+
) -> Any:
|
|
106
|
+
"""Execute a paid tool call (blocking). Handles the full x402 flow."""
|
|
107
|
+
return asyncio.get_event_loop().run_until_complete(
|
|
108
|
+
self.acall_tool(endpoint, payload, max_cost=max_cost, timeout_sec=timeout_sec)
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
async def acall_tool(
|
|
112
|
+
self,
|
|
113
|
+
endpoint: str,
|
|
114
|
+
payload: dict[str, Any],
|
|
115
|
+
*,
|
|
116
|
+
max_cost: Optional[float] = None,
|
|
117
|
+
timeout_sec: int = 120,
|
|
118
|
+
) -> Any:
|
|
119
|
+
"""Execute a paid tool call (async). Handles the full x402 flow."""
|
|
120
|
+
url = f"{self.base_url}{endpoint}"
|
|
121
|
+
|
|
122
|
+
async with httpx.AsyncClient(timeout=httpx.Timeout(120.0)) as client:
|
|
123
|
+
# Step 1 — Initial POST (expect 402 for paid tools)
|
|
124
|
+
resp = await client.post(url, headers=self._headers(), json=payload)
|
|
125
|
+
|
|
126
|
+
# Handle validation / content-blocked errors
|
|
127
|
+
if resp.status_code == 400:
|
|
128
|
+
data = resp.json()
|
|
129
|
+
err_type = data.get("error", "")
|
|
130
|
+
if err_type == "content_blocked":
|
|
131
|
+
raise ContentBlockedError(
|
|
132
|
+
data.get("message", "Content blocked"),
|
|
133
|
+
data.get("categories", []),
|
|
134
|
+
)
|
|
135
|
+
raise ValidationError(
|
|
136
|
+
data.get("message", "Invalid request"), "request"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# If not 402, this might be a free tool that returned directly
|
|
140
|
+
if resp.status_code != 402:
|
|
141
|
+
resp.raise_for_status()
|
|
142
|
+
result = resp.json()
|
|
143
|
+
# Handle async jobs
|
|
144
|
+
if isinstance(result, dict) and result.get("request_id") and result.get("status") in (
|
|
145
|
+
"pending",
|
|
146
|
+
"processing",
|
|
147
|
+
):
|
|
148
|
+
return await self._poll_job(client, result["request_id"], timeout_sec)
|
|
149
|
+
return result.get("data", result)
|
|
150
|
+
|
|
151
|
+
# Step 2 — Parse 402 response
|
|
152
|
+
quote_data = resp.json()
|
|
153
|
+
payment_request = quote_data["payment_request"]
|
|
154
|
+
context = quote_data.get("context", {})
|
|
155
|
+
quote_id = context.get("quote_id")
|
|
156
|
+
|
|
157
|
+
# Check max_cost
|
|
158
|
+
total = context.get("total") or context.get("pricing", {}).get("total")
|
|
159
|
+
if max_cost is not None and total is not None:
|
|
160
|
+
if float(total) > max_cost:
|
|
161
|
+
raise OneShotError(
|
|
162
|
+
f"Quote ${total} exceeds max_cost ${max_cost}"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
self._log(f"Payment required: {payment_request['amount']} USDC")
|
|
166
|
+
|
|
167
|
+
# Step 3 — Sign x402 payment
|
|
168
|
+
auth = sign_payment_authorization(
|
|
169
|
+
private_key=self._private_key,
|
|
170
|
+
from_address=self.address,
|
|
171
|
+
to_address=payment_request["recipient"],
|
|
172
|
+
amount=payment_request["amount"],
|
|
173
|
+
token_address=payment_request["token_address"],
|
|
174
|
+
chain_id=payment_request["chain_id"],
|
|
175
|
+
network=f"eip155:{payment_request['chain_id']}",
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# Step 4 — Re-POST with payment headers
|
|
179
|
+
headers = {
|
|
180
|
+
**self._headers(),
|
|
181
|
+
"x-payment": json.dumps(auth),
|
|
182
|
+
}
|
|
183
|
+
if quote_id:
|
|
184
|
+
headers["x-quote-id"] = quote_id
|
|
185
|
+
|
|
186
|
+
resp2 = await client.post(url, headers=headers, json=payload)
|
|
187
|
+
|
|
188
|
+
if resp2.status_code not in (200, 201, 202):
|
|
189
|
+
raise ToolError(
|
|
190
|
+
"Tool request failed after payment",
|
|
191
|
+
resp2.status_code,
|
|
192
|
+
resp2.text,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
result = resp2.json()
|
|
196
|
+
|
|
197
|
+
# Step 5 — Poll if async job
|
|
198
|
+
if isinstance(result, dict) and result.get("request_id") and result.get("status") in (
|
|
199
|
+
"pending",
|
|
200
|
+
"processing",
|
|
201
|
+
):
|
|
202
|
+
self._log(f"Job queued: {result['request_id']}")
|
|
203
|
+
return await self._poll_job(client, result["request_id"], timeout_sec)
|
|
204
|
+
|
|
205
|
+
return result.get("data", result)
|
|
206
|
+
|
|
207
|
+
# ------------------------------------------------------------------
|
|
208
|
+
# Free endpoint helpers
|
|
209
|
+
# ------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
def call_free_get(
|
|
212
|
+
self,
|
|
213
|
+
endpoint: str,
|
|
214
|
+
params: Optional[dict[str, str]] = None,
|
|
215
|
+
) -> Any:
|
|
216
|
+
"""GET a free endpoint (blocking)."""
|
|
217
|
+
return asyncio.get_event_loop().run_until_complete(
|
|
218
|
+
self.acall_free_get(endpoint, params)
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
async def acall_free_get(
|
|
222
|
+
self,
|
|
223
|
+
endpoint: str,
|
|
224
|
+
params: Optional[dict[str, str]] = None,
|
|
225
|
+
) -> Any:
|
|
226
|
+
"""GET a free endpoint (async)."""
|
|
227
|
+
url = f"{self.base_url}{endpoint}"
|
|
228
|
+
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
|
|
229
|
+
resp = await client.get(url, headers=self._headers(), params=params)
|
|
230
|
+
if not resp.is_success:
|
|
231
|
+
raise ToolError(f"GET {endpoint} failed", resp.status_code, resp.text)
|
|
232
|
+
return resp.json()
|
|
233
|
+
|
|
234
|
+
def call_free_post(
|
|
235
|
+
self,
|
|
236
|
+
endpoint: str,
|
|
237
|
+
payload: Optional[dict[str, Any]] = None,
|
|
238
|
+
) -> Any:
|
|
239
|
+
"""POST to a free endpoint (blocking)."""
|
|
240
|
+
return asyncio.get_event_loop().run_until_complete(
|
|
241
|
+
self.acall_free_post(endpoint, payload)
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
async def acall_free_post(
|
|
245
|
+
self,
|
|
246
|
+
endpoint: str,
|
|
247
|
+
payload: Optional[dict[str, Any]] = None,
|
|
248
|
+
) -> Any:
|
|
249
|
+
"""POST to a free endpoint (async)."""
|
|
250
|
+
url = f"{self.base_url}{endpoint}"
|
|
251
|
+
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
|
|
252
|
+
resp = await client.post(url, headers=self._headers(), json=payload or {})
|
|
253
|
+
if not resp.is_success:
|
|
254
|
+
raise ToolError(f"POST {endpoint} failed", resp.status_code, resp.text)
|
|
255
|
+
return resp.json()
|
|
256
|
+
|
|
257
|
+
def call_free_patch(
|
|
258
|
+
self,
|
|
259
|
+
endpoint: str,
|
|
260
|
+
payload: Optional[dict[str, Any]] = None,
|
|
261
|
+
) -> Any:
|
|
262
|
+
"""PATCH a free endpoint (blocking)."""
|
|
263
|
+
return asyncio.get_event_loop().run_until_complete(
|
|
264
|
+
self.acall_free_patch(endpoint, payload)
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
async def acall_free_patch(
|
|
268
|
+
self,
|
|
269
|
+
endpoint: str,
|
|
270
|
+
payload: Optional[dict[str, Any]] = None,
|
|
271
|
+
) -> Any:
|
|
272
|
+
"""PATCH a free endpoint (async)."""
|
|
273
|
+
url = f"{self.base_url}{endpoint}"
|
|
274
|
+
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
|
|
275
|
+
resp = await client.patch(url, headers=self._headers(), json=payload or {})
|
|
276
|
+
if not resp.is_success:
|
|
277
|
+
raise ToolError(f"PATCH {endpoint} failed", resp.status_code, resp.text)
|
|
278
|
+
# PATCH may return empty body (204)
|
|
279
|
+
if resp.status_code == 204 or not resp.text:
|
|
280
|
+
return {"success": True}
|
|
281
|
+
return resp.json()
|
|
282
|
+
|
|
283
|
+
# ------------------------------------------------------------------
|
|
284
|
+
# Job polling
|
|
285
|
+
# ------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
async def _poll_job(
|
|
288
|
+
self,
|
|
289
|
+
client: httpx.AsyncClient,
|
|
290
|
+
request_id: str,
|
|
291
|
+
timeout_sec: int,
|
|
292
|
+
) -> Any:
|
|
293
|
+
start = time.monotonic()
|
|
294
|
+
retries = 0
|
|
295
|
+
|
|
296
|
+
while (time.monotonic() - start) < timeout_sec:
|
|
297
|
+
try:
|
|
298
|
+
resp = await client.get(
|
|
299
|
+
f"{self.base_url}/v1/requests/{request_id}",
|
|
300
|
+
headers=self._headers(),
|
|
301
|
+
)
|
|
302
|
+
if not resp.is_success:
|
|
303
|
+
raise ToolError(
|
|
304
|
+
"Failed to check job status",
|
|
305
|
+
resp.status_code,
|
|
306
|
+
resp.text,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
job = resp.json()
|
|
310
|
+
|
|
311
|
+
if job.get("status") == "completed":
|
|
312
|
+
self._log("Job completed")
|
|
313
|
+
return job.get("result", job)
|
|
314
|
+
|
|
315
|
+
if job.get("status") == "failed":
|
|
316
|
+
raise JobError(
|
|
317
|
+
f"Job failed: {job.get('error', 'Unknown')}",
|
|
318
|
+
request_id,
|
|
319
|
+
str(job.get("error", "Unknown")),
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
retries = 0
|
|
323
|
+
await asyncio.sleep(_POLL_INTERVAL)
|
|
324
|
+
|
|
325
|
+
except (OneShotError, JobError):
|
|
326
|
+
raise
|
|
327
|
+
except Exception:
|
|
328
|
+
retries += 1
|
|
329
|
+
if retries > _MAX_POLL_RETRIES:
|
|
330
|
+
raise
|
|
331
|
+
await asyncio.sleep(_POLL_INTERVAL * (2 ** (retries - 1)))
|
|
332
|
+
|
|
333
|
+
elapsed_ms = int((time.monotonic() - start) * 1000)
|
|
334
|
+
raise JobTimeoutError(request_id, elapsed_ms)
|
oneshot/x402.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""EIP-712 payment signing for x402 protocol (USDC TransferWithAuthorization).
|
|
2
|
+
|
|
3
|
+
Ports the signing logic from:
|
|
4
|
+
- libs/agent-sdk/src/index.ts (L1511-1568)
|
|
5
|
+
- apps/api-service/src/services/x402-facilitator.ts (L28-29) for domain names
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any, TypedDict
|
|
13
|
+
|
|
14
|
+
from eth_account import Account
|
|
15
|
+
from eth_account.messages import encode_typed_data
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PaymentSignature(TypedDict):
|
|
19
|
+
v: int
|
|
20
|
+
r: str
|
|
21
|
+
s: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PaymentAuthorization(TypedDict):
|
|
25
|
+
"""Mirrors the TS SDK PaymentAuthorization interface."""
|
|
26
|
+
|
|
27
|
+
from_address: str # 'from' is reserved in Python
|
|
28
|
+
to: str
|
|
29
|
+
value: str
|
|
30
|
+
validAfter: int
|
|
31
|
+
validBefore: int
|
|
32
|
+
nonce: str
|
|
33
|
+
signature: PaymentSignature
|
|
34
|
+
network: str
|
|
35
|
+
token: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _get_usdc_domain_name(chain_id: int) -> str:
|
|
39
|
+
"""Return the EIP-712 domain name for USDC on the given chain.
|
|
40
|
+
|
|
41
|
+
Base Sepolia (84532) uses "USDC", Base Mainnet (8453) uses "USD Coin".
|
|
42
|
+
Source: apps/api-service/src/services/x402-facilitator.ts:28-29
|
|
43
|
+
"""
|
|
44
|
+
return "USDC" if chain_id == 84532 else "USD Coin"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def sign_payment_authorization(
|
|
48
|
+
*,
|
|
49
|
+
private_key: str,
|
|
50
|
+
from_address: str,
|
|
51
|
+
to_address: str,
|
|
52
|
+
amount: str,
|
|
53
|
+
token_address: str,
|
|
54
|
+
chain_id: int,
|
|
55
|
+
network: str,
|
|
56
|
+
) -> dict[str, Any]:
|
|
57
|
+
"""Create an EIP-712 TransferWithAuthorization signature for USDC.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
private_key: Hex-encoded private key (with or without 0x prefix).
|
|
61
|
+
from_address: Sender wallet address.
|
|
62
|
+
to_address: Recipient (merchant) address.
|
|
63
|
+
amount: Amount in USDC human-readable units (e.g. "1.50").
|
|
64
|
+
token_address: USDC contract address.
|
|
65
|
+
chain_id: EVM chain ID (84532 for Base Sepolia, 8453 for Base Mainnet).
|
|
66
|
+
network: CAIP-2 network string (e.g. "eip155:84532").
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Payment authorization dict ready to be JSON-serialized as x-payment header.
|
|
70
|
+
"""
|
|
71
|
+
# Convert human-readable amount to USDC base units (6 decimals)
|
|
72
|
+
value = _parse_usdc_amount(amount)
|
|
73
|
+
now = int(time.time())
|
|
74
|
+
nonce = "0x" + os.urandom(32).hex()
|
|
75
|
+
|
|
76
|
+
domain_data = {
|
|
77
|
+
"name": _get_usdc_domain_name(chain_id),
|
|
78
|
+
"version": "2",
|
|
79
|
+
"chainId": chain_id,
|
|
80
|
+
"verifyingContract": token_address,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
message_types = {
|
|
84
|
+
"TransferWithAuthorization": [
|
|
85
|
+
{"name": "from", "type": "address"},
|
|
86
|
+
{"name": "to", "type": "address"},
|
|
87
|
+
{"name": "value", "type": "uint256"},
|
|
88
|
+
{"name": "validAfter", "type": "uint256"},
|
|
89
|
+
{"name": "validBefore", "type": "uint256"},
|
|
90
|
+
{"name": "nonce", "type": "bytes32"},
|
|
91
|
+
],
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
message_data = {
|
|
95
|
+
"from": from_address,
|
|
96
|
+
"to": to_address,
|
|
97
|
+
"value": value,
|
|
98
|
+
"validAfter": now - 300,
|
|
99
|
+
"validBefore": now + 3600,
|
|
100
|
+
"nonce": bytes.fromhex(nonce[2:]),
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
signable = encode_typed_data(
|
|
104
|
+
domain_data=domain_data,
|
|
105
|
+
message_types=message_types,
|
|
106
|
+
message_data=message_data,
|
|
107
|
+
)
|
|
108
|
+
signed = Account.sign_message(signable, private_key=private_key)
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
"from": from_address,
|
|
112
|
+
"to": to_address,
|
|
113
|
+
"value": str(value),
|
|
114
|
+
"validAfter": now - 300,
|
|
115
|
+
"validBefore": now + 3600,
|
|
116
|
+
"nonce": nonce,
|
|
117
|
+
"signature": {
|
|
118
|
+
"v": signed.v,
|
|
119
|
+
"r": hex(signed.r),
|
|
120
|
+
"s": hex(signed.s),
|
|
121
|
+
},
|
|
122
|
+
"network": network,
|
|
123
|
+
"token": token_address,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _parse_usdc_amount(amount: str) -> int:
|
|
128
|
+
"""Convert a human-readable USDC amount to base units (6 decimals).
|
|
129
|
+
|
|
130
|
+
Examples:
|
|
131
|
+
"1.50" -> 1500000
|
|
132
|
+
"10" -> 10000000
|
|
133
|
+
"0.01" -> 10000
|
|
134
|
+
"""
|
|
135
|
+
parts = amount.split(".")
|
|
136
|
+
whole = int(parts[0])
|
|
137
|
+
if len(parts) == 1:
|
|
138
|
+
return whole * 1_000_000
|
|
139
|
+
|
|
140
|
+
# Pad or truncate fractional part to 6 digits
|
|
141
|
+
frac_str = parts[1][:6].ljust(6, "0")
|
|
142
|
+
return whole * 1_000_000 + int(frac_str)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: oneshot-python
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Core Python SDK for the OneShot API — HTTP client with x402 payment handling
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Requires-Dist: eth-account>=0.13.0
|
|
8
|
+
Requires-Dist: httpx>=0.27.0
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
11
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
12
|
+
Description-Content-Type: text/plain
|
|
13
|
+
|
|
14
|
+
Core Python SDK for the OneShot API
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
oneshot/__init__.py,sha256=yDrp5FGVWFLuMZUBzS2exrdizzBAWXZXS1VVoesmUuM,498
|
|
2
|
+
oneshot/_errors.py,sha256=hbDMBck3JU3pFlNvSKMWZ4j1uFsyTQNciFVjAatE5rc,1432
|
|
3
|
+
oneshot/client.py,sha256=6qlXeGbfWEBOrmsftilm3q23mn3fAqXWg0M05fb2zs0,11585
|
|
4
|
+
oneshot/x402.py,sha256=mSrCy_uRJqm5QUbi1YwOSDtg5wfZQPEyhsJsYItpjIk,3947
|
|
5
|
+
oneshot_python-0.1.0.dist-info/METADATA,sha256=JMapJpeQNn3KqsM0qrKh3wR_8xRh6hxYSLKxGrXFnuY,446
|
|
6
|
+
oneshot_python-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
7
|
+
oneshot_python-0.1.0.dist-info/RECORD,,
|