oneshot-python 0.16.0__tar.gz → 0.19.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.
- {oneshot_python-0.16.0 → oneshot_python-0.19.0}/PKG-INFO +2 -2
- {oneshot_python-0.16.0 → oneshot_python-0.19.0}/README.md +29 -1
- {oneshot_python-0.16.0 → oneshot_python-0.19.0}/oneshot/__init__.py +10 -0
- {oneshot_python-0.16.0 → oneshot_python-0.19.0}/oneshot/_errors.py +27 -0
- {oneshot_python-0.16.0 → oneshot_python-0.19.0}/oneshot/_types.py +52 -1
- {oneshot_python-0.16.0 → oneshot_python-0.19.0}/oneshot/client.py +171 -7
- {oneshot_python-0.16.0 → oneshot_python-0.19.0}/oneshot/x402.py +46 -0
- {oneshot_python-0.16.0 → oneshot_python-0.19.0}/pyproject.toml +1 -1
- oneshot_python-0.19.0/tests/test_charge_amount.py +57 -0
- oneshot_python-0.19.0/tests/test_domains.py +208 -0
- oneshot_python-0.19.0/tests/test_payment_rejection.py +88 -0
- {oneshot_python-0.16.0 → oneshot_python-0.19.0}/tests/test_tag_receipt_value.py +3 -1
- {oneshot_python-0.16.0 → oneshot_python-0.19.0}/.gitignore +0 -0
- {oneshot_python-0.16.0 → oneshot_python-0.19.0}/tests/__init__.py +0 -0
- {oneshot_python-0.16.0 → oneshot_python-0.19.0}/tests/test_balance.py +0 -0
- {oneshot_python-0.16.0 → oneshot_python-0.19.0}/tests/test_compute.py +0 -0
- {oneshot_python-0.16.0 → oneshot_python-0.19.0}/tests/test_email_payload.py +0 -0
- {oneshot_python-0.16.0 → oneshot_python-0.19.0}/tests/test_emergency_error.py +0 -0
- {oneshot_python-0.16.0 → oneshot_python-0.19.0}/tests/test_max_cost_header.py +0 -0
- {oneshot_python-0.16.0 → oneshot_python-0.19.0}/tests/test_phones_pending.py +0 -0
- {oneshot_python-0.16.0 → oneshot_python-0.19.0}/tests/test_request_id.py +0 -0
- {oneshot_python-0.16.0 → oneshot_python-0.19.0}/tests/test_x402.py +0 -0
- {oneshot_python-0.16.0 → oneshot_python-0.19.0}/uv.lock +0 -0
|
@@ -60,6 +60,12 @@ Paid endpoints use the [x402 protocol](https://x402.org):
|
|
|
60
60
|
|
|
61
61
|
Your private key never leaves your machine. All signing happens locally via `eth-account`.
|
|
62
62
|
|
|
63
|
+
## Read Authentication
|
|
64
|
+
|
|
65
|
+
Read endpoints (inbox, SMS inbox, notifications, balance, browser profiles) return private, per-agent data. Because a wallet address is public, the client proves you actually control it: on every free/read call it signs a short-lived **EIP-712 read proof** (`sign_read_proof`) and sends it as the `x-agent-proof` header. The API verifies the signature locally and rejects any request whose proof doesn't match the `X-Agent-ID` wallet.
|
|
66
|
+
|
|
67
|
+
This is automatic — no extra code beyond `private_key`. **Requires `oneshot-python >= 0.17.0`.** Older versions keep working until the API enables enforcement, after which they must send a valid `x-agent-proof`.
|
|
68
|
+
|
|
63
69
|
## Configuration
|
|
64
70
|
|
|
65
71
|
```python
|
|
@@ -87,6 +93,28 @@ The SDK operates on **Base Mainnet** with real USDC. Fund your wallet before mak
|
|
|
87
93
|
- `call_free_post(endpoint, payload=None)` / `acall_free_post(...)` — POST
|
|
88
94
|
- `call_free_patch(endpoint, payload=None)` / `acall_free_patch(...)` — PATCH
|
|
89
95
|
|
|
96
|
+
### Sending domains — reputation + rotation
|
|
97
|
+
|
|
98
|
+
Free, and scoped to the caller's own domains.
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
for d in client.list_domains()["domains"]:
|
|
102
|
+
print(d["domain"], d["warmup_state"], d["warmup_score"], d["warmup_score_updated_at"])
|
|
103
|
+
|
|
104
|
+
client.pause_domain("example.com") # out of rotation, still owned
|
|
105
|
+
client.resume_domain("example.com") # back into rotation
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
- `list_domains()` / `alist_domains()` — pool + reputation for every domain the agent owns
|
|
109
|
+
- `pause_domain(domain)` / `apause_domain(domain)`
|
|
110
|
+
- `resume_domain(domain)` / `aresume_domain(domain)`
|
|
111
|
+
|
|
112
|
+
`warmup_state` (reputation: `warming` | `warmed` | `degraded`) is orthogonal to `pool_status`
|
|
113
|
+
(rotation: `active` | `paused` | `removed`) — a domain can be paused *and* warming. Two traps when
|
|
114
|
+
reading `warmup_score`: it is `None` for a domain that was never enrolled in warmup, which is not
|
|
115
|
+
the same as a score of `0` (check `warmup_started_at` to tell them apart), and it is refreshed by a
|
|
116
|
+
poll-only reconciler, so it is only as current as `warmup_score_updated_at`.
|
|
117
|
+
|
|
90
118
|
### Compute — autonomous goal orchestration
|
|
91
119
|
|
|
92
120
|
Launch a compute goal and let the orchestrator plan, execute, and iterate. Paid via x402 (same flow as other tools).
|
|
@@ -127,7 +155,7 @@ Every method has an `a*` async mirror (`acompute`, `aget_compute_goal`, …).
|
|
|
127
155
|
## Links
|
|
128
156
|
|
|
129
157
|
- [Documentation](https://docs.oneshotagent.com/sdk/installation#install-via-pip-python)
|
|
130
|
-
- [LangChain integration](https://pypi.org/project/langchain-oneshot/) —
|
|
158
|
+
- [LangChain integration](https://pypi.org/project/langchain-oneshot/) — 34 tools as LangChain BaseTool
|
|
131
159
|
- [GAME plugin](https://pypi.org/project/game-plugin-oneshot/) — Virtuals Protocol integration
|
|
132
160
|
- [TypeScript SDK](https://www.npmjs.com/package/@oneshot-agent/sdk)
|
|
133
161
|
- [MCP Server](https://www.npmjs.com/package/@oneshot-agent/mcp-server)
|
|
@@ -6,6 +6,7 @@ from oneshot._errors import (
|
|
|
6
6
|
JobError,
|
|
7
7
|
JobTimeoutError,
|
|
8
8
|
OneShotError,
|
|
9
|
+
PaymentError,
|
|
9
10
|
ToolError,
|
|
10
11
|
ValidationError,
|
|
11
12
|
)
|
|
@@ -20,6 +21,10 @@ from oneshot._types import (
|
|
|
20
21
|
ComputeSchedule,
|
|
21
22
|
ComputeTask,
|
|
22
23
|
ComputeTaskResponseResult,
|
|
24
|
+
DomainAddressEntry,
|
|
25
|
+
DomainPoolEntry,
|
|
26
|
+
DomainPoolListResult,
|
|
27
|
+
DomainPoolStatusResult,
|
|
23
28
|
)
|
|
24
29
|
from oneshot.client import OneShotClient
|
|
25
30
|
from oneshot.x402 import sign_payment_authorization
|
|
@@ -28,6 +33,7 @@ __all__ = [
|
|
|
28
33
|
"OneShotClient",
|
|
29
34
|
"OneShotError",
|
|
30
35
|
"ToolError",
|
|
36
|
+
"PaymentError",
|
|
31
37
|
"JobError",
|
|
32
38
|
"JobTimeoutError",
|
|
33
39
|
"ValidationError",
|
|
@@ -44,4 +50,8 @@ __all__ = [
|
|
|
44
50
|
"ComputePauseResult",
|
|
45
51
|
"ComputeResumeResult",
|
|
46
52
|
"ComputeFundResult",
|
|
53
|
+
"DomainAddressEntry",
|
|
54
|
+
"DomainPoolEntry",
|
|
55
|
+
"DomainPoolListResult",
|
|
56
|
+
"DomainPoolStatusResult",
|
|
47
57
|
]
|
|
@@ -14,6 +14,33 @@ class ToolError(OneShotError):
|
|
|
14
14
|
self.response_body = response_body
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
class PaymentError(OneShotError):
|
|
18
|
+
"""The facilitator rejected the payment signed for this request.
|
|
19
|
+
|
|
20
|
+
Distinct from the ordinary 402 that opens the quote-pay handshake: this is a
|
|
21
|
+
402 arriving on the PAID retry, meaning the signature was refused.
|
|
22
|
+
``reason`` is the facilitator's machine-readable code
|
|
23
|
+
(``insufficient_funds``, ``invalid_exact_evm_payload_authorization_value``,
|
|
24
|
+
…) and ``expected`` / ``received`` name the amounts when the two disagree —
|
|
25
|
+
the case that produced a silent, bodiless 402 before the server started
|
|
26
|
+
reporting it.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
message: str,
|
|
32
|
+
reason: str,
|
|
33
|
+
expected: dict | None = None,
|
|
34
|
+
received: dict | None = None,
|
|
35
|
+
quote_id: str | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
super().__init__(message)
|
|
38
|
+
self.reason = reason
|
|
39
|
+
self.expected = expected or {}
|
|
40
|
+
self.received = received or {}
|
|
41
|
+
self.quote_id = quote_id
|
|
42
|
+
|
|
43
|
+
|
|
17
44
|
class JobError(OneShotError):
|
|
18
45
|
"""Async job completed with an error."""
|
|
19
46
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Type hints for the compute orchestration
|
|
1
|
+
"""Type hints for the compute orchestration and email-domain APIs.
|
|
2
2
|
|
|
3
3
|
These TypedDicts mirror libs/agent-sdk/src/types.ts field-for-field. They are
|
|
4
4
|
optional at runtime — `OneShotClient` continues to return raw dicts — but
|
|
@@ -164,6 +164,53 @@ class ComputeFundResult(TypedDict, total=False):
|
|
|
164
164
|
remaining: str
|
|
165
165
|
|
|
166
166
|
|
|
167
|
+
class DomainAddressEntry(TypedDict, total=False):
|
|
168
|
+
"""A mailbox already provisioned on a domain (`addresses[]` in the pool)."""
|
|
169
|
+
|
|
170
|
+
address: str
|
|
171
|
+
status: str # active | provisioning | failed
|
|
172
|
+
warmup_state: str # warming | warmed | degraded — per address
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class DomainPoolEntry(TypedDict, total=False):
|
|
176
|
+
"""One sending domain. Mirrors `DomainPoolEntry` in TS.
|
|
177
|
+
|
|
178
|
+
`pool_status` is rotation eligibility; `warmup_state` is reputation health.
|
|
179
|
+
They are orthogonal — a domain can be `paused` AND still `warming`.
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
domain: str
|
|
183
|
+
default_from: str
|
|
184
|
+
addresses: list[DomainAddressEntry]
|
|
185
|
+
pool_status: str # active | paused | removed
|
|
186
|
+
warmup_state: str # warming | warmed | degraded
|
|
187
|
+
pause_reason: Optional[str] # warming | low_reputation | manual | None
|
|
188
|
+
provisioning_status: str
|
|
189
|
+
warmup_score: Optional[int]
|
|
190
|
+
warmup_score_updated_at: Optional[str]
|
|
191
|
+
warmup_started_at: Optional[str]
|
|
192
|
+
daily_send_limit: int
|
|
193
|
+
daily_sent_count: int
|
|
194
|
+
daily_sent_date: Optional[str]
|
|
195
|
+
last_used_at: Optional[str]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class DomainPoolListResult(TypedDict, total=False):
|
|
199
|
+
"""Returned by `list_domains()`."""
|
|
200
|
+
|
|
201
|
+
agent_id: str
|
|
202
|
+
domains: list[DomainPoolEntry]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class DomainPoolStatusResult(TypedDict, total=False):
|
|
206
|
+
"""Returned by `pause_domain()` / `resume_domain()`."""
|
|
207
|
+
|
|
208
|
+
domain: str
|
|
209
|
+
pool_status: str # active | paused
|
|
210
|
+
warmup_state: str
|
|
211
|
+
pause_reason: Optional[str]
|
|
212
|
+
|
|
213
|
+
|
|
167
214
|
__all__ = [
|
|
168
215
|
"ComputeSchedule",
|
|
169
216
|
"ComputeGoalResult",
|
|
@@ -175,4 +222,8 @@ __all__ = [
|
|
|
175
222
|
"ComputePauseResult",
|
|
176
223
|
"ComputeResumeResult",
|
|
177
224
|
"ComputeFundResult",
|
|
225
|
+
"DomainAddressEntry",
|
|
226
|
+
"DomainPoolEntry",
|
|
227
|
+
"DomainPoolListResult",
|
|
228
|
+
"DomainPoolStatusResult",
|
|
178
229
|
]
|
|
@@ -10,7 +10,9 @@ from __future__ import annotations
|
|
|
10
10
|
import asyncio
|
|
11
11
|
import json
|
|
12
12
|
import time
|
|
13
|
+
from decimal import Decimal
|
|
13
14
|
from typing import Any, Optional
|
|
15
|
+
from urllib.parse import quote
|
|
14
16
|
|
|
15
17
|
import httpx
|
|
16
18
|
from eth_account import Account
|
|
@@ -21,14 +23,20 @@ from oneshot._errors import (
|
|
|
21
23
|
JobError,
|
|
22
24
|
JobTimeoutError,
|
|
23
25
|
OneShotError,
|
|
26
|
+
PaymentError,
|
|
24
27
|
ToolError,
|
|
25
28
|
ValidationError,
|
|
26
29
|
)
|
|
30
|
+
from oneshot._types import (
|
|
31
|
+
DomainPoolListResult,
|
|
32
|
+
DomainPoolStatusResult,
|
|
33
|
+
)
|
|
27
34
|
from oneshot.x402 import (
|
|
28
35
|
build_zero_cost_authorization,
|
|
29
36
|
encode_payment_header,
|
|
30
37
|
parse_payment_required,
|
|
31
38
|
sign_payment_authorization,
|
|
39
|
+
sign_read_proof,
|
|
32
40
|
)
|
|
33
41
|
|
|
34
42
|
# Derived from the installed package metadata so it never drifts from
|
|
@@ -38,7 +46,7 @@ try:
|
|
|
38
46
|
|
|
39
47
|
SDK_VERSION = _pkg_version("oneshot-python")
|
|
40
48
|
except Exception: # pragma: no cover - editable/source runs without dist metadata
|
|
41
|
-
SDK_VERSION = "0.
|
|
49
|
+
SDK_VERSION = "0.19.0"
|
|
42
50
|
|
|
43
51
|
# ---------------------------------------------------------------------------
|
|
44
52
|
# Environment configuration
|
|
@@ -99,6 +107,82 @@ def _build_email_payload(
|
|
|
99
107
|
return payload
|
|
100
108
|
|
|
101
109
|
|
|
110
|
+
def _parse_payment_rejection(resp: Any) -> Optional[PaymentError]:
|
|
111
|
+
"""Build a ``PaymentError`` from a 402 that names its own cause.
|
|
112
|
+
|
|
113
|
+
The API attaches ``error='payment_verification_failed'`` plus the
|
|
114
|
+
facilitator's reason and the expected/received amounts when a presented
|
|
115
|
+
payment is refused. Returns None for any other response so the caller can
|
|
116
|
+
fall back to ``ToolError``.
|
|
117
|
+
"""
|
|
118
|
+
if resp.status_code != 402:
|
|
119
|
+
return None
|
|
120
|
+
try:
|
|
121
|
+
data = resp.json()
|
|
122
|
+
except Exception: # noqa: BLE001 - non-JSON body
|
|
123
|
+
return None
|
|
124
|
+
if not isinstance(data, dict) or data.get("error") != "payment_verification_failed":
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
reason = data.get("reason") or "unknown"
|
|
128
|
+
expected = data.get("expected") or {}
|
|
129
|
+
received = data.get("received") or {}
|
|
130
|
+
detail = ", ".join(
|
|
131
|
+
part for part in (
|
|
132
|
+
f"expected ${expected.get('amount')}" if expected.get("amount") else None,
|
|
133
|
+
f"signed ${received.get('amount')}" if received.get("amount") else None,
|
|
134
|
+
) if part
|
|
135
|
+
)
|
|
136
|
+
message = f"payment rejected: {reason}"
|
|
137
|
+
if detail:
|
|
138
|
+
message += f" — {detail}"
|
|
139
|
+
if data.get("message"):
|
|
140
|
+
message += f" ({data['message']})"
|
|
141
|
+
|
|
142
|
+
return PaymentError(
|
|
143
|
+
message,
|
|
144
|
+
reason,
|
|
145
|
+
expected={
|
|
146
|
+
"amount": expected.get("amount"),
|
|
147
|
+
"asset": expected.get("asset"),
|
|
148
|
+
"network": expected.get("network"),
|
|
149
|
+
"pay_to": expected.get("pay_to"),
|
|
150
|
+
},
|
|
151
|
+
received={"amount": received.get("amount")},
|
|
152
|
+
quote_id=data.get("quote_id"),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _resolve_charge_amount(accepted: Any, payment_request: dict[str, Any]) -> str:
|
|
157
|
+
"""The amount to sign, in decimal USDC.
|
|
158
|
+
|
|
159
|
+
A 402 advertises its price twice: the x402 v2 ``PAYMENT-REQUIRED`` header
|
|
160
|
+
(``accepts[0].amount``, atomic units) and the legacy JSON body
|
|
161
|
+
(``payment_request.amount``, decimal). The header is authoritative — it is
|
|
162
|
+
what the server rebuilds its requirement from and what
|
|
163
|
+
``findMatchingRequirements`` compares against.
|
|
164
|
+
|
|
165
|
+
Reading the body alone is how quote-based routes (email/send, sms, voice,
|
|
166
|
+
build, commerce/buy, compute) silently broke: the body carried a hardcoded
|
|
167
|
+
``"0.00"``, so this client took the ``amount == 0`` branch and sent a
|
|
168
|
+
ZERO-COST authorization against a real charge. The server rejected it with
|
|
169
|
+
a bodiless 402 and the caller had nothing to go on.
|
|
170
|
+
|
|
171
|
+
Falls back to the body when the header is absent or unparseable.
|
|
172
|
+
"""
|
|
173
|
+
atomic = accepted.get("amount") if isinstance(accepted, dict) else None
|
|
174
|
+
body = payment_request.get("amount")
|
|
175
|
+
if not (isinstance(atomic, str) and atomic.isdigit()):
|
|
176
|
+
return str(body)
|
|
177
|
+
|
|
178
|
+
from_header = str(Decimal(atomic) / Decimal(10**6))
|
|
179
|
+
# When the two agree (every fixed-price route), keep the body's string
|
|
180
|
+
# verbatim; only a genuine disagreement flips to the header.
|
|
181
|
+
if body is not None and float(body) == float(from_header):
|
|
182
|
+
return str(body)
|
|
183
|
+
return from_header
|
|
184
|
+
|
|
185
|
+
|
|
102
186
|
class OneShotClient:
|
|
103
187
|
"""Synchronous + async HTTP client for the OneShot API.
|
|
104
188
|
|
|
@@ -141,6 +225,20 @@ class OneShotClient:
|
|
|
141
225
|
headers["X-Max-Cost-USDC"] = str(max_cost)
|
|
142
226
|
return headers
|
|
143
227
|
|
|
228
|
+
def _read_headers(self) -> dict[str, str]:
|
|
229
|
+
"""Auth headers plus a signed EIP-712 read proof (x-agent-proof) for the
|
|
230
|
+
free READ routes (inbox, sms inbox, notifications, balance, browser
|
|
231
|
+
profiles). These identify the caller by wallet, and wallet addresses are
|
|
232
|
+
public, so without a proof anyone could read another agent's data by
|
|
233
|
+
supplying its address. Signing failure falls back to plain headers (the
|
|
234
|
+
server runs log-only until read-proof enforcement is enabled)."""
|
|
235
|
+
headers = self._headers()
|
|
236
|
+
try:
|
|
237
|
+
headers["x-agent-proof"] = sign_read_proof(self._private_key, self.address)
|
|
238
|
+
except Exception as e: # noqa: BLE001
|
|
239
|
+
self._log(f"Failed to sign read proof (continuing without): {e}")
|
|
240
|
+
return headers
|
|
241
|
+
|
|
144
242
|
def _log(self, msg: str) -> None:
|
|
145
243
|
if self.debug:
|
|
146
244
|
print(f"[OneShot] {msg}")
|
|
@@ -277,7 +375,8 @@ class OneShotClient:
|
|
|
277
375
|
self._log(f"Payment required: {payment_request['amount']} USDC")
|
|
278
376
|
|
|
279
377
|
# Step 3 — Sign x402 payment (zero-cost auth if credits cover full cost)
|
|
280
|
-
|
|
378
|
+
charge = _resolve_charge_amount(accepted, payment_request)
|
|
379
|
+
amount = float(charge)
|
|
281
380
|
if amount == 0:
|
|
282
381
|
self._log("Credits cover full cost — sending zero-cost authorization")
|
|
283
382
|
auth = build_zero_cost_authorization(
|
|
@@ -299,7 +398,7 @@ class OneShotClient:
|
|
|
299
398
|
private_key=self._private_key,
|
|
300
399
|
from_address=self.address,
|
|
301
400
|
to_address=payment_request["recipient"],
|
|
302
|
-
amount=
|
|
401
|
+
amount=charge,
|
|
303
402
|
token_address=payment_request["token_address"],
|
|
304
403
|
chain_id=payment_request["chain_id"],
|
|
305
404
|
network=f"eip155:{payment_request['chain_id']}",
|
|
@@ -319,6 +418,12 @@ class OneShotClient:
|
|
|
319
418
|
resp2 = await client.post(url, headers=headers, json=payload)
|
|
320
419
|
|
|
321
420
|
if resp2.status_code not in (200, 201, 202):
|
|
421
|
+
# A 402 here means the facilitator refused the signature — not
|
|
422
|
+
# the ordinary quote-pay 402 handled above. The API names the
|
|
423
|
+
# cause, so raise the specific error rather than a generic one.
|
|
424
|
+
rejection = _parse_payment_rejection(resp2)
|
|
425
|
+
if rejection is not None:
|
|
426
|
+
raise rejection
|
|
322
427
|
raise ToolError(
|
|
323
428
|
"Tool request failed after payment",
|
|
324
429
|
resp2.status_code,
|
|
@@ -365,7 +470,7 @@ class OneShotClient:
|
|
|
365
470
|
"""GET a free endpoint (async)."""
|
|
366
471
|
url = f"{self.base_url}{endpoint}"
|
|
367
472
|
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
|
|
368
|
-
resp = await client.get(url, headers=self.
|
|
473
|
+
resp = await client.get(url, headers=self._read_headers(), params=params)
|
|
369
474
|
if not resp.is_success:
|
|
370
475
|
raise ToolError(f"GET {endpoint} failed", resp.status_code, resp.text)
|
|
371
476
|
return resp.json()
|
|
@@ -388,7 +493,7 @@ class OneShotClient:
|
|
|
388
493
|
"""POST to a free endpoint (async)."""
|
|
389
494
|
url = f"{self.base_url}{endpoint}"
|
|
390
495
|
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
|
|
391
|
-
resp = await client.post(url, headers=self.
|
|
496
|
+
resp = await client.post(url, headers=self._read_headers(), json=payload or {})
|
|
392
497
|
if not resp.is_success:
|
|
393
498
|
raise ToolError(f"POST {endpoint} failed", resp.status_code, resp.text)
|
|
394
499
|
return resp.json()
|
|
@@ -411,7 +516,7 @@ class OneShotClient:
|
|
|
411
516
|
"""PATCH a free endpoint (async)."""
|
|
412
517
|
url = f"{self.base_url}{endpoint}"
|
|
413
518
|
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
|
|
414
|
-
resp = await client.patch(url, headers=self.
|
|
519
|
+
resp = await client.patch(url, headers=self._read_headers(), json=payload or {})
|
|
415
520
|
if not resp.is_success:
|
|
416
521
|
raise ToolError(f"PATCH {endpoint} failed", resp.status_code, resp.text)
|
|
417
522
|
# PATCH may return empty body (204)
|
|
@@ -435,7 +540,7 @@ class OneShotClient:
|
|
|
435
540
|
"""DELETE a free endpoint (async)."""
|
|
436
541
|
url = f"{self.base_url}{endpoint}"
|
|
437
542
|
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
|
|
438
|
-
resp = await client.delete(url, headers=self.
|
|
543
|
+
resp = await client.delete(url, headers=self._read_headers())
|
|
439
544
|
if not resp.is_success:
|
|
440
545
|
raise ToolError(f"DELETE {endpoint} failed", resp.status_code, resp.text)
|
|
441
546
|
if resp.status_code == 204 or not resp.text:
|
|
@@ -977,6 +1082,65 @@ class OneShotClient:
|
|
|
977
1082
|
"""Get unified balance (on-chain USDC + credits). Async."""
|
|
978
1083
|
return await self.acall_free_get("/v1/tools/balance")
|
|
979
1084
|
|
|
1085
|
+
# ------------------------------------------------------------------
|
|
1086
|
+
# Email domains — rotation pool + sender reputation
|
|
1087
|
+
# ------------------------------------------------------------------
|
|
1088
|
+
# All three endpoints are free. `pause`/`resume` sit behind the API's
|
|
1089
|
+
# paidEndpointLimiter, but that is a rate limiter, not a price — no x402
|
|
1090
|
+
# quote is issued, so they go through `call_free_post`, not `call_tool`.
|
|
1091
|
+
|
|
1092
|
+
def list_domains(self) -> DomainPoolListResult:
|
|
1093
|
+
"""List the caller's sending domains with reputation + rotation state. Blocking.
|
|
1094
|
+
|
|
1095
|
+
Each entry carries `pool_status` (rotation eligibility: active | paused |
|
|
1096
|
+
removed), `warmup_state` (reputation health: warming | warmed | degraded),
|
|
1097
|
+
`pause_reason`, `warmup_score`, daily send counters, and `addresses[]` —
|
|
1098
|
+
the mailboxes already provisioned on the domain, each with its own
|
|
1099
|
+
`warmup_state`.
|
|
1100
|
+
|
|
1101
|
+
Two things to know before trusting `warmup_score`:
|
|
1102
|
+
|
|
1103
|
+
- It is ``None`` when the domain was never enrolled in warmup. That is not
|
|
1104
|
+
the same as "enrolled, no score yet" — check `warmup_started_at` to tell
|
|
1105
|
+
the two apart.
|
|
1106
|
+
- Scores are refreshed by a poll-only reconciler, so the number is only as
|
|
1107
|
+
fresh as `warmup_score_updated_at`. A stale timestamp means a stale score,
|
|
1108
|
+
not a current measurement.
|
|
1109
|
+
"""
|
|
1110
|
+
return self.call_free_get("/v1/tools/email/domains")
|
|
1111
|
+
|
|
1112
|
+
async def alist_domains(self) -> DomainPoolListResult:
|
|
1113
|
+
"""List the caller's sending domains with reputation + rotation state. Async."""
|
|
1114
|
+
return await self.acall_free_get("/v1/tools/email/domains")
|
|
1115
|
+
|
|
1116
|
+
def pause_domain(self, domain: str) -> DomainPoolStatusResult:
|
|
1117
|
+
"""Take a domain out of rotation without releasing it. Blocking."""
|
|
1118
|
+
return asyncio.get_event_loop().run_until_complete(self.apause_domain(domain))
|
|
1119
|
+
|
|
1120
|
+
async def apause_domain(self, domain: str) -> DomainPoolStatusResult:
|
|
1121
|
+
"""Take a domain out of rotation without releasing it. Async."""
|
|
1122
|
+
return await self.acall_free_post(self._domain_action_path(domain, "pause"))
|
|
1123
|
+
|
|
1124
|
+
def resume_domain(self, domain: str) -> DomainPoolStatusResult:
|
|
1125
|
+
"""Put a paused domain back into rotation. Blocking."""
|
|
1126
|
+
return asyncio.get_event_loop().run_until_complete(self.aresume_domain(domain))
|
|
1127
|
+
|
|
1128
|
+
async def aresume_domain(self, domain: str) -> DomainPoolStatusResult:
|
|
1129
|
+
"""Put a paused domain back into rotation. Async.
|
|
1130
|
+
|
|
1131
|
+
Reputation pauses (`pause_reason='low_reputation'`) auto-recover once the
|
|
1132
|
+
domain is warmed again; a manual pause needs this call.
|
|
1133
|
+
"""
|
|
1134
|
+
return await self.acall_free_post(self._domain_action_path(domain, "resume"))
|
|
1135
|
+
|
|
1136
|
+
@staticmethod
|
|
1137
|
+
def _domain_action_path(domain: str, action: str) -> str:
|
|
1138
|
+
"""Build `/v1/tools/email/domains/<domain>/<action>` with the domain
|
|
1139
|
+
percent-encoded (matches the TS SDK's encodeURIComponent)."""
|
|
1140
|
+
if not domain:
|
|
1141
|
+
raise ValidationError("domain is required", "domain")
|
|
1142
|
+
return f"/v1/tools/email/domains/{quote(domain, safe='')}/{action}"
|
|
1143
|
+
|
|
980
1144
|
# ------------------------------------------------------------------
|
|
981
1145
|
# Receipts — value tagging (RoCS)
|
|
982
1146
|
# ------------------------------------------------------------------
|
|
@@ -249,3 +249,49 @@ def _parse_usdc_amount(amount: str) -> int:
|
|
|
249
249
|
# Pad or truncate fractional part to 6 digits
|
|
250
250
|
frac_str = parts[1][:6].ljust(6, "0")
|
|
251
251
|
return whole * 1_000_000 + int(frac_str)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def sign_read_proof(private_key: str, address: str, scope: str = "read") -> str:
|
|
255
|
+
"""Build a signed EIP-712 agent read proof — the `x-agent-proof` header value.
|
|
256
|
+
|
|
257
|
+
Mirrors the TS SDK's signedReadHeaders() and the server's AgentReadAuth
|
|
258
|
+
verification (api-service middleware/agent-proof-auth.ts). The agent READ
|
|
259
|
+
routes (inbox, sms inbox, notifications, balance, browser profiles) identify
|
|
260
|
+
the caller by wallet; without a proof anyone could read another agent's data
|
|
261
|
+
by supplying its public address. This binds the request to the wallet the SDK
|
|
262
|
+
controls. A fresh nonce per call bounds replay.
|
|
263
|
+
"""
|
|
264
|
+
domain_data = {"name": "OneShot Agent Auth", "version": "1"}
|
|
265
|
+
message_types = {
|
|
266
|
+
"AgentReadAuth": [
|
|
267
|
+
{"name": "agent", "type": "address"},
|
|
268
|
+
{"name": "scope", "type": "string"},
|
|
269
|
+
{"name": "issuedAt", "type": "uint256"},
|
|
270
|
+
{"name": "nonce", "type": "bytes32"},
|
|
271
|
+
],
|
|
272
|
+
}
|
|
273
|
+
issued_at = int(time.time())
|
|
274
|
+
nonce_bytes = os.urandom(32)
|
|
275
|
+
message_data = {
|
|
276
|
+
"agent": address,
|
|
277
|
+
"scope": scope,
|
|
278
|
+
"issuedAt": issued_at,
|
|
279
|
+
"nonce": nonce_bytes,
|
|
280
|
+
}
|
|
281
|
+
signable = encode_typed_data(
|
|
282
|
+
domain_data=domain_data,
|
|
283
|
+
message_types=message_types,
|
|
284
|
+
message_data=message_data,
|
|
285
|
+
)
|
|
286
|
+
signed = Account.sign_message(signable, private_key=private_key)
|
|
287
|
+
signature = "0x" + (
|
|
288
|
+
signed.signature.hex() if isinstance(signed.signature, bytes) else format(signed.signature, "x")
|
|
289
|
+
)
|
|
290
|
+
proof = {
|
|
291
|
+
"agent": address,
|
|
292
|
+
"scope": scope,
|
|
293
|
+
"issuedAt": issued_at,
|
|
294
|
+
"nonce": "0x" + nonce_bytes.hex(),
|
|
295
|
+
"signature": signature,
|
|
296
|
+
}
|
|
297
|
+
return base64.b64encode(json.dumps(proof).encode("utf-8")).decode("ascii")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "oneshot-python"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.19.0"
|
|
4
4
|
description = "Core Python SDK for the OneShot API — HTTP client with x402 payment handling"
|
|
5
5
|
readme = {text = "Core Python SDK for the OneShot API", content-type = "text/plain"}
|
|
6
6
|
license = "MIT"
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""The signed amount comes from the PAYMENT-REQUIRED header, not the body.
|
|
2
|
+
|
|
3
|
+
A 402 advertises its price twice — the x402 v2 header (atomic units) and the
|
|
4
|
+
legacy JSON body (decimal). They disagreed on quote-based routes: the body
|
|
5
|
+
carried a hardcoded "0.00" while the header carried the real total. Reading the
|
|
6
|
+
body made this client take the `amount == 0` branch and send a ZERO-COST
|
|
7
|
+
authorization against a real charge, which the server rejected with a bodiless
|
|
8
|
+
402 (observed in prod on email/send: $20.00 quote, body said 0.00).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from oneshot.client import _resolve_charge_amount
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _accepted(atomic: str) -> dict:
|
|
19
|
+
return {
|
|
20
|
+
"scheme": "exact",
|
|
21
|
+
"network": "eip155:8453",
|
|
22
|
+
"amount": atomic,
|
|
23
|
+
"asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
24
|
+
"payTo": "0x9fb365E4E9385E2a39FeBAd70368267e6f571d9A",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TestResolveChargeAmount:
|
|
29
|
+
def test_header_wins_over_a_zeroed_body(self) -> None:
|
|
30
|
+
"""The prod incident: header $20, body "0.00"."""
|
|
31
|
+
amount = _resolve_charge_amount(_accepted("20000000"), {"amount": "0.00"})
|
|
32
|
+
assert float(amount) == 20.0
|
|
33
|
+
|
|
34
|
+
def test_never_takes_the_zero_cost_branch_on_a_real_charge(self) -> None:
|
|
35
|
+
amount = _resolve_charge_amount(_accepted("20000000"), {"amount": "0.00"})
|
|
36
|
+
assert float(amount) != 0
|
|
37
|
+
|
|
38
|
+
@pytest.mark.parametrize(
|
|
39
|
+
"atomic,expected",
|
|
40
|
+
[("10000", 0.01), ("1431000", 1.431), ("250000000", 250.0), ("1000", 0.001)],
|
|
41
|
+
)
|
|
42
|
+
def test_atomic_to_decimal(self, atomic: str, expected: float) -> None:
|
|
43
|
+
assert float(_resolve_charge_amount(_accepted(atomic), {"amount": "0.00"})) == expected
|
|
44
|
+
|
|
45
|
+
def test_falls_back_to_body_when_header_missing(self) -> None:
|
|
46
|
+
assert float(_resolve_charge_amount(None, {"amount": "0.05"})) == 0.05
|
|
47
|
+
|
|
48
|
+
def test_falls_back_to_body_when_header_amount_unparseable(self) -> None:
|
|
49
|
+
assert float(_resolve_charge_amount({"amount": "not-a-number"}, {"amount": "0.05"})) == 0.05
|
|
50
|
+
|
|
51
|
+
def test_genuine_zero_still_reads_as_zero(self) -> None:
|
|
52
|
+
"""Credits covering the full cost must still take the zero-cost path."""
|
|
53
|
+
assert float(_resolve_charge_amount(_accepted("0"), {"amount": "0.00"})) == 0.0
|
|
54
|
+
|
|
55
|
+
def test_no_float_artifacts(self) -> None:
|
|
56
|
+
"""Decimal conversion, so the signed string matches the server's requirement."""
|
|
57
|
+
assert _resolve_charge_amount(_accepted("1431000"), {"amount": "0.00"}) == "1.431"
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Tests for the email-domain methods on OneShotClient.
|
|
2
|
+
|
|
3
|
+
Covers list_domains / pause_domain / resume_domain (sync + async): the exact
|
|
4
|
+
endpoints hit, percent-encoding of the domain path segment, and the empty-domain
|
|
5
|
+
guard. All three endpoints are free, so they must route through the
|
|
6
|
+
`call_free_get` / `call_free_post` helpers — never `call_tool` (which would open
|
|
7
|
+
an x402 quote-and-pay flow for a read).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from unittest.mock import AsyncMock, patch
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
from eth_account import Account
|
|
16
|
+
|
|
17
|
+
from oneshot._errors import ValidationError
|
|
18
|
+
from oneshot.client import OneShotClient
|
|
19
|
+
|
|
20
|
+
# Deterministic test key (never use with real funds)
|
|
21
|
+
TEST_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
|
|
22
|
+
TEST_ADDRESS = Account.from_key(TEST_PRIVATE_KEY).address
|
|
23
|
+
|
|
24
|
+
MOCK_DOMAINS_RESPONSE = {
|
|
25
|
+
"agent_id": "39c6fd04-acfc-4be4-bb35-0e8f6523d8e7",
|
|
26
|
+
"domains": [
|
|
27
|
+
{
|
|
28
|
+
"domain": "warmed-example.com",
|
|
29
|
+
"default_from": "agent@warmed-example.com",
|
|
30
|
+
"addresses": [
|
|
31
|
+
{
|
|
32
|
+
"address": "agent@warmed-example.com",
|
|
33
|
+
"status": "active",
|
|
34
|
+
"warmup_state": "warmed",
|
|
35
|
+
}
|
|
36
|
+
],
|
|
37
|
+
"pool_status": "active",
|
|
38
|
+
"warmup_state": "warmed",
|
|
39
|
+
"pause_reason": None,
|
|
40
|
+
"provisioning_status": "active",
|
|
41
|
+
"warmup_score": 98,
|
|
42
|
+
"warmup_score_updated_at": "2026-08-13T18:00:05.497Z",
|
|
43
|
+
"warmup_started_at": "2026-06-25T14:07:43.230Z",
|
|
44
|
+
"daily_send_limit": 100,
|
|
45
|
+
"daily_sent_count": 5,
|
|
46
|
+
"daily_sent_date": "2026-08-13",
|
|
47
|
+
"last_used_at": "2026-08-13T17:12:00.000Z",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
# Never enrolled in warmup: score is None, not 0. Callers must not
|
|
51
|
+
# read this as "reputation zero".
|
|
52
|
+
"domain": "never-enrolled.com",
|
|
53
|
+
"default_from": "agent@never-enrolled.com",
|
|
54
|
+
"addresses": [],
|
|
55
|
+
"pool_status": "paused",
|
|
56
|
+
"warmup_state": "warming",
|
|
57
|
+
"pause_reason": "warming",
|
|
58
|
+
"provisioning_status": "active",
|
|
59
|
+
"warmup_score": None,
|
|
60
|
+
"warmup_score_updated_at": None,
|
|
61
|
+
"warmup_started_at": None,
|
|
62
|
+
"daily_send_limit": 50,
|
|
63
|
+
"daily_sent_count": 0,
|
|
64
|
+
"daily_sent_date": None,
|
|
65
|
+
"last_used_at": None,
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
MOCK_STATUS_RESPONSE = {
|
|
71
|
+
"domain": "warmed-example.com",
|
|
72
|
+
"pool_status": "paused",
|
|
73
|
+
"warmup_state": "warmed",
|
|
74
|
+
"pause_reason": "manual",
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class TestListDomains:
|
|
79
|
+
"""Synchronous list_domains() tests."""
|
|
80
|
+
|
|
81
|
+
def test_calls_correct_endpoint(self) -> None:
|
|
82
|
+
client = OneShotClient(TEST_PRIVATE_KEY)
|
|
83
|
+
|
|
84
|
+
with patch.object(client, "call_free_get", return_value=MOCK_DOMAINS_RESPONSE) as mock:
|
|
85
|
+
result = client.list_domains()
|
|
86
|
+
|
|
87
|
+
mock.assert_called_once_with("/v1/tools/email/domains")
|
|
88
|
+
assert result == MOCK_DOMAINS_RESPONSE
|
|
89
|
+
|
|
90
|
+
def test_surfaces_reputation_fields(self) -> None:
|
|
91
|
+
client = OneShotClient(TEST_PRIVATE_KEY)
|
|
92
|
+
|
|
93
|
+
with patch.object(client, "call_free_get", return_value=MOCK_DOMAINS_RESPONSE):
|
|
94
|
+
domains = client.list_domains()["domains"]
|
|
95
|
+
|
|
96
|
+
warmed, never_enrolled = domains
|
|
97
|
+
assert warmed["warmup_state"] == "warmed"
|
|
98
|
+
assert warmed["warmup_score"] == 98
|
|
99
|
+
assert warmed["warmup_score_updated_at"] == "2026-08-13T18:00:05.497Z"
|
|
100
|
+
assert warmed["addresses"][0]["warmup_state"] == "warmed"
|
|
101
|
+
# Never enrolled: null score, null started_at — distinct from a real 0.
|
|
102
|
+
assert never_enrolled["warmup_score"] is None
|
|
103
|
+
assert never_enrolled["warmup_started_at"] is None
|
|
104
|
+
|
|
105
|
+
def test_propagates_errors(self) -> None:
|
|
106
|
+
client = OneShotClient(TEST_PRIVATE_KEY)
|
|
107
|
+
|
|
108
|
+
with patch.object(client, "call_free_get", side_effect=Exception("Network error")):
|
|
109
|
+
with pytest.raises(Exception, match="Network error"):
|
|
110
|
+
client.list_domains()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class TestAlistDomains:
|
|
114
|
+
"""Async alist_domains() tests."""
|
|
115
|
+
|
|
116
|
+
@pytest.mark.asyncio
|
|
117
|
+
async def test_calls_correct_endpoint(self) -> None:
|
|
118
|
+
client = OneShotClient(TEST_PRIVATE_KEY)
|
|
119
|
+
|
|
120
|
+
with patch.object(
|
|
121
|
+
client, "acall_free_get", new_callable=AsyncMock, return_value=MOCK_DOMAINS_RESPONSE
|
|
122
|
+
) as mock:
|
|
123
|
+
result = await client.alist_domains()
|
|
124
|
+
|
|
125
|
+
mock.assert_called_once_with("/v1/tools/email/domains")
|
|
126
|
+
assert result == MOCK_DOMAINS_RESPONSE
|
|
127
|
+
|
|
128
|
+
@pytest.mark.asyncio
|
|
129
|
+
async def test_propagates_errors(self) -> None:
|
|
130
|
+
client = OneShotClient(TEST_PRIVATE_KEY)
|
|
131
|
+
|
|
132
|
+
with patch.object(
|
|
133
|
+
client, "acall_free_get", new_callable=AsyncMock, side_effect=Exception("Timeout")
|
|
134
|
+
):
|
|
135
|
+
with pytest.raises(Exception, match="Timeout"):
|
|
136
|
+
await client.alist_domains()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class TestPauseResumeDomain:
|
|
140
|
+
"""pause_domain() / resume_domain(), sync + async."""
|
|
141
|
+
|
|
142
|
+
@pytest.mark.asyncio
|
|
143
|
+
async def test_pause_calls_correct_endpoint(self) -> None:
|
|
144
|
+
client = OneShotClient(TEST_PRIVATE_KEY)
|
|
145
|
+
|
|
146
|
+
with patch.object(
|
|
147
|
+
client, "acall_free_post", new_callable=AsyncMock, return_value=MOCK_STATUS_RESPONSE
|
|
148
|
+
) as mock:
|
|
149
|
+
result = await client.apause_domain("warmed-example.com")
|
|
150
|
+
|
|
151
|
+
mock.assert_called_once_with("/v1/tools/email/domains/warmed-example.com/pause")
|
|
152
|
+
assert result == MOCK_STATUS_RESPONSE
|
|
153
|
+
|
|
154
|
+
@pytest.mark.asyncio
|
|
155
|
+
async def test_resume_calls_correct_endpoint(self) -> None:
|
|
156
|
+
client = OneShotClient(TEST_PRIVATE_KEY)
|
|
157
|
+
|
|
158
|
+
with patch.object(
|
|
159
|
+
client, "acall_free_post", new_callable=AsyncMock, return_value=MOCK_STATUS_RESPONSE
|
|
160
|
+
) as mock:
|
|
161
|
+
await client.aresume_domain("warmed-example.com")
|
|
162
|
+
|
|
163
|
+
mock.assert_called_once_with("/v1/tools/email/domains/warmed-example.com/resume")
|
|
164
|
+
|
|
165
|
+
@pytest.mark.asyncio
|
|
166
|
+
async def test_encodes_the_domain_segment(self) -> None:
|
|
167
|
+
"""A domain with a slash must not escape into a different route."""
|
|
168
|
+
client = OneShotClient(TEST_PRIVATE_KEY)
|
|
169
|
+
|
|
170
|
+
with patch.object(
|
|
171
|
+
client, "acall_free_post", new_callable=AsyncMock, return_value=MOCK_STATUS_RESPONSE
|
|
172
|
+
) as mock:
|
|
173
|
+
await client.apause_domain("evil.com/../../admin")
|
|
174
|
+
|
|
175
|
+
mock.assert_called_once_with(
|
|
176
|
+
"/v1/tools/email/domains/evil.com%2F..%2F..%2Fadmin/pause"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
@pytest.mark.asyncio
|
|
180
|
+
async def test_rejects_empty_domain(self) -> None:
|
|
181
|
+
client = OneShotClient(TEST_PRIVATE_KEY)
|
|
182
|
+
|
|
183
|
+
with pytest.raises(ValidationError):
|
|
184
|
+
await client.apause_domain("")
|
|
185
|
+
with pytest.raises(ValidationError):
|
|
186
|
+
await client.aresume_domain("")
|
|
187
|
+
|
|
188
|
+
def test_sync_pause_delegates_to_async(self) -> None:
|
|
189
|
+
client = OneShotClient(TEST_PRIVATE_KEY)
|
|
190
|
+
|
|
191
|
+
with patch.object(
|
|
192
|
+
client, "apause_domain", new_callable=AsyncMock, return_value=MOCK_STATUS_RESPONSE
|
|
193
|
+
) as mock:
|
|
194
|
+
result = client.pause_domain("warmed-example.com")
|
|
195
|
+
|
|
196
|
+
mock.assert_called_once_with("warmed-example.com")
|
|
197
|
+
assert result == MOCK_STATUS_RESPONSE
|
|
198
|
+
|
|
199
|
+
def test_sync_resume_delegates_to_async(self) -> None:
|
|
200
|
+
client = OneShotClient(TEST_PRIVATE_KEY)
|
|
201
|
+
|
|
202
|
+
with patch.object(
|
|
203
|
+
client, "aresume_domain", new_callable=AsyncMock, return_value=MOCK_STATUS_RESPONSE
|
|
204
|
+
) as mock:
|
|
205
|
+
result = client.resume_domain("warmed-example.com")
|
|
206
|
+
|
|
207
|
+
mock.assert_called_once_with("warmed-example.com")
|
|
208
|
+
assert result == MOCK_STATUS_RESPONSE
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""A refused payment raises PaymentError, not a generic ToolError.
|
|
2
|
+
|
|
3
|
+
The API now names the cause of a rejected payment in the 402 body
|
|
4
|
+
(`payment_verification_failed` + reason + expected/received amounts). The client
|
|
5
|
+
surfaces that as a typed error so callers can branch on `err.reason` instead of
|
|
6
|
+
string-matching a response body — which was empty before the server started
|
|
7
|
+
reporting it.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from oneshot._errors import PaymentError
|
|
13
|
+
from oneshot.client import _parse_payment_rejection
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _Resp:
|
|
17
|
+
"""Minimal httpx.Response stand-in."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, status_code: int, payload=None, raises: bool = False) -> None:
|
|
20
|
+
self.status_code = status_code
|
|
21
|
+
self._payload = payload
|
|
22
|
+
self._raises = raises
|
|
23
|
+
self.text = "" if payload is None else str(payload)
|
|
24
|
+
|
|
25
|
+
def json(self):
|
|
26
|
+
if self._raises:
|
|
27
|
+
raise ValueError("not json")
|
|
28
|
+
return self._payload
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
REJECTION = {
|
|
32
|
+
"error": "payment_verification_failed",
|
|
33
|
+
"reason": "invalid_exact_evm_payload_authorization_value",
|
|
34
|
+
"message": "authorization value does not match requirement",
|
|
35
|
+
"expected": {
|
|
36
|
+
"amount": "20.000000",
|
|
37
|
+
"asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
38
|
+
"network": "eip155:8453",
|
|
39
|
+
"pay_to": "0x9fb365E4E9385E2a39FeBAd70368267e6f571d9A",
|
|
40
|
+
},
|
|
41
|
+
"received": {"amount": "0"},
|
|
42
|
+
"quote_id": "quote_01KZYBN6AE00K8PS29P5469KJM",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TestParsePaymentRejection:
|
|
47
|
+
def test_builds_a_payment_error(self) -> None:
|
|
48
|
+
err = _parse_payment_rejection(_Resp(402, REJECTION))
|
|
49
|
+
assert isinstance(err, PaymentError)
|
|
50
|
+
assert err.reason == "invalid_exact_evm_payload_authorization_value"
|
|
51
|
+
assert err.expected["amount"] == "20.000000"
|
|
52
|
+
assert err.received["amount"] == "0"
|
|
53
|
+
assert err.quote_id == "quote_01KZYBN6AE00K8PS29P5469KJM"
|
|
54
|
+
|
|
55
|
+
def test_message_states_both_amounts(self) -> None:
|
|
56
|
+
"""The whole point: the caller can see the mismatch without server logs."""
|
|
57
|
+
err = _parse_payment_rejection(_Resp(402, REJECTION))
|
|
58
|
+
assert "expected $20.000000" in str(err)
|
|
59
|
+
assert "signed $0" in str(err)
|
|
60
|
+
assert "invalid_exact_evm_payload_authorization_value" in str(err)
|
|
61
|
+
|
|
62
|
+
def test_tolerates_a_reasonless_rejection(self) -> None:
|
|
63
|
+
err = _parse_payment_rejection(_Resp(402, {"error": "payment_verification_failed"}))
|
|
64
|
+
assert isinstance(err, PaymentError)
|
|
65
|
+
assert err.reason == "unknown"
|
|
66
|
+
assert err.expected["amount"] is None
|
|
67
|
+
|
|
68
|
+
def test_ignores_the_ordinary_quote_leg_402(self) -> None:
|
|
69
|
+
"""The quote-pay handshake's 402 is not an error — must not raise."""
|
|
70
|
+
quote_402 = {
|
|
71
|
+
"error": "payment_required",
|
|
72
|
+
"code": 402,
|
|
73
|
+
"payment_request": {"amount": "20.000000", "recipient": "0x9fb3"},
|
|
74
|
+
}
|
|
75
|
+
assert _parse_payment_rejection(_Resp(402, quote_402)) is None
|
|
76
|
+
|
|
77
|
+
def test_ignores_non_402(self) -> None:
|
|
78
|
+
assert _parse_payment_rejection(_Resp(500, {"error": "boom"})) is None
|
|
79
|
+
|
|
80
|
+
def test_ignores_a_non_json_body(self) -> None:
|
|
81
|
+
assert _parse_payment_rejection(_Resp(402, raises=True)) is None
|
|
82
|
+
|
|
83
|
+
def test_payment_error_is_a_oneshot_error(self) -> None:
|
|
84
|
+
from oneshot import OneShotError, PaymentError as Exported
|
|
85
|
+
|
|
86
|
+
err = _parse_payment_rejection(_Resp(402, REJECTION))
|
|
87
|
+
assert isinstance(err, OneShotError)
|
|
88
|
+
assert isinstance(err, Exported)
|
|
@@ -105,10 +105,11 @@ def test_sync_wrapper_delegates_to_async(monkeypatch):
|
|
|
105
105
|
c = OneShotClient(TEST_PRIVATE_KEY)
|
|
106
106
|
called: dict = {}
|
|
107
107
|
|
|
108
|
-
async def fake_async(receipt_id=None, value_tag=None, *, request_id=None):
|
|
108
|
+
async def fake_async(receipt_id=None, value_tag=None, *, request_id=None, goal_id=None):
|
|
109
109
|
called["receipt_id"] = receipt_id
|
|
110
110
|
called["value_tag"] = value_tag
|
|
111
111
|
called["request_id"] = request_id
|
|
112
|
+
called["goal_id"] = goal_id
|
|
112
113
|
return {"ok": True}
|
|
113
114
|
|
|
114
115
|
c.atag_receipt_value = fake_async # type: ignore[method-assign]
|
|
@@ -118,4 +119,5 @@ def test_sync_wrapper_delegates_to_async(monkeypatch):
|
|
|
118
119
|
"receipt_id": "rcpt_01HX",
|
|
119
120
|
"value_tag": {"type": "savings", "amount": 12},
|
|
120
121
|
"request_id": None,
|
|
122
|
+
"goal_id": None,
|
|
121
123
|
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|