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.
Files changed (23) hide show
  1. {oneshot_python-0.18.0 → oneshot_python-0.19.0}/PKG-INFO +1 -1
  2. {oneshot_python-0.18.0 → oneshot_python-0.19.0}/oneshot/__init__.py +2 -0
  3. {oneshot_python-0.18.0 → oneshot_python-0.19.0}/oneshot/_errors.py +27 -0
  4. {oneshot_python-0.18.0 → oneshot_python-0.19.0}/oneshot/client.py +88 -3
  5. {oneshot_python-0.18.0 → oneshot_python-0.19.0}/pyproject.toml +1 -1
  6. oneshot_python-0.19.0/tests/test_charge_amount.py +57 -0
  7. oneshot_python-0.19.0/tests/test_payment_rejection.py +88 -0
  8. {oneshot_python-0.18.0 → oneshot_python-0.19.0}/.gitignore +0 -0
  9. {oneshot_python-0.18.0 → oneshot_python-0.19.0}/README.md +0 -0
  10. {oneshot_python-0.18.0 → oneshot_python-0.19.0}/oneshot/_types.py +0 -0
  11. {oneshot_python-0.18.0 → oneshot_python-0.19.0}/oneshot/x402.py +0 -0
  12. {oneshot_python-0.18.0 → oneshot_python-0.19.0}/tests/__init__.py +0 -0
  13. {oneshot_python-0.18.0 → oneshot_python-0.19.0}/tests/test_balance.py +0 -0
  14. {oneshot_python-0.18.0 → oneshot_python-0.19.0}/tests/test_compute.py +0 -0
  15. {oneshot_python-0.18.0 → oneshot_python-0.19.0}/tests/test_domains.py +0 -0
  16. {oneshot_python-0.18.0 → oneshot_python-0.19.0}/tests/test_email_payload.py +0 -0
  17. {oneshot_python-0.18.0 → oneshot_python-0.19.0}/tests/test_emergency_error.py +0 -0
  18. {oneshot_python-0.18.0 → oneshot_python-0.19.0}/tests/test_max_cost_header.py +0 -0
  19. {oneshot_python-0.18.0 → oneshot_python-0.19.0}/tests/test_phones_pending.py +0 -0
  20. {oneshot_python-0.18.0 → oneshot_python-0.19.0}/tests/test_request_id.py +0 -0
  21. {oneshot_python-0.18.0 → oneshot_python-0.19.0}/tests/test_tag_receipt_value.py +0 -0
  22. {oneshot_python-0.18.0 → oneshot_python-0.19.0}/tests/test_x402.py +0 -0
  23. {oneshot_python-0.18.0 → oneshot_python-0.19.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: oneshot-python
3
- Version: 0.18.0
3
+ Version: 0.19.0
4
4
  Summary: Core Python SDK for the OneShot API — HTTP client with x402 payment handling
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
@@ -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.18.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
- amount = float(payment_request["amount"])
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=payment_request["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.18.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