diff --git a/CHANGELOG.md b/CHANGELOG.md index c780e59..d4b3c94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,45 @@ All notable changes to blockrun-llm will be documented in this file. +## 1.7.1 — 2026-07-16 + +### Fixed + +- **The settlement header was read under a name no gateway sends.** Both + gateways emit `PAYMENT-RESPONSE` (the x402 v2 spec name) — `blockrun` at 36 + call sites, `blockrun-sol` at 25 — and neither emits `X-PAYMENT-RESPONSE` + even once. The SDK read only the legacy name, in four hand-rolled places, so + `_last_settlement` decoded nothing against production: no tx hash, no + settlement on any paid call. The sidecar hit this exact bug and fixed it in + blockrun-litellm 0.6.0, live-verified against a real paid call; the SDK half + was never done. Both names now go through one helper + (`tx_log.read_settlement_header`) so they can't drift apart again. The legacy + name stays accepted for other facilitators. + +- **The paid-request error no longer claims your money is gone.** + `"API error after payment"` reads as *funds are lost*, which is usually false + — a real image-edit 500 was reported as lost USDC by two readers before + anyone checked the gateway. It now reports only what the settlement header + proves: a tx hash means SETTLED and is named; absence means unknown. + + Absence is **not** reported as "payment likely not taken", which the first cut + of this change did. That trades a false alarm for a false all-clear, and the + all-clear lands on precisely the wrong requests: Solana's paid chat path + settles *in parallel* with the upstream call and re-raises immediately + (`logChargedButFailed(...); throw primaryError`), so a request the gateway + logs as `CHARGED BUT REQUEST FAILED — refund manually` answers *before* + settlement lands, and therefore carries no header at all. Absence and "you + were charged" co-occur systematically on the one path where it costs money. + Base settles after the upstream call and does match the optimistic reading, + but a set of headers doesn't tell the SDK which gateway produced it. So the + wording names the usual case without asserting it, and points at wallet + history. + + Gated on `tx_hash`, never the header's `success` field: the gateways hard-code + `success: true` even when settle didn't land, so older clients don't surface a + spurious error. A tx hash is the only field that means money moved — the same + field the gateways gate their own revenue accounting on. + ## 1.7.0 — 2026-07-15 ### Added diff --git a/blockrun_llm/__init__.py b/blockrun_llm/__init__.py index c33f15a..bfefbc8 100644 --- a/blockrun_llm/__init__.py +++ b/blockrun_llm/__init__.py @@ -170,7 +170,7 @@ ) from .tx_log import TransactionLogger, decode_settlement_header, format_row -__version__ = "1.7.0" +__version__ = "1.7.1" __all__ = [ "LLMClient", "AsyncLLMClient", diff --git a/blockrun_llm/client.py b/blockrun_llm/client.py index b4cd5e8..4a3084e 100644 --- a/blockrun_llm/client.py +++ b/blockrun_llm/client.py @@ -61,7 +61,13 @@ chunk_usage_dict, ) from .router import route as route_request -from .tx_log import TransactionLogger, decode_settlement_header, _resolve_log_dir +from .tx_log import ( + TransactionLogger, + decode_settlement_header, + paid_request_error_prefix, + read_settlement_header, + _resolve_log_dir, +) from .x402 import create_payment_payload, parse_payment_required, extract_payment_details from .validation import ( validate_private_key, @@ -310,7 +316,7 @@ def __init__( self._model_pricing_cache: Optional[Dict[str, Dict[str, float]]] = None # Opt-in transaction log + last on-chain settlement payload. The - # settlement is populated from X-PAYMENT-RESPONSE on every paid retry + # settlement is populated from PAYMENT-RESPONSE on every paid retry # and cleared right before save_to_cache fires so it can't bleed # across calls when logging is disabled. log_dir = _resolve_log_dir(transaction_log) @@ -327,9 +333,7 @@ def _capture_settlement(self, response: httpx.Response) -> Optional[Dict[str, An ``save_to_cache``. ``None`` when the facilitator didn't include a settlement header — older facilitators / cached free responses. """ - header = response.headers.get("x-payment-response") or response.headers.get( - "X-PAYMENT-RESPONSE" - ) + header = read_settlement_header(response.headers) settlement = decode_settlement_header(header) self._last_settlement = settlement return settlement @@ -1050,7 +1054,7 @@ def _raise_stream_error(response: httpx.Response, *, after_payment: bool) -> Non error_body = response.json() except Exception: error_body = {"error": "Stream request failed"} - prefix = "API error after payment" if after_payment else "API error" + prefix = paid_request_error_prefix(response.headers) if after_payment else "API error" raise APIError( f"{prefix}: {response.status_code}", response.status_code, @@ -1199,7 +1203,7 @@ def _handle_payment_and_retry( except Exception: error_body = {"error": "Request failed"} raise APIError( - f"API error after payment: {retry_response.status_code}", + f"{paid_request_error_prefix(retry_response.headers)}: {retry_response.status_code}", retry_response.status_code, sanitize_error_response(error_body), ) @@ -1366,7 +1370,7 @@ def _handle_payment_and_retry_raw( except Exception: error_body = {"error": "Request failed"} raise APIError( - f"API error after payment: {retry_response.status_code}", + f"{paid_request_error_prefix(retry_response.headers)}: {retry_response.status_code}", retry_response.status_code, sanitize_error_response(error_body), ) @@ -1504,7 +1508,7 @@ def _handle_get_payment_and_retry( except Exception: error_body = {"error": "Request failed"} raise APIError( - f"API error after payment: {retry_response.status_code}", + f"{paid_request_error_prefix(retry_response.headers)}: {retry_response.status_code}", retry_response.status_code, sanitize_error_response(error_body), ) @@ -2082,7 +2086,7 @@ def _log_transaction( """Append one row to the project-local transaction log, if enabled. Pulls the on-chain settlement out of ``self._last_settlement`` - (captured from ``X-PAYMENT-RESPONSE`` on the paid retry) and + (captured from ``PAYMENT-RESPONSE`` on the paid retry) and consumes it — so a subsequent free / cached call right after a paid one cannot reuse stale tx fields. No-op when the logger is disabled; never raises (best-effort logging by design).""" @@ -2271,9 +2275,7 @@ def __init__( def _capture_settlement(self, response: httpx.Response) -> Optional[Dict[str, Any]]: """Async-client twin of :meth:`LLMClient._capture_settlement`.""" - header = response.headers.get("x-payment-response") or response.headers.get( - "X-PAYMENT-RESPONSE" - ) + header = read_settlement_header(response.headers) settlement = decode_settlement_header(header) self._last_settlement = settlement return settlement @@ -2765,7 +2767,7 @@ async def _handle_payment_and_retry( except Exception: error_body = {"error": "Request failed"} raise APIError( - f"API error after payment: {retry_response.status_code}", + f"{paid_request_error_prefix(retry_response.headers)}: {retry_response.status_code}", retry_response.status_code, sanitize_error_response(error_body), ) @@ -2920,7 +2922,7 @@ async def _handle_payment_and_retry_raw( except Exception: error_body = {"error": "Request failed"} raise APIError( - f"API error after payment: {retry_response.status_code}", + f"{paid_request_error_prefix(retry_response.headers)}: {retry_response.status_code}", retry_response.status_code, sanitize_error_response(error_body), ) @@ -3044,7 +3046,7 @@ async def _handle_get_payment_and_retry( except Exception: error_body = {"error": "Request failed"} raise APIError( - f"API error after payment: {retry_response.status_code}", + f"{paid_request_error_prefix(retry_response.headers)}: {retry_response.status_code}", retry_response.status_code, sanitize_error_response(error_body), ) diff --git a/blockrun_llm/image.py b/blockrun_llm/image.py index c46f18c..2084f56 100644 --- a/blockrun_llm/image.py +++ b/blockrun_llm/image.py @@ -41,6 +41,7 @@ validate_private_key, validate_resource_url, ) +from .tx_log import paid_request_error_prefix # Load environment variables @@ -365,7 +366,7 @@ def _handle_payment_and_retry( except Exception: error_body = {"error": "Request failed"} raise APIError( - f"API error after payment: {retry_response.status_code}", + f"{paid_request_error_prefix(retry_response.headers)}: {retry_response.status_code}", retry_response.status_code, sanitize_error_response(error_body), ) diff --git a/blockrun_llm/music.py b/blockrun_llm/music.py index 1124e91..16058db 100644 --- a/blockrun_llm/music.py +++ b/blockrun_llm/music.py @@ -42,6 +42,7 @@ validate_api_url, sanitize_error_response, ) +from .tx_log import paid_request_error_prefix load_dotenv() @@ -238,7 +239,7 @@ def _handle_payment_and_retry( except Exception: error_body = {"error": "Request failed"} raise APIError( - f"API error after payment: {retry_response.status_code}", + f"{paid_request_error_prefix(retry_response.headers)}: {retry_response.status_code}", retry_response.status_code, sanitize_error_response(error_body), ) diff --git a/blockrun_llm/phone.py b/blockrun_llm/phone.py index f391d45..f3ee5e1 100644 --- a/blockrun_llm/phone.py +++ b/blockrun_llm/phone.py @@ -56,6 +56,7 @@ extract_payment_details, parse_payment_required, ) +from .tx_log import paid_request_error_prefix load_dotenv() @@ -300,7 +301,7 @@ def _unwrap(response: httpx.Response, *, after_payment: bool = False) -> Dict[st error_body = response.json() except Exception: error_body = {"error": "Request failed"} - prefix = "API error after payment" if after_payment else "API error" + prefix = paid_request_error_prefix(response.headers) if after_payment else "API error" raise APIError( f"{prefix}: {response.status_code}", response.status_code, diff --git a/blockrun_llm/portrait.py b/blockrun_llm/portrait.py index d90939d..395bb43 100644 --- a/blockrun_llm/portrait.py +++ b/blockrun_llm/portrait.py @@ -59,6 +59,7 @@ sanitize_error_response, validate_resource_url, ) +from .tx_log import paid_request_error_prefix load_dotenv() @@ -276,7 +277,7 @@ def _handle_payment_and_retry( ) if retry.status_code != 200: - self._raise_api_error(retry, "Enrollment failed after payment") + self._raise_api_error(retry, f"Enrollment: {paid_request_error_prefix(retry.headers)}") return PortraitEnrollment(**retry.json()) diff --git a/blockrun_llm/price.py b/blockrun_llm/price.py index 45ea083..91b3e89 100644 --- a/blockrun_llm/price.py +++ b/blockrun_llm/price.py @@ -46,6 +46,7 @@ validate_api_url, sanitize_error_response, ) +from .tx_log import paid_request_error_prefix load_dotenv() @@ -285,7 +286,7 @@ def _pay_and_retry( except Exception: error_body = {"error": "Request failed"} raise APIError( - f"API error after payment: {retry.status_code}", + f"{paid_request_error_prefix(retry.headers)}: {retry.status_code}", retry.status_code, sanitize_error_response(error_body), ) diff --git a/blockrun_llm/realface.py b/blockrun_llm/realface.py index e0bb226..d51f3ec 100644 --- a/blockrun_llm/realface.py +++ b/blockrun_llm/realface.py @@ -82,6 +82,7 @@ sanitize_error_response, validate_resource_url, ) +from .tx_log import paid_request_error_prefix load_dotenv() @@ -443,7 +444,9 @@ def _handle_payment_and_retry( ) if retry.status_code != 200: - self._raise_api_error(retry, "RealFace enrollment failed after payment") + self._raise_api_error( + retry, f"RealFace enrollment: {paid_request_error_prefix(retry.headers)}" + ) return RealFaceEnrollment(**retry.json()) diff --git a/blockrun_llm/rpc.py b/blockrun_llm/rpc.py index 5ec2dbd..20369e4 100644 --- a/blockrun_llm/rpc.py +++ b/blockrun_llm/rpc.py @@ -59,6 +59,7 @@ validate_api_url, sanitize_error_response, ) +from .tx_log import paid_request_error_prefix load_dotenv() @@ -378,7 +379,7 @@ def _handle_payment_and_retry( except Exception: error_body = {"error": "Request failed"} raise APIError( - f"API error after payment: {retry_response.status_code}", + f"{paid_request_error_prefix(retry_response.headers)}: {retry_response.status_code}", retry_response.status_code, sanitize_error_response(error_body), ) diff --git a/blockrun_llm/search.py b/blockrun_llm/search.py index 8549790..0e4882d 100644 --- a/blockrun_llm/search.py +++ b/blockrun_llm/search.py @@ -32,6 +32,7 @@ validate_api_url, sanitize_error_response, ) +from .tx_log import paid_request_error_prefix load_dotenv() @@ -195,7 +196,7 @@ def _handle_payment_and_retry( except Exception: error_body = {"error": "Request failed"} raise APIError( - f"API error after payment: {retry.status_code}", + f"{paid_request_error_prefix(retry.headers)}: {retry.status_code}", retry.status_code, sanitize_error_response(error_body), ) diff --git a/blockrun_llm/solana_client.py b/blockrun_llm/solana_client.py index 1ea6ffd..ea0bc67 100644 --- a/blockrun_llm/solana_client.py +++ b/blockrun_llm/solana_client.py @@ -53,7 +53,13 @@ chunk_usage_dict, ) from .solana_wallet import get_solana_public_key -from .tx_log import TransactionLogger, decode_settlement_header, _resolve_log_dir +from .tx_log import ( + TransactionLogger, + decode_settlement_header, + paid_request_error_prefix, + read_settlement_header, + _resolve_log_dir, +) from .price import Category, Market, Resolution, Session from .realface import _GROUP_ID_RE from .validation import ( @@ -559,12 +565,15 @@ def _capture_settlement(self, response: httpx.Response) -> Optional[Dict[str, An """Decode the x402 settlement header on a Solana paid response. Solana facilitators put the on-chain transaction signature in the - same ``X-PAYMENT-RESPONSE`` header EVM does — different chain id, - same wire format. ``None`` when no header is returned. + same ``PAYMENT-RESPONSE`` header EVM does — different chain id, same + wire format. ``None`` when no header is returned. + + Absence does NOT mean the call was free: this gateway's paid chat + path settles in parallel with the upstream call and re-raises at + once, so a charged-but-failed request answers before settlement + lands. See ``paid_request_error_prefix``. """ - header = response.headers.get("x-payment-response") or response.headers.get( - "X-PAYMENT-RESPONSE" - ) + header = read_settlement_header(response.headers) settlement = decode_settlement_header(header) self._last_settlement = settlement return settlement @@ -1067,7 +1076,7 @@ def _raise_stream_error(response: httpx.Response, *, after_payment: bool) -> Non error_body = response.json() except Exception: error_body = {"error": "Stream request failed"} - prefix = "API error after payment" if after_payment else "API error" + prefix = paid_request_error_prefix(response.headers) if after_payment else "API error" raise APIError( f"{prefix}: {response.status_code}", response.status_code, @@ -1177,7 +1186,7 @@ def _handle_payment_and_retry( except Exception: error_body = {"error": "Request failed"} raise APIError( - f"API error after payment: {retry_response.status_code}", + f"{paid_request_error_prefix(retry_response.headers)}: {retry_response.status_code}", retry_response.status_code, sanitize_error_response(error_body), ) @@ -1301,7 +1310,7 @@ def _handle_payment_and_retry_raw( except Exception: error_body = {"error": "Request failed"} raise APIError( - f"API error after payment: {retry_response.status_code}", + f"{paid_request_error_prefix(retry_response.headers)}: {retry_response.status_code}", retry_response.status_code, sanitize_error_response(error_body), ) @@ -1408,7 +1417,7 @@ def _handle_get_payment_and_retry( except Exception: error_body = {"error": "Request failed"} raise APIError( - f"API error after payment: {retry_response.status_code}", + f"{paid_request_error_prefix(retry_response.headers)}: {retry_response.status_code}", retry_response.status_code, sanitize_error_response(error_body), ) @@ -1567,7 +1576,7 @@ def _request_image_with_payment( except Exception: error_body = {"error": "Request failed"} raise APIError( - f"Image request after payment: HTTP {submit_resp.status_code}", + f"Image request failed: {paid_request_error_prefix(submit_resp.headers)}: HTTP {submit_resp.status_code}", submit_resp.status_code, sanitize_error_response(error_body), ) @@ -2840,9 +2849,7 @@ async def _sign_payment(self, payment_required: Any) -> Any: def _capture_settlement(self, response: httpx.Response) -> Optional[Dict[str, Any]]: """Async-Solana twin of :meth:`SolanaLLMClient._capture_settlement`.""" - header = response.headers.get("x-payment-response") or response.headers.get( - "X-PAYMENT-RESPONSE" - ) + header = read_settlement_header(response.headers) settlement = decode_settlement_header(header) self._last_settlement = settlement return settlement @@ -3347,7 +3354,7 @@ async def _handle_payment_and_retry( except Exception: error_body = {"error": "Request failed"} raise APIError( - f"API error after payment: {retry_response.status_code}", + f"{paid_request_error_prefix(retry_response.headers)}: {retry_response.status_code}", retry_response.status_code, sanitize_error_response(error_body), ) @@ -3414,7 +3421,7 @@ async def _request_with_payment_raw( except Exception: error_body = {"error": "Request failed"} raise APIError( - f"API error after payment: {retry_response.status_code}", + f"{paid_request_error_prefix(retry_response.headers)}: {retry_response.status_code}", retry_response.status_code, sanitize_error_response(error_body), ) @@ -3483,7 +3490,7 @@ async def _get_with_payment_raw( except Exception: error_body = {"error": "Request failed"} raise APIError( - f"API error after payment: {retry_response.status_code}", + f"{paid_request_error_prefix(retry_response.headers)}: {retry_response.status_code}", retry_response.status_code, sanitize_error_response(error_body), ) @@ -4187,7 +4194,7 @@ async def _request_image_with_payment( except Exception: error_body = {"error": "Request failed"} raise APIError( - f"Image request after payment: HTTP {submit_resp.status_code}", + f"Image request failed: {paid_request_error_prefix(submit_resp.headers)}: HTTP {submit_resp.status_code}", submit_resp.status_code, sanitize_error_response(error_body), ) diff --git a/blockrun_llm/speech.py b/blockrun_llm/speech.py index fb3eb98..97f7fdd 100644 --- a/blockrun_llm/speech.py +++ b/blockrun_llm/speech.py @@ -48,6 +48,7 @@ validate_api_url, sanitize_error_response, ) +from .tx_log import paid_request_error_prefix load_dotenv() @@ -327,7 +328,7 @@ def _handle_payment_and_retry( except Exception: error_body = {"error": "Request failed"} raise APIError( - f"API error after payment: {retry_response.status_code}", + f"{paid_request_error_prefix(retry_response.headers)}: {retry_response.status_code}", retry_response.status_code, sanitize_error_response(error_body), ) diff --git a/blockrun_llm/surf.py b/blockrun_llm/surf.py index b4a643a..2711b48 100644 --- a/blockrun_llm/surf.py +++ b/blockrun_llm/surf.py @@ -53,6 +53,7 @@ extract_payment_details, parse_payment_required, ) +from .tx_log import paid_request_error_prefix load_dotenv() @@ -394,7 +395,7 @@ def _unwrap(response: httpx.Response, *, after_payment: bool = False) -> Dict[st error_body = response.json() except Exception: error_body = {"error": "Request failed"} - prefix = "API error after payment" if after_payment else "API error" + prefix = paid_request_error_prefix(response.headers) if after_payment else "API error" raise APIError( f"{prefix}: {response.status_code}", response.status_code, diff --git a/blockrun_llm/tx_log.py b/blockrun_llm/tx_log.py index a29b03e..1fe2ece 100644 --- a/blockrun_llm/tx_log.py +++ b/blockrun_llm/tx_log.py @@ -44,12 +44,42 @@ # --------------------------------------------------------------------------- -# Settlement header decoding (X-PAYMENT-RESPONSE → on-chain dict) +# Settlement header decoding (PAYMENT-RESPONSE → on-chain dict) # --------------------------------------------------------------------------- +# The settlement header has two names on the wire, and the one our gateways +# actually send is NOT the one this SDK was written against. Both BlockRun +# gateways emit ``PAYMENT-RESPONSE`` (the x402 v2 spec name) and neither ever +# emits ``X-PAYMENT-RESPONSE``; reading only the legacy name decodes nothing at +# all against production. The sidecar hit exactly this and fixed it in +# blockrun-litellm 0.6.0, live-verified against a real paid call. +# +# The legacy name stays accepted: other x402 facilitators still send it, and an +# unknown header costs nothing to check. Order matters only if both are present, +# in which case the spec name wins. +_SETTLEMENT_HEADER_NAMES = ("PAYMENT-RESPONSE", "X-PAYMENT-RESPONSE") + + +def read_settlement_header(headers: Any) -> Optional[str]: + """Pull the raw settlement header out of a response, under either name. + + Single source of truth for the header name — call sites must not hand-roll + the fallback, which is how the SDK ended up reading only the legacy name in + four separate places. Never raises: a header mapping that doesn't behave + like one yields ``None`` rather than exploding on an error path. + """ + try: + for name in _SETTLEMENT_HEADER_NAMES: + value = headers.get(name) + if value: + return value + except Exception: + return None + return None + def decode_settlement_header(header_value: Optional[str]) -> Optional[Dict[str, Any]]: - """Decode an ``X-PAYMENT-RESPONSE`` header into a settlement dict. + """Decode a ``PAYMENT-RESPONSE`` header into a settlement dict. The x402 facilitator returns a base64-encoded JSON describing what landed on chain. Field names vary by chain — EVM uses ``transaction``, @@ -84,6 +114,57 @@ def decode_settlement_header(header_value: Optional[str]) -> Optional[Dict[str, } +def paid_request_error_prefix(headers: Any) -> str: + """Error prefix for a failed request that carried a payment header. + + This used to be the flat string "API error after payment", which reads as + *your money is gone* — usually false, and it cost real time: a 500 from an + image edit was read as a lost payment by two separate readers and reported + as real spend before anyone checked the gateway. The wording manufactured + the false alarm. + + The fix is to report only what is known, which is less than it looks: + + * settlement present → funds **did** move; say so, and name the tx. + * settlement absent → **unknown**, and it must not be read as "free". + + Absence is genuinely uninformative, in two ways that bite: + + 1. Base settles synchronously after the upstream call, so absence there + usually does mean nothing moved. Solana's paid chat path settles + *in parallel* with the upstream call and re-raises immediately + (``logChargedButFailed(...); throw primaryError``) — the response is on + the wire before settlement lands. So on the one path where the caller is + charged for a 5xx and the gateway logs ``CHARGED BUT REQUEST FAILED — + refund manually``, the error carries **no header at all**. Absence and + "you were charged" co-occur *systematically*, not by chance. + 2. A gateway could always settle and omit the header. + + Hence the hedge names the usual case without asserting it. Claiming "payment + likely not taken" would replace a false alarm with a false all-clear, on + exactly the requests that need a manual refund — the worse of the two errors + for anyone reconciling spend. + + Gated on ``tx_hash``, never on the header's ``success`` field: our gateways + hard-code ``success: true`` even when settle didn't land, so that clients + parsing the header don't surface a spurious error. A tx hash is the only + thing in there that means money moved — the gateways gate their own revenue + accounting on exactly the same field. + """ + settlement = None + try: + settlement = decode_settlement_header(read_settlement_header(headers)) + except Exception: + settlement = None + if settlement and settlement.get("tx_hash"): + return f"API error after settlement (payment SETTLED, tx {settlement['tx_hash']})" + return ( + "API error on the paid request (no settlement reported — a failed call " + "usually moves no funds, but settlement can land after the error; check " + "your wallet history before assuming nothing was charged)" + ) + + # --------------------------------------------------------------------------- # Path resolution # --------------------------------------------------------------------------- diff --git a/blockrun_llm/voice.py b/blockrun_llm/voice.py index b0e21e3..db39288 100644 --- a/blockrun_llm/voice.py +++ b/blockrun_llm/voice.py @@ -46,6 +46,7 @@ validate_api_url, sanitize_error_response, ) +from .tx_log import paid_request_error_prefix load_dotenv() @@ -339,7 +340,7 @@ def _handle_payment_and_retry( except Exception: error_body = {"error": "Request failed"} raise APIError( - f"API error after payment: {retry_response.status_code}", + f"{paid_request_error_prefix(retry_response.headers)}: {retry_response.status_code}", retry_response.status_code, sanitize_error_response(error_body), ) diff --git a/pyproject.toml b/pyproject.toml index 6aa8f94..bf71973 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "blockrun-llm" -version = "1.7.0" +version = "1.7.1" description = "BlockRun SDK - Pay-per-request AI (LLM, Image, Video, Music, Voice) via x402 on Base and Solana" readme = "README.md" license = "MIT" diff --git a/tests/unit/test_paid_request_error_prefix.py b/tests/unit/test_paid_request_error_prefix.py new file mode 100644 index 0000000..f023e42 --- /dev/null +++ b/tests/unit/test_paid_request_error_prefix.py @@ -0,0 +1,137 @@ +"""The paid-request error message must not claim money moved — or that it didn't. + +"API error after payment" read as *your funds are gone* on every failure, which +is usually false. This is a regression test for a real misdiagnosis: an +image-edit 500 was reported as lost USDC by two readers before anyone checked +the gateway's settle ordering. The wording alone caused it. + +The second half of the file guards the opposite error, which is worse. Both +gateways send the settlement under the x402 v2 name ``PAYMENT-RESPONSE`` and +neither ever sends ``X-PAYMENT-RESPONSE`` — so reading only the legacy name +decodes nothing in production and reports every settled failure as unsettled. +And on Solana's paid chat path, settle runs in parallel with the upstream call +and the error re-raises before it lands, so the requests that DID charge (the +ones the gateway logs as ``CHARGED BUT REQUEST FAILED — refund manually``) are +exactly the ones arriving with no header. Absence cannot be sold as "you weren't +charged". +""" + +import base64 +import json + +import httpx +import pytest + +from blockrun_llm.tx_log import paid_request_error_prefix, read_settlement_header + +# What our gateways actually send (x402 v2). The legacy name is still accepted +# for other facilitators, so both are exercised everywhere it matters. +SPEC_NAME = "PAYMENT-RESPONSE" +LEGACY_NAME = "X-PAYMENT-RESPONSE" +BOTH_NAMES = [SPEC_NAME, LEGACY_NAME] + + +def _settlement_header(tx_hash="0xabc123", **extra): + payload = {"transaction": tx_hash, "network": "base", "success": True, **extra} + return base64.b64encode(json.dumps(payload).encode()).decode() + + +class TestHeaderName: + """The bug that made the whole mechanism dead code in production.""" + + def test_spec_name_is_read(self): + """Regression: the SDK read only the legacy name, which no gateway sends. + + blockrun and blockrun-sol emit `PAYMENT-RESPONSE` (36 and 25 call sites + respectively) and `X-PAYMENT-RESPONSE` zero times. The sidecar hit this + in blockrun-litellm 0.6.0, live-verified against a real paid call. + """ + msg = paid_request_error_prefix(httpx.Headers({SPEC_NAME: _settlement_header("0xfeed")})) + assert "SETTLED" in msg and "0xfeed" in msg + + def test_legacy_name_still_accepted(self): + msg = paid_request_error_prefix(httpx.Headers({LEGACY_NAME: _settlement_header("0xbeef")})) + assert "SETTLED" in msg and "0xbeef" in msg + + def test_spec_name_wins_when_both_present(self): + headers = httpx.Headers( + {SPEC_NAME: _settlement_header("0xspec"), LEGACY_NAME: _settlement_header("0xlegacy")} + ) + assert "0xspec" in paid_request_error_prefix(headers) + + def test_reader_never_raises_on_a_junk_mapping(self): + class Hostile: + def get(self, _name): + raise RuntimeError("headers exploded") + + assert read_settlement_header(Hostile()) is None + + +class TestUnsettled: + """No settlement header means UNKNOWN, and must never be sold as "free".""" + + def test_no_header_does_not_claim_payment_was_taken(self): + msg = paid_request_error_prefix(httpx.Headers({})) + assert "SETTLED" not in msg + # The specific phrase that caused the false alarm must be gone. + assert "after payment" not in msg + + def test_no_header_does_not_claim_payment_was_NOT_taken(self): + """The inverse error, and the more expensive one. + + Solana settles in parallel and re-raises before settle lands, so a + charged-but-failed request carries no header. Promising "payment likely + not taken" there is a false all-clear on exactly the request that needs + a manual refund. + """ + msg = paid_request_error_prefix(httpx.Headers({})) + assert "likely not taken" not in msg + assert "check your wallet history" in msg, "must point somewhere authoritative" + + @pytest.mark.parametrize("name", BOTH_NAMES) + @pytest.mark.parametrize( + "bad", ["", "!!!not-base64!!!", "e30=", base64.b64encode(b"[]").decode()] + ) + def test_unparseable_header_degrades_to_unsettled(self, name, bad): + """An error path must never raise while reporting an error.""" + msg = paid_request_error_prefix(httpx.Headers({name: bad})) + assert "no settlement reported" in msg + + @pytest.mark.parametrize("name", BOTH_NAMES) + def test_header_without_tx_hash_is_not_a_settlement(self, name): + payload = base64.b64encode(json.dumps({"network": "base"}).encode()).decode() + msg = paid_request_error_prefix(httpx.Headers({name: payload})) + assert "no settlement reported" in msg + + def test_success_true_without_tx_hash_is_still_unsettled(self): + """`success` is not a settle signal: the gateways hard-code it to true + even when settle didn't land, so older clients don't surface an error. + A tx hash is the only field that means money moved — which is what the + gateways gate their own revenue accounting on.""" + payload = base64.b64encode(json.dumps({"success": True, "network": "base"}).encode()) + msg = paid_request_error_prefix(httpx.Headers({SPEC_NAME: payload.decode()})) + assert "SETTLED" not in msg + + +class TestSettled: + """A settlement header means funds really did move — say so, and name the tx.""" + + def test_reports_settlement_and_tx_hash(self): + msg = paid_request_error_prefix( + httpx.Headers({SPEC_NAME: _settlement_header("0xdeadbeef")}) + ) + assert "SETTLED" in msg + assert "0xdeadbeef" in msg, "the tx hash is what makes this actionable" + + @pytest.mark.parametrize("name", BOTH_NAMES) + def test_solana_signature_field_also_counts(self, name): + payload = base64.b64encode(json.dumps({"signature": "5xSolSig"}).encode()).decode() + msg = paid_request_error_prefix(httpx.Headers({name: payload})) + assert "SETTLED" in msg and "5xSolSig" in msg + + +def test_the_two_cases_are_distinguishable(): + """The whole point: they used to be the same string.""" + unsettled = paid_request_error_prefix(httpx.Headers({})) + settled = paid_request_error_prefix(httpx.Headers({SPEC_NAME: _settlement_header()})) + assert unsettled != settled