oneshot-python 0.18.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.18.0 → oneshot_python-0.19.0}/PKG-INFO +1 -1
- {oneshot_python-0.18.0 → oneshot_python-0.19.0}/oneshot/__init__.py +2 -0
- {oneshot_python-0.18.0 → oneshot_python-0.19.0}/oneshot/_errors.py +27 -0
- {oneshot_python-0.18.0 → oneshot_python-0.19.0}/oneshot/client.py +88 -3
- {oneshot_python-0.18.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_payment_rejection.py +88 -0
- {oneshot_python-0.18.0 → oneshot_python-0.19.0}/.gitignore +0 -0
- {oneshot_python-0.18.0 → oneshot_python-0.19.0}/README.md +0 -0
- {oneshot_python-0.18.0 → oneshot_python-0.19.0}/oneshot/_types.py +0 -0
- {oneshot_python-0.18.0 → oneshot_python-0.19.0}/oneshot/x402.py +0 -0
- {oneshot_python-0.18.0 → oneshot_python-0.19.0}/tests/__init__.py +0 -0
- {oneshot_python-0.18.0 → oneshot_python-0.19.0}/tests/test_balance.py +0 -0
- {oneshot_python-0.18.0 → oneshot_python-0.19.0}/tests/test_compute.py +0 -0
- {oneshot_python-0.18.0 → oneshot_python-0.19.0}/tests/test_domains.py +0 -0
- {oneshot_python-0.18.0 → oneshot_python-0.19.0}/tests/test_email_payload.py +0 -0
- {oneshot_python-0.18.0 → oneshot_python-0.19.0}/tests/test_emergency_error.py +0 -0
- {oneshot_python-0.18.0 → oneshot_python-0.19.0}/tests/test_max_cost_header.py +0 -0
- {oneshot_python-0.18.0 → oneshot_python-0.19.0}/tests/test_phones_pending.py +0 -0
- {oneshot_python-0.18.0 → oneshot_python-0.19.0}/tests/test_request_id.py +0 -0
- {oneshot_python-0.18.0 → oneshot_python-0.19.0}/tests/test_tag_receipt_value.py +0 -0
- {oneshot_python-0.18.0 → oneshot_python-0.19.0}/tests/test_x402.py +0 -0
- {oneshot_python-0.18.0 → oneshot_python-0.19.0}/uv.lock +0 -0
|
@@ -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
|
)
|
|
@@ -32,6 +33,7 @@ __all__ = [
|
|
|
32
33
|
"OneShotClient",
|
|
33
34
|
"OneShotError",
|
|
34
35
|
"ToolError",
|
|
36
|
+
"PaymentError",
|
|
35
37
|
"JobError",
|
|
36
38
|
"JobTimeoutError",
|
|
37
39
|
"ValidationError",
|
|
@@ -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
|
|
|
@@ -10,6 +10,7 @@ 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
|
|
14
15
|
from urllib.parse import quote
|
|
15
16
|
|
|
@@ -22,6 +23,7 @@ from oneshot._errors import (
|
|
|
22
23
|
JobError,
|
|
23
24
|
JobTimeoutError,
|
|
24
25
|
OneShotError,
|
|
26
|
+
PaymentError,
|
|
25
27
|
ToolError,
|
|
26
28
|
ValidationError,
|
|
27
29
|
)
|
|
@@ -44,7 +46,7 @@ try:
|
|
|
44
46
|
|
|
45
47
|
SDK_VERSION = _pkg_version("oneshot-python")
|
|
46
48
|
except Exception: # pragma: no cover - editable/source runs without dist metadata
|
|
47
|
-
SDK_VERSION = "0.
|
|
49
|
+
SDK_VERSION = "0.19.0"
|
|
48
50
|
|
|
49
51
|
# ---------------------------------------------------------------------------
|
|
50
52
|
# Environment configuration
|
|
@@ -105,6 +107,82 @@ def _build_email_payload(
|
|
|
105
107
|
return payload
|
|
106
108
|
|
|
107
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
|
+
|
|
108
186
|
class OneShotClient:
|
|
109
187
|
"""Synchronous + async HTTP client for the OneShot API.
|
|
110
188
|
|
|
@@ -297,7 +375,8 @@ class OneShotClient:
|
|
|
297
375
|
self._log(f"Payment required: {payment_request['amount']} USDC")
|
|
298
376
|
|
|
299
377
|
# Step 3 — Sign x402 payment (zero-cost auth if credits cover full cost)
|
|
300
|
-
|
|
378
|
+
charge = _resolve_charge_amount(accepted, payment_request)
|
|
379
|
+
amount = float(charge)
|
|
301
380
|
if amount == 0:
|
|
302
381
|
self._log("Credits cover full cost — sending zero-cost authorization")
|
|
303
382
|
auth = build_zero_cost_authorization(
|
|
@@ -319,7 +398,7 @@ class OneShotClient:
|
|
|
319
398
|
private_key=self._private_key,
|
|
320
399
|
from_address=self.address,
|
|
321
400
|
to_address=payment_request["recipient"],
|
|
322
|
-
amount=
|
|
401
|
+
amount=charge,
|
|
323
402
|
token_address=payment_request["token_address"],
|
|
324
403
|
chain_id=payment_request["chain_id"],
|
|
325
404
|
network=f"eip155:{payment_request['chain_id']}",
|
|
@@ -339,6 +418,12 @@ class OneShotClient:
|
|
|
339
418
|
resp2 = await client.post(url, headers=headers, json=payload)
|
|
340
419
|
|
|
341
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
|
|
342
427
|
raise ToolError(
|
|
343
428
|
"Tool request failed after payment",
|
|
344
429
|
resp2.status_code,
|
|
@@ -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,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)
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|