From af9cdb4dabfab1b8589e00012a425a0cb5944d57 Mon Sep 17 00:00:00 2001 From: 1bcMax Date: Wed, 30 Sep 2026 22:50:29 +0800 Subject: [PATCH 1/6] feat(x402): pay the actual cost on Base with upto (Permit2), never worse than exact Under exact the wallet signs a fixed pre-call quote, so a discount the gateway only learns after the call (a DeepSeek prompt-cache hit) can never reach an x402 caller. When a 402 also offers `upto`, LLMClient / AsyncLLMClient chat calls (non-stream and stream) now sign a Permit2 PermitWitnessTransferFrom for the ceiling and the gateway settles the actual amount. Taken only when it cannot be worse than exact: an EVM upto offer with extra.facilitatorAddress for USDC on a network in EVM_NETWORKS, a USDC balance covering the ceiling, and either a Permit2 allowance covering it or a declared eip2612GasSponsoring extension (then a gasless EIP-2612 permit for exactly the ceiling rides along, so a wallet with no ETH works). Any RPC or signing error, or a ceiling over a spend limit, signs exact as before. A payment rejected before anything is served is retried once with exact and the client stays on exact for that network. One gas-sponsored permit per wallet+network in flight, tracked by the USDC nonce it signs over, since a second permit over the same nonce reverts on-chain. Signing is byte-identical to the official @x402/evm 2.28.0 client: the tests pin a vector that client generated (scripts/gen-upto-vector.mjs). A ceiling is not a charge: ChatResponse / chunk payment_scheme and cost_is_ceiling, get_spending()["ceiling_usd"], cost_basis on cost-log rows, and a marker in transactions.log. A settled amount from PAYMENT-RESPONSE is booked when reported. Opt out with payment_scheme="exact" or BLOCKRUN_PAYMENT_SCHEME=exact. --- CHANGELOG.md | 50 + README.md | 48 +- blockrun_llm/cache.py | 22 +- blockrun_llm/client.py | 830 ++++++++++----- blockrun_llm/tx_log.py | 15 +- blockrun_llm/types.py | 7 + blockrun_llm/x402.py | 42 +- blockrun_llm/x402_upto.py | 788 ++++++++++++++ scripts/gen-upto-vector.mjs | 144 +++ tests/unit/test_x402_upto.py | 1094 ++++++++++++++++++++ tests/unit/x402_upto_reference_vector.json | 89 ++ 11 files changed, 2841 insertions(+), 288 deletions(-) create mode 100644 blockrun_llm/x402_upto.py create mode 100644 scripts/gen-upto-vector.mjs create mode 100644 tests/unit/test_x402_upto.py create mode 100644 tests/unit/x402_upto_reference_vector.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 8eacdf2..0e3c5d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,56 @@ All notable changes to blockrun-llm will be documented in this file. +## Unreleased + +### Added +- **x402 `upto` (Permit2) on Base: pay the actual cost, not the quote.** Under + `exact` the wallet signs a fixed pre-call quote, so a discount the gateway + only learns after the call — a DeepSeek prompt-cache hit is $0.028/M input + instead of $0.14/M — could never reach an x402 caller. When a 402 also offers + `upto` (listed after `exact`), `LLMClient` / `AsyncLLMClient` chat calls + (non-stream and stream) now sign a Permit2 `PermitWitnessTransferFrom` for the + ceiling and the gateway settles the actual amount. + + Taken only when it cannot be worse than `exact`: the offer carries + `extra.facilitatorAddress` for USDC on a network in `EVM_NETWORKS`; the wallet + holds the ceiling; and Permit2 may already pull it, or the 402 declares + `eip2612GasSponsoring` — then a gasless EIP-2612 permit approving Permit2 for + exactly the ceiling rides along and the facilitator submits it, so a wallet + with no ETH works. (Exactly the ceiling: the upto proxy reverts with + `Permit2612AmountMismatch` on any other value, and CDP rejects MaxUint256.) + Balance / allowance / nonce come from public Base RPCs with a 3 s timeout + each. Anything else — no RPC, a signing error, a ceiling over a spend limit — + signs `exact` as before (debug log only). A payment the gateway rejects + before serving anything is retried once with `exact`, and that client stays + on exact for that network. + + Per wallet and network only one gas-sponsored upto payment is in flight: a + second permit over the same USDC nonce reverted on-chain in a live test of + the TS SDK. The SDK records the nonce each permit signs over and, while the + on-chain nonce has not moved and the permit's deadline has not passed, pays + exact for any call that would need another. Solana, the Anthropic client and + every non-chat endpoint are unchanged. + + Signing matches the official `@x402/evm` 2.28.0 client byte for byte: the + tests pin a vector it generated (`scripts/gen-upto-vector.mjs`). + + Opt out with `payment_scheme="exact"` or `BLOCKRUN_PAYMENT_SCHEME=exact`. +- **A ceiling is not a charge.** `ChatResponse.payment_scheme` / + `cost_is_ceiling` (also on stream chunks), `get_spending()["ceiling_usd"]`, + a `cost_basis` field on cost-log rows (`upto_settled` / `upto_ceiling`) and a + `(upto ceiling)` marker in `transactions.log`. An upto call books the settled + amount when `PAYMENT-RESPONSE` reports one, else the ceiling, labeled. Spend + limits count the ceiling in full. + +### Changed +- Exact chat payments book and cap on the signed requirement's amount when the + 402 also offers upto (the body's `price` is then the upto ceiling), and the + async client books a paid chat call the same way the sync one does. +- `extract_payment_details` picks the first non-`upto` requirement instead of + blindly taking `accepts[0]`. +- `get_balance()` reads its USDC contract and RPC list from `EVM_NETWORKS`. + ## 1.17.1 — 2026-09-30 ### Fixed diff --git a/README.md b/README.md index c55f5fe..e7a3659 100644 --- a/README.md +++ b/README.md @@ -392,12 +392,57 @@ That single call does all of this under the hood: One call, no separate pay step. +#### Pay the actual cost: x402 `upto` (Base) + +By default (`payment_scheme="auto"`) a chat call whose 402 also offers the x402 +`upto` scheme pays with a Permit2 signature for a **ceiling** instead of an +`exact` EIP-3009 transfer for a fixed quote. The gateway then settles the +**actual** cost after the call — never more than the ceiling — so discounts it +only learns afterwards, such as prompt-cache hits, reach you. The gateway lists +`exact` first; upto is taken only when all of these hold: + +- the 402 offers an EVM `upto` requirement with `extra.facilitatorAddress`, for + USDC on a network the SDK signs on (Base, Base Sepolia); +- your wallet holds at least the ceiling in USDC; +- Permit2 can already pull the ceiling from your wallet, **or** the gateway + declares the `eip2612GasSponsoring` extension. In that case the SDK also signs + a gasless EIP-2612 permit approving the canonical Permit2 contract + (`0x0000…78BA3`) for exactly the ceiling, and the facilitator submits it — + **no ETH needed**; +- the ceiling fits your `max_cost_per_call` / `max_session_cost`. + +Otherwise — or on any RPC or signing error — the SDK signs `exact` exactly as +before. If the gateway rejects an upto payment before serving anything, the +request is retried once with `exact`, and that client pays exact on that network +from then on. Solana is unchanged (exact only). + +**Per wallet, only one gas-sponsored upto payment can be in flight.** Its permit +lands only when that call settles, and until then the chain still shows the old +USDC permit nonce — a second permit over the same nonce would revert on-chain. +So concurrent or not-yet-settled calls that would need a permit pay `exact` +(the SDK watches the on-chain nonce, and gives up on a permit at its deadline). +Calls where Permit2 can already pull the ceiling are unaffected. + +```python +LLMClient(payment_scheme="exact") # opt out; or BLOCKRUN_PAYMENT_SCHEME=exact +``` + +The signed ceiling is an upper bound, not a charge. `ChatResponse.payment_scheme` +says which scheme paid; when the gateway's `PAYMENT-RESPONSE` reports the settled +amount, `cost_usd` is that amount; when it does not (always, for streams, whose +header arrives before settlement), `cost_usd` is the ceiling and +`cost_is_ceiling` is `True`. `get_spending()["ceiling_usd"]` is the part of +`total_usd` booked at a ceiling, cost-log rows carry `cost_basis` +(`upto_settled` / `upto_ceiling`), and `transactions.log` marks ceiling rows. +Spend limits count ceilings in full, so they err on the safe side. + ### What it costs, and how to verify it - **Pay-as-you-go, per call.** You pay only the gateway price of each request (see [Available Models](#available-models)). The free NVIDIA models are `$0`. - **Track spend.** `client.get_spending()` returns this session's - `{total_usd, calls}`. On the API-key rail the gateway does not tell the client + `{total_usd, calls, ceiling_usd}` (`ceiling_usd`: see + [upto](#pay-the-actual-cost-x402-upto-base)). On the API-key rail the gateway does not tell the client what a call cost, so treat that total as a floor and [user.blockrun.ai/dashboard](https://user.blockrun.ai/dashboard) as the authority. Every paid call also appends a line to @@ -1586,6 +1631,7 @@ optional. | `SOLANA_RPC_URL` / `SOLANA_RPC_HEADERS` / `SOLANA_RPC_API_KEY` | RPC for blockhash + mint info while signing | BlockRun's free proxy | | `BLOCKRUN_CHAT_TIMEOUT` | Chat HTTP timeout, in seconds | `600` | | `BLOCKRUN_MAX_COST_PER_CALL` / `BLOCKRUN_MAX_SESSION_COST` | Opt-in spend limits (wallet rail) | unlimited | +| `BLOCKRUN_PAYMENT_SCHEME` | `auto` (prefer x402 `upto` when the gateway offers it and it is safe) or `exact` | `auto` | `BLOCKRUN_API_KEY_URL` is deliberately not `BLOCKRUN_API_URL`: that one names an x402 gateway, and an API-key client must never follow it and send your key to a diff --git a/blockrun_llm/cache.py b/blockrun_llm/cache.py index 2f9f079..7a77c2d 100644 --- a/blockrun_llm/cache.py +++ b/blockrun_llm/cache.py @@ -149,10 +149,16 @@ def save_to_cache( wallet: str | None = None, network: str | None = None, client_kind: str | None = None, + cost_basis: str | None = None, ) -> None: """ Save a paid API response locally. + ``cost_basis`` labels a cost that is not a plain exact charge: an x402 upto + call records ``"upto_settled"`` (the gateway reported the settled amount) + or ``"upto_ceiling"`` (``cost_usd`` is the signed ceiling — an upper bound, + not a confirmed charge). ``None`` (exact) writes the same rows as before. + 1. Hash-keyed cache file (for TTL-based dedup) 2. Human-readable data file (browsable archive of every paid call) 3. Cost log entry (with billing metadata when supplied) @@ -167,6 +173,8 @@ def save_to_cache( "response": response, "cost_usd": cost_usd, } + if cost_basis is not None: + entry["cost_basis"] = cost_basis try: _cache_path(key).write_text(json.dumps(entry, default=str)) @@ -174,7 +182,7 @@ def save_to_cache( pass # Save human-readable copy to ~/.blockrun/data/ - _save_readable(endpoint, body, response, cost_usd) + _save_readable(endpoint, body, response, cost_usd, cost_basis=cost_basis) # Append to the cost log (never overwritten). Pull model from the body # if the caller didn't pass one explicitly. @@ -185,6 +193,7 @@ def save_to_cache( wallet=wallet, network=network, client_kind=client_kind, + cost_basis=cost_basis, ) @@ -193,6 +202,8 @@ def _save_readable( body: dict[str, Any], response: dict[str, Any], cost_usd: float, + *, + cost_basis: str | None = None, ) -> None: """Save a human-readable JSON file to ~/.blockrun/data/.""" DATA_DIR.mkdir(parents=True, exist_ok=True) @@ -204,6 +215,8 @@ def _save_readable( "request": body, "response": response, } + if cost_basis is not None: + entry["cost_basis"] = cost_basis try: (DATA_DIR / filename).write_text(json.dumps(entry, indent=2, default=str)) except OSError: @@ -218,12 +231,15 @@ def _append_cost_log( wallet: str | None = None, network: str | None = None, client_kind: str | None = None, + cost_basis: str | None = None, ) -> None: """Append one JSONL row to ``~/.blockrun/cost_log.jsonl``. The full schema is:: - {ts, endpoint, cost_usd, model, wallet, network, client_kind} + {ts, endpoint, cost_usd, model, wallet, network, client_kind, cost_basis} + + ``cost_basis`` is present only for x402 upto calls (see ``save_to_cache``). Older rows that only carry ``{ts, endpoint, cost_usd}`` are still readable — missing fields surface as ``None`` in summary / export views. @@ -247,6 +263,8 @@ def _append_cost_log( entry["network"] = network if client_kind is not None: entry["client_kind"] = client_kind + if cost_basis is not None: + entry["cost_basis"] = cost_basis f.write(json.dumps(entry) + "\n") except OSError: pass diff --git a/blockrun_llm/client.py b/blockrun_llm/client.py index 56cf4ea..4cc2b0a 100644 --- a/blockrun_llm/client.py +++ b/blockrun_llm/client.py @@ -43,8 +43,8 @@ import os import re import sys -from collections.abc import AsyncIterator, Iterator -from typing import Any +from collections.abc import AsyncGenerator, AsyncIterator, Iterator +from typing import Any, Callable, NamedTuple, NoReturn import httpx from dotenv import load_dotenv @@ -85,6 +85,7 @@ SearchResult, SmartChatCompletionResponse, SmartChatResponse, + SpendLimitError, chunk_meta, chunk_usage_dict, retry_after_of, @@ -104,7 +105,22 @@ validate_temperature, validate_top_p, ) -from .x402 import create_payment_payload, extract_payment_details, parse_payment_required +from .x402 import ( + EVM_NETWORKS, + create_payment_payload, + extract_payment_details, + parse_payment_required, +) +from .x402_upto import ( + UptoOption, + UptoPayment, + atry_upto_payment, + find_upto_option, + offers_upto, + resolve_payment_scheme, + settled_upto_amount, + try_upto_payment, +) # Load environment variables load_dotenv() @@ -336,6 +352,228 @@ def _enforce_spend_limits(client: Any, cost_usd: float, model: str | None = None ) +# --------------------------------------------------------------------------- +# Chat payment signing: exact (EIP-3009) or upto (Permit2) +# --------------------------------------------------------------------------- + + +class _ChatPayment(NamedTuple): + """A signed chat payment: the paid retry's headers, and what to book for it.""" + + headers: dict[str, str] + # exact: the quote, which is what settles. upto: the signed CEILING, which + # is the most that can settle — never book it as paid when a settled + # amount is known (see _booked_cost). + cost_usd: float + scheme: str # "exact" | "upto" + amount_micro: int # the signed amount, micro-USDC + # upto only: the network signed on, and how to sign the same 402's exact + # requirement instead if the gateway rejects the upto payment. + network: str | None = None + exact_fallback: Callable[[], _ChatPayment] | None = None + + +def _is_payment_rejection(response: httpx.Response) -> bool: + """Did the gateway refuse the payment itself (before serving anything)? + + A 402 on the paid request, or a 4xx whose body is the gateway's + payment-verification failure (``error: "Payment verification failed"`` / + a ``PAYMENT_*`` code). + """ + status = response.status_code + if status == 402: + return True + if not 400 <= status < 500: + return False + try: + body = response.json() + except Exception: + return False + if not isinstance(body, dict): + return False + error = body.get("error") + if isinstance(error, dict): + error = error.get("message") + code = body.get("code") + return (isinstance(error, str) and "payment verification failed" in error.lower()) or ( + isinstance(code, str) and code.upper().startswith("PAYMENT_") + ) + + +def _exact_after_upto_rejection(client: Any, payment: _ChatPayment) -> _ChatPayment: + """The gateway rejected an upto payment: remember that for this wallet and + network (for the life of the client), and sign the 402's exact requirement. + + Called at most once per request, and only before any response body was + delivered — a 2xx or a stream in progress is never retried. + """ + assert payment.exact_fallback is not None + if client.account is not None and payment.network: + client._upto_rejected.add((client.account.address.lower(), payment.network)) + sys.stderr.write( + f"[blockrun_llm] upto payment rejected on {payment.network}; " + "retrying once with exact, and using exact for the rest of this client's life\n" + ) + return payment.exact_fallback() + + +def _payment_headers(payment_payload: str) -> dict[str, str]: + return { + "Content-Type": "application/json", + "User-Agent": _get_user_agent(), + "PAYMENT-SIGNATURE": payment_payload, + } + + +def _read_payment_required(response: httpx.Response) -> tuple[dict[str, Any], dict[str, Any]]: + """``(payment_required, price_info)`` from a 402: the ``payment-required`` + header, else an ``x402`` body (whose ``price`` then rides along).""" + payment_header: Any = response.headers.get("payment-required") + price_info: dict[str, Any] = {} + if not payment_header: + try: + resp_body = response.json() + if "x402" in resp_body: + payment_header = resp_body + price_info = resp_body.get("price", {}) + except Exception: + pass + + if not payment_header: + raise PaymentError("402 response but no payment requirements found") + + if isinstance(payment_header, str): + return parse_payment_required(payment_header), price_info + return payment_header, price_info + + +def _within_spend_limits(client: Any, body: dict[str, Any]) -> Callable[[float], bool]: + """A predicate: would signing this much pass the client's spend limits?""" + model = body.get("model") if isinstance(body, dict) else None + + def ok(cost_usd: float) -> bool: + try: + _enforce_spend_limits(client, cost_usd, model) + except SpendLimitError: + return False + return True + + return ok + + +def _upto_offer( + client: Any, + body: dict[str, Any], + payment_required: dict[str, Any], + details: dict[str, Any], +) -> tuple[UptoOption, dict[str, Any]] | None: + """The upto offer worth trying, with ``try_upto_payment``'s keyword + arguments — or ``None``, meaning sign exact exactly as before.""" + if getattr(client, "_payment_scheme", "auto") != "auto" or client.account is None: + return None + option = find_upto_option(payment_required) + if option is None: + return None + if (client.account.address.lower(), option.network) in getattr(client, "_upto_rejected", ()): + return None # this gateway already refused upto from this wallet + resource = details.get("resource") or {} + try: + resource_url = validate_resource_url( + resource.get("url", f"{client.api_url}/v1/chat/completions"), client.api_url + ) + except Exception: + return None # the exact path raises the same error, unchanged + return option, { + "resource_url": resource_url, + "resource_description": resource.get("description", "BlockRun AI API call"), + "within_limits": _within_spend_limits(client, body), + } + + +def _upto_chat_payment( + client: Any, + body: dict[str, Any], + payment_required: dict[str, Any], + price_info: dict[str, Any], + details: dict[str, Any], + option: UptoOption, + signed: UptoPayment, +) -> _ChatPayment: + _warn_if_clamped(body, (details.get("resource") or {}).get("description")) + return _ChatPayment( + _payment_headers(signed.header), + signed.ceiling_usd, + "upto", + signed.amount, + network=option.network, + exact_fallback=lambda: _exact_chat_payment( + client, body, payment_required, price_info, details + ), + ) + + +def _exact_chat_payment( + client: Any, + body: dict[str, Any], + payment_required: dict[str, Any], + price_info: dict[str, Any], + details: dict[str, Any], +) -> _ChatPayment: + """Sign the exact (EIP-3009) requirement — the path every release has used.""" + # A gateway offering upto quotes its `price` at the upto CEILING, which is + # not what exact signs; book and cap exact on its own amount then. + cost_usd = ( + float(price_info.get("amount", 0)) + if price_info and not offers_upto(payment_required) + else float(details.get("amount", 0)) / 1e6 + ) + # Before signing: a refused quote is never sent, so nothing settles. + _enforce_spend_limits(client, cost_usd, body.get("model") if isinstance(body, dict) else None) + + # SECURITY: Signing happens locally - only the signature is sent to server + resource = details.get("resource") or {} + _warn_if_clamped(body, resource.get("description")) + # Pass through extensions from server (for Bazaar discovery) + extensions = payment_required.get("extensions", {}) + payment_payload = create_payment_payload( + account=client.account, + recipient=details["recipient"], + amount=details["amount"], + network=details.get("network", "eip155:84532" if client.is_testnet() else "eip155:8453"), + resource_url=validate_resource_url( + resource.get("url", f"{client.api_url}/v1/chat/completions"), client.api_url + ), + resource_description=resource.get("description", "BlockRun AI API call"), + max_timeout_seconds=details.get("maxTimeoutSeconds", 300), + extra=details.get("extra"), + extensions=extensions, + asset=details.get("asset"), + ) + try: + amount_micro = int(str(details.get("amount", 0))) + except ValueError: + amount_micro = 0 + return _ChatPayment(_payment_headers(payment_payload), cost_usd, "exact", amount_micro) + + +def _booked_cost( + payment: _ChatPayment, settlement: dict[str, Any] | None +) -> tuple[float, str | None]: + """What to record as spent for a paid call, and on what basis. + + exact → the quote, basis ``None`` (records unchanged from earlier releases). + upto → the settled amount when ``PAYMENT-RESPONSE`` reports one + (``"upto_settled"``); otherwise the signed ceiling, labeled + ``"upto_ceiling"`` — an upper bound, not a confirmed charge. + """ + if payment.scheme != "upto": + return payment.cost_usd, None + settled = settled_upto_amount(settlement, payment.amount_micro) + if settled is not None: + return settled / 1e6, "upto_settled" + return payment.cost_usd, "upto_ceiling" + + def _detect_network(api_url: str) -> str: """Map an API URL to the canonical network label used in billing records. Returns ``base-mainnet`` / ``base-sepolia`` / ``solana-mainnet`` @@ -396,6 +634,7 @@ def __init__( transaction_log: bool | str | os.PathLike[str] | None = None, max_cost_per_call: float | None = None, max_session_cost: float | None = None, + payment_scheme: str | None = None, ): """ Initialize the BlockRun LLM client. @@ -415,6 +654,14 @@ def __init__( ``transactions.jsonl`` (model, input, output, cost_usd, tx_hash, on-chain amount, payer, payee, network) and writes a pretty-printed JSON file next to it. + payment_scheme: ``"auto"`` (default) or ``"exact"``. With ``"auto"``, a chat + call whose 402 also offers x402 ``upto`` pays with a Permit2 + ceiling and is settled at the ACTUAL cost after the call + (prompt-cache discounts included) — but only when the wallet + already has a Permit2 allowance or the gateway sponsors the + approval gaslessly; otherwise, or on any error, it signs + ``exact`` as before. ``"exact"`` never signs upto. ``None`` + honors the ``BLOCKRUN_PAYMENT_SCHEME`` env var. Raises: ValueError: If no wallet is configured. For agent use, call setup_agent_wallet() first. @@ -482,6 +729,9 @@ def __init__( # Session spending tracking self._session_total_usd: float = 0.0 + # The part of _session_total_usd that is an upto CEILING the gateway + # did not report a settled amount for: an upper bound, not a charge. + self._session_ceiling_usd: float = 0.0 # Opt-in spend limits. None (the default) means unlimited, which is the # behavior every release before 1.9.0 had: every 402 quote was signed # automatically with nothing compared against anything. @@ -489,6 +739,12 @@ def __init__( max_cost_per_call, "BLOCKRUN_MAX_COST_PER_CALL" ) self._max_session_cost = resolve_spend_limit(max_session_cost, "BLOCKRUN_MAX_SESSION_COST") + # x402 scheme preference (see x402_upto). Resolved eagerly so a typo in + # the argument or BLOCKRUN_PAYMENT_SCHEME fails at construction. + self._payment_scheme = resolve_payment_scheme(payment_scheme) + # (wallet, network) pairs whose gateway rejected an upto payment: those + # go straight to exact for the life of this client. + self._upto_rejected: set[tuple[str, str]] = set() self._session_calls: int = 0 self._last_call_cost: float = 0.0 @@ -719,7 +975,10 @@ def get_spending(self) -> dict[str, Any]: Get current session spending. Returns: - Dict with total_usd and calls count + Dict with ``total_usd``, ``calls`` and ``ceiling_usd``. ``ceiling_usd`` + is the part of ``total_usd`` booked at an x402 ``upto`` CEILING because + the gateway reported no settled amount: an upper bound on what those + calls cost, not a confirmed charge (0.0 when every call paid ``exact``). Example: spending = client.get_spending() @@ -728,8 +987,21 @@ def get_spending(self) -> dict[str, Any]: return { "total_usd": self._session_total_usd, "calls": self._session_calls, + "ceiling_usd": self._session_ceiling_usd, } + def _book_paid_call( + self, payment: _ChatPayment, settlement: dict[str, Any] | None + ) -> tuple[float, str | None]: + """Record a paid call in the session totals; returns ``_booked_cost``'s pair.""" + cost_usd, basis = _booked_cost(payment, settlement) + self._session_calls += 1 + self._session_total_usd += cost_usd + if basis == "upto_ceiling": + self._session_ceiling_usd += cost_usd + self._last_call_cost = cost_usd + return cost_usd, basis + def chat( self, model: str, @@ -1120,8 +1392,7 @@ def _stream_with_payment( timeout = self.search_timeout if is_search else self.timeout # ----- Phase 1: probe (no payment header) ----- - payment_headers: dict[str, str] | None = None - cost_usd = 0.0 + payment: _ChatPayment | None = None backoffs = self._STREAM_5XX_BACKOFFS for attempt in range(len(backoffs) + 1): @@ -1137,7 +1408,7 @@ def _stream_with_payment( # Account rail: a 402 is the account being out of credit, not a # challenge to sign. Nothing here can sign, so say so plainly. raise_for_api_key_402(resp1, self.api_key) - payment_headers, cost_usd = self._sign_payment_from_response(body, resp1) + payment = self._sign_chat_payment(body, resp1) break # advance to phase 2 if resp1.status_code in self._STREAM_5XX_STATUSES and attempt < len(backoffs): import time @@ -1155,9 +1426,11 @@ def _stream_with_payment( # Signing above was settlement. A timeout here has already been paid # for, so tag it: the stream fallback chain must not settle again on # the next model just because zero chunks arrived. - assert payment_headers is not None # break implies signing succeeded + assert payment is not None # break implies signing succeeded try: - yield from self._stream_paid_phase(url, body, payment_headers, cost_usd, timeout) + yield from self._stream_paid_phase( + url, body, payment.headers, payment.cost_usd, timeout, payment=payment + ) except (httpx.HTTPError, APIError) as exc: _mark_settled(exc) raise @@ -1169,27 +1442,46 @@ def _stream_paid_phase( payment_headers: dict[str, str], cost_usd: float, timeout: float | None, + *, + payment: _ChatPayment | None = None, + rejected: httpx.Response | None = None, ) -> Iterator[ChatCompletionChunk]: - """Phase 2 of :meth:`_stream_with_payment`: the paid, already-settled leg.""" + """Phase 2 of :meth:`_stream_with_payment`: the paid, already-settled leg. + + ``rejected`` is set on the one exact retry after the gateway refused an + upto payment: if exact is refused too, that original refusal surfaces. + """ + if payment is None: + payment = _ChatPayment(payment_headers, cost_usd, "exact", 0) + upto_refusal: httpx.Response | None = None backoffs = self._STREAM_5XX_BACKOFFS for attempt in range(len(backoffs) + 1): with self._client.stream( - "POST", url, json=body, headers=payment_headers, timeout=timeout + "POST", url, json=body, headers=payment.headers, timeout=timeout ) as resp2: if resp2.status_code == 200: + basis: str | None = None if cost_usd > 0: - self._session_calls += 1 - self._session_total_usd += cost_usd - self._last_call_cost = cost_usd - self._capture_settlement(resp2) - yield from self._iter_and_archive(resp2, body, cost_usd, streaming=True) + # A stream's PAYMENT-RESPONSE arrives before the upto + # settle, so an upto stream books its ceiling, labeled. + cost_usd, basis = self._book_paid_call( + payment, self._capture_settlement(resp2) + ) + yield from self._iter_and_archive( + resp2, + body, + cost_usd, + streaming=True, + payment_scheme=payment.scheme, + cost_basis=basis, + ) return resp2.read() - if resp2.status_code == 402: - # Account rail: a 402 is the account being out of credit, not a - # challenge to sign. Nothing here can sign, so say so plainly. - raise_for_api_key_402(resp2, self.api_key) - raise PaymentError("Payment was rejected. Check your wallet balance.") + if _is_payment_rejection(resp2): + if payment.exact_fallback is not None: + upto_refusal = resp2 # nothing streamed yet: retry exact + break + self._raise_payment_rejection(rejected if rejected is not None else resp2) if resp2.status_code in self._STREAM_5XX_STATUSES and attempt < len(backoffs): import time @@ -1197,6 +1489,28 @@ def _stream_paid_phase( continue self._raise_stream_error(resp2, after_payment=True) + if upto_refusal is not None: + exact = _exact_after_upto_rejection(self, payment) + yield from self._stream_paid_phase( + url, + body, + exact.headers, + exact.cost_usd, + timeout, + payment=exact, + rejected=upto_refusal, + ) + + def _raise_payment_rejection(self, response: httpx.Response) -> NoReturn: + """Raise for a paid request the gateway refused to accept payment for.""" + if response.status_code == 402: + # Account rail: a 402 is the account being out of credit, not a + # challenge to sign. Nothing here can sign, so say so plainly. + raise_for_api_key_402(response, self.api_key) + raise PaymentError("Payment was rejected. Check your wallet balance.") + self._raise_stream_error(response, after_payment=True) + raise AssertionError("unreachable: _raise_stream_error always raises") + def _iter_and_archive( self, response: httpx.Response, @@ -1204,6 +1518,8 @@ def _iter_and_archive( cost_usd: float, *, streaming: bool = True, + payment_scheme: str | None = None, + cost_basis: str | None = None, ) -> Iterator[ChatCompletionChunk]: """Yield each SSE chunk, accumulate content for the local archive, then once ``data: [DONE]`` arrives ``save_to_cache`` the assembled @@ -1242,6 +1558,11 @@ def _iter_and_archive( # (e.g. the blockrun-litellm adapter) read it off the chunk to # report the real wallet deduction instead of a list-price estimate. chunk.cost_usd = cost_usd + if payment_scheme is not None: + # upto: cost_usd is the signed ceiling unless the gateway + # reported the settled amount — say which. + setattr(chunk, "payment_scheme", payment_scheme) # noqa: B010 (extra field) + setattr(chunk, "cost_is_ceiling", cost_basis == "upto_ceiling") # noqa: B010 yield chunk # Stream complete (saw [DONE]). Free models have cost_usd == 0; only @@ -1274,12 +1595,15 @@ def _iter_and_archive( body, response_data, cost_usd=cost_usd, + cost_basis=cost_basis, **self._billing_meta(), ) except Exception: # Logging never breaks the call. pass - self._log_transaction("/v1/chat/completions", body, response_data, cost_usd) + self._log_transaction( + "/v1/chat/completions", body, response_data, cost_usd, cost_basis=cost_basis + ) @staticmethod def _iter_sse_chunks(response: httpx.Response) -> Iterator[ChatCompletionChunk]: @@ -1307,6 +1631,22 @@ def _iter_sse_chunks(response: httpx.Response) -> Iterator[ChatCompletionChunk]: # model construction to avoid silently dropping output. yield ChatCompletionChunk.model_construct(**chunk_dict) + def _post_paid( + self, + url: str, + body: dict[str, Any], + headers: dict[str, str], + timeout: float | None, + ) -> httpx.Response: + """The paid POST, with one automatic retry on 502/503.""" + response = self._client.post(url, json=body, headers=headers, timeout=timeout) + if response.status_code in (502, 503): + import time + + time.sleep(1) + response = self._client.post(url, json=body, headers=headers, timeout=timeout) + return response + def _sign_payment_from_response( self, body: dict[str, Any], @@ -1316,65 +1656,31 @@ def _sign_payment_from_response( Extract a 402's payment requirements, sign locally, and return ``(headers_with_PAYMENT_SIGNATURE, cost_usd)``. - Mirrors the inline signing logic in :meth:`_handle_payment_and_retry` - but returns the signed headers instead of doing the retry POST — - which lets the streaming path open an SSE connection for the retry. + Kept for compatibility; see :meth:`_sign_chat_payment`, which also says + which scheme was signed (for ``upto``, ``cost_usd`` is the ceiling). """ - payment_header = response.headers.get("payment-required") - price_info: dict[str, Any] = {} - if not payment_header: - try: - resp_body = response.json() - if "x402" in resp_body: - payment_header = resp_body - price_info = resp_body.get("price", {}) - except Exception: - pass - - if not payment_header: - raise PaymentError("402 response but no payment requirements found") + payment = self._sign_chat_payment(body, response) + return payment.headers, payment.cost_usd - if isinstance(payment_header, str): - payment_required = parse_payment_required(payment_header) - else: - payment_required = payment_header + def _sign_chat_payment(self, body: dict[str, Any], response: httpx.Response) -> _ChatPayment: + """Sign a chat 402: ``upto`` when the policy in :mod:`blockrun_llm.x402_upto` + allows it, else ``exact`` exactly as before. Returns the paid retry's + headers and what to book. + SECURITY: Payment signing happens entirely on your machine. + Only the signature is sent - your private key never leaves. + """ + payment_required, price_info = _read_payment_required(response) details = extract_payment_details(payment_required) - - cost_usd = ( - float(price_info.get("amount", 0)) - if price_info - else float(details.get("amount", 0)) / 1e6 - ) - # Before signing: a refused quote is never sent, so nothing settles. - _enforce_spend_limits(self, cost_usd, body.get("model") if isinstance(body, dict) else None) - - resource = details.get("resource") or {} - _warn_if_clamped(body, resource.get("description")) - extensions = payment_required.get("extensions", {}) - payment_payload = create_payment_payload( - account=self.account, - recipient=details["recipient"], - amount=details["amount"], - network=details.get("network", "eip155:84532" if self.is_testnet() else "eip155:8453"), - resource_url=validate_resource_url( - resource.get("url", f"{self.api_url}/v1/chat/completions"), self.api_url - ), - resource_description=resource.get("description", "BlockRun AI API call"), - max_timeout_seconds=details.get("maxTimeoutSeconds", 300), - extra=details.get("extra"), - extensions=extensions, - asset=details.get("asset"), - ) - - return ( - { - "Content-Type": "application/json", - "User-Agent": _get_user_agent(), - "PAYMENT-SIGNATURE": payment_payload, - }, - cost_usd, - ) + offer = _upto_offer(self, body, payment_required, details) + if offer is not None: + option, kwargs = offer + signed = try_upto_payment(self.account, option, payment_required, **kwargs) + if signed is not None: + return _upto_chat_payment( + self, body, payment_required, price_info, details, option, signed + ) + return _exact_chat_payment(self, body, payment_required, price_info, details) @staticmethod def _raise_stream_error(response: httpx.Response, *, after_payment: bool) -> None: @@ -1458,84 +1764,23 @@ def _handle_payment_and_retry( SECURITY: Payment signing happens entirely on your machine. Only the signature is sent - your private key never leaves. """ - # Get payment required header (x402 library uses lowercase) - payment_header = response.headers.get("payment-required") - price_info = {} - if not payment_header: - # Try to get from response body - try: - resp_body = response.json() - if "x402" in resp_body: - payment_header = resp_body - # Extract price info for spending report - price_info = resp_body.get("price", {}) - except Exception: - pass - - if not payment_header: - raise PaymentError("402 response but no payment requirements found") - - # Parse payment requirements - if isinstance(payment_header, str): - payment_required = parse_payment_required(payment_header) - else: - payment_required = payment_header - - # Extract payment details - details = extract_payment_details(payment_required) - - # Get the cost being paid - cost_usd = ( - float(price_info.get("amount", 0)) - if price_info - else float(details.get("amount", 0)) / 1e6 - ) - # Before signing: a refused quote is never sent, so nothing settles. - _enforce_spend_limits(self, cost_usd, body.get("model") if isinstance(body, dict) else None) - - # Create signed payment payload (v2 format) - # SECURITY: Signing happens locally - only the signature is sent to server - resource = details.get("resource") or {} - _warn_if_clamped(body, resource.get("description")) - # Pass through extensions from server (for Bazaar discovery) - extensions = payment_required.get("extensions", {}) - payment_payload = create_payment_payload( - account=self.account, - recipient=details["recipient"], - amount=details["amount"], - network=details.get("network", "eip155:84532" if self.is_testnet() else "eip155:8453"), - resource_url=validate_resource_url( - resource.get("url", f"{self.api_url}/v1/chat/completions"), self.api_url - ), - resource_description=resource.get("description", "BlockRun AI API call"), - max_timeout_seconds=details.get("maxTimeoutSeconds", 300), - extra=details.get("extra"), - extensions=extensions, - asset=details.get("asset"), - ) + payment = self._sign_chat_payment(body, response) # Retry with payment (x402 library expects PAYMENT-SIGNATURE header) # Use longer timeout for Live Search requests is_search_request = "search_parameters" in body or body.get("search") is True request_timeout = self.search_timeout if is_search_request else self.timeout - payment_headers = { - "Content-Type": "application/json", - "User-Agent": _get_user_agent(), - "PAYMENT-SIGNATURE": payment_payload, - } - - # Retry with payment, with one automatic retry on 502/503 - retry_response = self._client.post( - url, json=body, headers=payment_headers, timeout=request_timeout - ) - if retry_response.status_code in (502, 503): - import time - - time.sleep(1) - retry_response = self._client.post( - url, json=body, headers=payment_headers, timeout=request_timeout - ) + retry_response = self._post_paid(url, body, payment.headers, request_timeout) + if payment.exact_fallback is not None and _is_payment_rejection(retry_response): + # The gateway refused the upto payment before serving anything: + # one retry with the same 402's exact requirement. If exact is + # refused too, the original refusal is what surfaces. + rejected = retry_response + payment = _exact_after_upto_rejection(self, payment) + retry_response = self._post_paid(url, body, payment.headers, request_timeout) + if _is_payment_rejection(retry_response): + retry_response = rejected # Check for errors if retry_response.status_code == 402: @@ -1560,11 +1805,10 @@ def _handle_payment_and_retry( response_data = retry_response.json() chat_response = ChatResponse(**response_data) - # Update session spending - self._session_calls += 1 - self._session_total_usd += cost_usd - self._last_call_cost = cost_usd + # Update session spending. For upto this is the settled amount when + # PAYMENT-RESPONSE reports one, else the signed ceiling, labeled. settlement = self._capture_settlement(retry_response) + cost_usd, cost_basis = self._book_paid_call(payment, settlement) # Attach the real x402 charge (and on-chain settlement) to THIS response # object so callers get a per-call, race-free cost. Use the value @@ -1572,6 +1816,8 @@ def _handle_payment_and_retry( # (shared state a concurrent call on the same client could overwrite), # and a local cost_usd rather than self._last_call_cost which goes stale. chat_response.cost_usd = cost_usd + chat_response.payment_scheme = payment.scheme + chat_response.cost_is_ceiling = cost_basis == "upto_ceiling" if settlement: chat_response.settlement = dict(settlement) @@ -1583,9 +1829,12 @@ def _handle_payment_and_retry( body, response_data, cost_usd=cost_usd, + cost_basis=cost_basis, **self._billing_meta(), ) - self._log_transaction("/v1/chat/completions", body, response_data, cost_usd) + self._log_transaction( + "/v1/chat/completions", body, response_data, cost_usd, cost_basis=cost_basis + ) return chat_response @@ -2518,6 +2767,8 @@ def _log_transaction( body: dict[str, Any], response: Any, cost_usd: float, + *, + cost_basis: str | None = None, ) -> None: """Append one row to the project-local transaction log, if enabled. @@ -2542,6 +2793,7 @@ def _log_transaction( network=_detect_network(self.api_url), client_kind=type(self).__name__, settlement=settlement, + cost_basis=cost_basis, ) except Exception: pass @@ -2569,19 +2821,11 @@ def get_balance(self) -> float: # USDC contracts # Mainnet: Base # Testnet: Base Sepolia - if self.is_testnet(): - usdc_contract = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" - rpcs = [ - "https://sepolia.base.org", - "https://base-sepolia-rpc.publicnode.com", - ] - else: - usdc_contract = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" - rpcs = [ - "https://base.publicnode.com", - "https://mainnet.base.org", - "https://base.meowrpc.com", - ] + # Contract + public RPCs from the shared chain table (x402.EVM_NETWORKS), + # which the upto scheme's allowance reads use too. + net = EVM_NETWORKS["eip155:84532" if self.is_testnet() else "eip155:8453"] + usdc_contract = net["usdc"] + rpcs = list(net["rpcs"]) # balanceOf(address) function selector selector = "0x70a08231" @@ -2649,6 +2893,7 @@ def __init__( transaction_log: bool | str | os.PathLike[str] | None = None, max_cost_per_call: float | None = None, max_session_cost: float | None = None, + payment_scheme: str | None = None, ): """ Initialize the async BlockRun LLM client. @@ -2663,6 +2908,14 @@ def __init__( ``./log/``; pass a string/Path for a custom dir; ``None`` honors the ``BLOCKRUN_TX_LOG`` env var. See ``LLMClient`` for the full record schema. + payment_scheme: ``"auto"`` (default) or ``"exact"``. With ``"auto"``, a chat + call whose 402 also offers x402 ``upto`` pays with a Permit2 + ceiling and is settled at the ACTUAL cost after the call + (prompt-cache discounts included) — but only when the wallet + already has a Permit2 allowance or the gateway sponsors the + approval gaslessly; otherwise, or on any error, it signs + ``exact`` as before. ``"exact"`` never signs upto. ``None`` + honors the ``BLOCKRUN_PAYMENT_SCHEME`` env var. Raises: ValueError: If no wallet is configured @@ -2734,6 +2987,12 @@ def __init__( max_cost_per_call, "BLOCKRUN_MAX_COST_PER_CALL" ) self._max_session_cost = resolve_spend_limit(max_session_cost, "BLOCKRUN_MAX_SESSION_COST") + # x402 scheme preference (see x402_upto). Resolved eagerly so a typo in + # the argument or BLOCKRUN_PAYMENT_SCHEME fails at construction. + self._payment_scheme = resolve_payment_scheme(payment_scheme) + # (wallet, network) pairs whose gateway rejected an upto payment: those + # go straight to exact for the life of this client. + self._upto_rejected: set[tuple[str, str]] = set() log_dir = _resolve_log_dir(transaction_log) self._tx_logger: TransactionLogger | None = ( @@ -3126,8 +3385,7 @@ async def _stream_with_payment( statuses_5xx = LLMClient._STREAM_5XX_STATUSES # ----- Phase 1: probe (no payment header) ----- - payment_headers: dict[str, str] | None = None - cost_usd = 0.0 + payment: _ChatPayment | None = None for attempt in range(len(backoffs) + 1): async with self._client.stream( @@ -3142,7 +3400,7 @@ async def _stream_with_payment( # Account rail: a 402 is the account being out of credit, not a # challenge to sign. Nothing here can sign, so say so plainly. raise_for_api_key_402(resp1, self.api_key) - payment_headers, cost_usd = self._sign_payment_from_response(body, resp1) + payment = await self._asign_chat_payment(body, resp1) break if resp1.status_code in statuses_5xx and attempt < len(backoffs): import asyncio @@ -3155,13 +3413,15 @@ async def _stream_with_payment( # ----- Phase 2: stream with PAYMENT-SIGNATURE ----- # Settled from here on; see the sync path. - assert payment_headers is not None + assert payment is not None # `async for` does NOT close the inner async generator when this one is # closed or an exception leaves the loop, so the paid `async with # self._client.stream(...)` inside it would stay suspended and hold the # connection until GC finalization. The sync path gets this for free: # `yield from` propagates close() into the subgenerator. Close it here. - paid = self._astream_paid_phase(url, body, payment_headers, cost_usd, timeout) + paid = self._astream_paid_phase( + url, body, payment.headers, payment.cost_usd, timeout, payment=payment + ) try: async for chunk in paid: yield chunk @@ -3178,32 +3438,47 @@ async def _astream_paid_phase( payment_headers: dict[str, str], cost_usd: float, timeout: float | None, - ) -> AsyncIterator[ChatCompletionChunk]: - """Phase 2 of the async stream: the paid, already-settled leg.""" + *, + payment: _ChatPayment | None = None, + rejected: httpx.Response | None = None, + ) -> AsyncGenerator[ChatCompletionChunk, None]: + """Phase 2 of the async stream: the paid, already-settled leg. + + Same one-shot exact retry after an upto refusal as the sync path. + """ + if payment is None: + payment = _ChatPayment(payment_headers, cost_usd, "exact", 0) + upto_refusal: httpx.Response | None = None backoffs = LLMClient._STREAM_5XX_BACKOFFS statuses_5xx = LLMClient._STREAM_5XX_STATUSES for attempt in range(len(backoffs) + 1): async with self._client.stream( - "POST", url, json=body, headers=payment_headers, timeout=timeout + "POST", url, json=body, headers=payment.headers, timeout=timeout ) as resp2: if resp2.status_code == 200: # AsyncLLMClient only tracks ``_last_call_cost`` (no session # totals in the async path — matches the existing async # chat_completion convention). + basis: str | None = None if cost_usd > 0: + cost_usd, basis = _booked_cost(payment, self._capture_settlement(resp2)) self._last_call_cost = cost_usd - self._capture_settlement(resp2) async for chunk in self._aiter_and_archive( - resp2, body, cost_usd, streaming=True + resp2, + body, + cost_usd, + streaming=True, + payment_scheme=payment.scheme, + cost_basis=basis, ): yield chunk return await resp2.aread() - if resp2.status_code == 402: - # Account rail: a 402 is the account being out of credit, not a - # challenge to sign. Nothing here can sign, so say so plainly. - raise_for_api_key_402(resp2, self.api_key) - raise PaymentError("Payment was rejected. Check your wallet balance.") + if _is_payment_rejection(resp2): + if payment.exact_fallback is not None: + upto_refusal = resp2 # nothing streamed yet: retry exact + break + self._raise_payment_rejection(rejected if rejected is not None else resp2) if resp2.status_code in statuses_5xx and attempt < len(backoffs): import asyncio @@ -3211,6 +3486,23 @@ async def _astream_paid_phase( continue self._raise_stream_error(resp2, after_payment=True) + if upto_refusal is not None: + exact = _exact_after_upto_rejection(self, payment) + retry = self._astream_paid_phase( + url, + body, + exact.headers, + exact.cost_usd, + timeout, + payment=exact, + rejected=upto_refusal, + ) + try: + async for chunk in retry: + yield chunk + finally: + await retry.aclose() + async def _aiter_and_archive( self, response: httpx.Response, @@ -3218,6 +3510,8 @@ async def _aiter_and_archive( cost_usd: float, *, streaming: bool = True, + payment_scheme: str | None = None, + cost_basis: str | None = None, ) -> AsyncIterator[ChatCompletionChunk]: """Async mirror of :meth:`LLMClient._iter_and_archive`. Writes the assembled ``chat.completion`` response to ``~/.blockrun/data/`` and @@ -3250,6 +3544,11 @@ async def _aiter_and_archive( usage_dict = _usage # Race-free per-call x402 charge — see LLMClient._iter_and_archive. chunk.cost_usd = cost_usd + if payment_scheme is not None: + # upto: cost_usd is the signed ceiling unless the gateway + # reported the settled amount — say which. + setattr(chunk, "payment_scheme", payment_scheme) # noqa: B010 (extra field) + setattr(chunk, "cost_is_ceiling", cost_basis == "upto_ceiling") # noqa: B010 yield chunk if cost_usd > 0: @@ -3280,11 +3579,14 @@ async def _aiter_and_archive( body, response_data, cost_usd=cost_usd, + cost_basis=cost_basis, **self._billing_meta(), ) except Exception: pass - self._log_transaction("/v1/chat/completions", body, response_data, cost_usd) + self._log_transaction( + "/v1/chat/completions", body, response_data, cost_usd, cost_basis=cost_basis + ) @staticmethod async def _aiter_sse_chunks(response: httpx.Response) -> AsyncIterator[ChatCompletionChunk]: @@ -3306,8 +3608,47 @@ async def _aiter_sse_chunks(response: httpx.Response) -> AsyncIterator[ChatCompl # Reuse the sync helpers — Python class-attribute lookup binds them # correctly to whatever self is passed when the bound method is called. + # Sync signers kept for compatibility. They read chain state with blocking + # I/O when upto is offered, so this client's own paths use + # _asign_chat_payment instead. _sign_payment_from_response = LLMClient._sign_payment_from_response + _sign_chat_payment = LLMClient._sign_chat_payment + + async def _apost_paid( + self, + url: str, + body: dict[str, Any], + headers: dict[str, str], + timeout: float | None, + ) -> httpx.Response: + """The paid POST, with one automatic retry on 502/503.""" + response = await self._client.post(url, json=body, headers=headers, timeout=timeout) + if response.status_code in (502, 503): + import asyncio + + await asyncio.sleep(1) + response = await self._client.post(url, json=body, headers=headers, timeout=timeout) + return response + + async def _asign_chat_payment( + self, body: dict[str, Any], response: httpx.Response + ) -> _ChatPayment: + """Async :meth:`LLMClient._sign_chat_payment`: the upto chain reads do not + block the event loop.""" + payment_required, price_info = _read_payment_required(response) + details = extract_payment_details(payment_required) + offer = _upto_offer(self, body, payment_required, details) + if offer is not None: + option, kwargs = offer + signed = await atry_upto_payment(self.account, option, payment_required, **kwargs) + if signed is not None: + return _upto_chat_payment( + self, body, payment_required, price_info, details, option, signed + ) + return _exact_chat_payment(self, body, payment_required, price_info, details) + _raise_stream_error = LLMClient._raise_stream_error + _raise_payment_rejection = LLMClient._raise_payment_rejection async def _request_with_payment(self, endpoint: str, body: dict[str, Any]) -> ChatResponse: """Make async request with automatic payment handling.""" @@ -3358,78 +3699,24 @@ async def _handle_payment_and_retry( response: httpx.Response, ) -> ChatResponse: """Handle 402 response asynchronously.""" - # Get payment required header (x402 library uses lowercase) - payment_header = response.headers.get("payment-required") - if not payment_header: - try: - resp_body = response.json() - if "x402" in resp_body: - payment_header = resp_body - except Exception: - pass - - if not payment_header: - raise PaymentError("402 response but no payment requirements found") - - if isinstance(payment_header, str): - payment_required = parse_payment_required(payment_header) - else: - payment_required = payment_header - - details = extract_payment_details(payment_required) - - # Enforce the spend limit on the QUOTE, before signing. This handler - # computes its cost_usd only after the paid POST returns (it prefers the - # price echoed on the response), which is far too late to refuse. - _enforce_spend_limits( - self, - float(details.get("amount", 0)) / 1e6, - body.get("model") if isinstance(body, dict) else None, - ) - - # Create signed payment payload (v2 format) - # SECURITY: Signing happens locally - only the signature is sent to server - resource = details.get("resource") or {} - _warn_if_clamped(body, resource.get("description")) - # Pass through extensions from server (for Bazaar discovery) - extensions = payment_required.get("extensions", {}) - payment_payload = create_payment_payload( - account=self.account, - recipient=details["recipient"], - amount=details["amount"], - network=details.get("network", "eip155:84532" if self.is_testnet() else "eip155:8453"), - resource_url=validate_resource_url( - resource.get("url", f"{self.api_url}/v1/chat/completions"), self.api_url - ), - resource_description=resource.get("description", "BlockRun AI API call"), - max_timeout_seconds=details.get("maxTimeoutSeconds", 300), - extra=details.get("extra"), - extensions=extensions, - asset=details.get("asset"), - ) + # Signing prices the call: spend limits are enforced on the quote (the + # upto ceiling, for upto) before anything is signed or sent. + payment = await self._asign_chat_payment(body, response) # Retry with payment (x402 library expects PAYMENT-SIGNATURE header) # Use longer timeout for Live Search requests is_search_request = "search_parameters" in body or body.get("search") is True request_timeout = self.search_timeout if is_search_request else self.timeout - payment_headers = { - "Content-Type": "application/json", - "User-Agent": _get_user_agent(), - "PAYMENT-SIGNATURE": payment_payload, - } - - # Retry with payment, with one automatic retry on 502/503 - retry_response = await self._client.post( - url, json=body, headers=payment_headers, timeout=request_timeout - ) - if retry_response.status_code in (502, 503): - import asyncio - - await asyncio.sleep(1) - retry_response = await self._client.post( - url, json=body, headers=payment_headers, timeout=request_timeout - ) + retry_response = await self._apost_paid(url, body, payment.headers, request_timeout) + if payment.exact_fallback is not None and _is_payment_rejection(retry_response): + # Upto refused before anything was served: one exact retry; if exact + # is refused too, the original refusal surfaces (see the sync path). + rejected = retry_response + payment = _exact_after_upto_rejection(self, payment) + retry_response = await self._apost_paid(url, body, payment.headers, request_timeout) + if _is_payment_rejection(retry_response): + retry_response = rejected if retry_response.status_code == 402: # Account rail: a 402 is the account being out of credit, not a @@ -3449,25 +3736,18 @@ async def _handle_payment_and_retry( retry_after=retry_after_of(retry_response), ) - # Extract cost and save locally - price_info = {} - try: - resp_body = response.json() - price_info = resp_body.get("price", {}) - except Exception: - pass - cost_usd = ( - float(price_info.get("amount", 0)) - if price_info - else float(details.get("amount", 0)) / 1e6 - ) - self._last_call_cost = cost_usd + # Book the call: the quote for exact; for upto the settled amount when + # PAYMENT-RESPONSE reports one, else the signed ceiling, labeled. settlement = self._capture_settlement(retry_response) + cost_usd, cost_basis = _booked_cost(payment, settlement) + self._last_call_cost = cost_usd response_data = retry_response.json() # Per-call real charge + settlement (see sync _handle_payment_and_retry). chat_response = ChatResponse(**response_data) chat_response.cost_usd = cost_usd + chat_response.payment_scheme = payment.scheme + chat_response.cost_is_ceiling = cost_basis == "upto_ceiling" if settlement: chat_response.settlement = dict(settlement) from .cache import save_to_cache @@ -3477,9 +3757,12 @@ async def _handle_payment_and_retry( body, response_data, cost_usd=cost_usd, + cost_basis=cost_basis, **self._billing_meta(), ) - self._log_transaction("/v1/chat/completions", body, response_data, cost_usd) + self._log_transaction( + "/v1/chat/completions", body, response_data, cost_usd, cost_basis=cost_basis + ) return chat_response @@ -4120,6 +4403,8 @@ def _log_transaction( body: dict[str, Any], response: Any, cost_usd: float, + *, + cost_basis: str | None = None, ) -> None: """Async-client twin of :meth:`LLMClient._log_transaction`.""" logger = self._tx_logger @@ -4138,6 +4423,7 @@ def _log_transaction( network=_detect_network(self.api_url), client_kind=type(self).__name__, settlement=settlement, + cost_basis=cost_basis, ) except Exception: pass @@ -4165,19 +4451,11 @@ async def get_balance(self) -> float: # USDC contracts # Mainnet: Base # Testnet: Base Sepolia - if self.is_testnet(): - usdc_contract = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" - rpcs = [ - "https://sepolia.base.org", - "https://base-sepolia-rpc.publicnode.com", - ] - else: - usdc_contract = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" - rpcs = [ - "https://base.publicnode.com", - "https://mainnet.base.org", - "https://base.meowrpc.com", - ] + # Contract + public RPCs from the shared chain table (x402.EVM_NETWORKS), + # which the upto scheme's allowance reads use too. + net = EVM_NETWORKS["eip155:84532" if self.is_testnet() else "eip155:8453"] + usdc_contract = net["usdc"] + rpcs = list(net["rpcs"]) # balanceOf(address) function selector selector = "0x70a08231" diff --git a/blockrun_llm/tx_log.py b/blockrun_llm/tx_log.py index 44ead88..caf425f 100644 --- a/blockrun_llm/tx_log.py +++ b/blockrun_llm/tx_log.py @@ -274,18 +274,27 @@ def format_row( out_tokens: int, cost_usd: float, tx_hash: str | None, + cost_basis: str | None = None, ) -> str: - """Format one log row exactly like the example in the module docstring.""" + """Format one log row exactly like the example in the module docstring. + + An x402 upto call booked at its signed ceiling (``cost_basis == + "upto_ceiling"``) gets a trailing ``(upto ceiling)`` marker: that dollar + figure is an upper bound, not the settled charge. + """ if ts is None: ts = time.time() when = datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S") tag = _endpoint_tag(endpoint) model_str = (model or "-")[:30].ljust(30) tx_str = f"{tx_hash[:10]}…" if tx_hash else "(no-tx)" - return ( + row = ( f"{when} {tag:<5} {model_str} " f"in={in_tokens:>5} out={out_tokens:<3} ${cost_usd:.6f} {tx_str}" ) + if cost_basis == "upto_ceiling": + row += " (upto ceiling)" + return row # --------------------------------------------------------------------------- @@ -328,6 +337,7 @@ def log( network: str | None = None, client_kind: str | None = None, settlement: dict[str, Any] | None = None, + cost_basis: str | None = None, ) -> Path | None: """Append one formatted row to ``./log/transactions.log``. @@ -349,6 +359,7 @@ def log( out_tokens=out_tokens, cost_usd=float(cost_usd or 0.0), tx_hash=tx_hash, + cost_basis=cost_basis, ) # Silence unused-arg warnings without changing the public API — the diff --git a/blockrun_llm/types.py b/blockrun_llm/types.py index bd02f0e..2d01bcf 100644 --- a/blockrun_llm/types.py +++ b/blockrun_llm/types.py @@ -144,6 +144,13 @@ class ChatResponse(BaseModel): # returned an X-PAYMENT-RESPONSE header. cost_usd: Optional[float] = None settlement: Optional[Dict[str, Any]] = None + # Which x402 scheme paid for this call: "exact" (the quote is what settles) + # or "upto" (a signed ceiling; the gateway settles the actual cost, prompt- + # cache discounts included). ``cost_is_ceiling`` is True when ``cost_usd`` + # is that CEILING because the gateway reported no settled amount — an upper + # bound, not the confirmed charge. Both None for free / cached calls. + payment_scheme: Optional[str] = None + cost_is_ceiling: Optional[bool] = None class Config: extra = "allow" diff --git a/blockrun_llm/x402.py b/blockrun_llm/x402.py index af10d78..4ac9f1d 100644 --- a/blockrun_llm/x402.py +++ b/blockrun_llm/x402.py @@ -48,6 +48,14 @@ "chainId": BASE_CHAIN_ID, "verifyingContract": USDC_BASE, }, + # Public read-only RPCs, tried in order. Used for USDC balance reads + # (``get_balance``) and the upto scheme's Permit2 allowance / EIP-2612 + # nonce reads — never to send a transaction. + "rpcs": ( + "https://base.publicnode.com", + "https://mainnet.base.org", + "https://base.meowrpc.com", + ), }, "eip155:5042": { "name": "Arc", @@ -59,6 +67,10 @@ "chainId": ARC_CHAIN_ID, "verifyingContract": USDC_ARC, }, + # No public RPC is pinned for Arc yet, so the upto scheme (which must + # read Permit2 allowance before signing) never engages there: every + # Arc payment stays `exact`. + "rpcs": (), }, "eip155:84532": { "name": "Base Sepolia", @@ -70,6 +82,10 @@ "chainId": BASE_SEPOLIA_CHAIN_ID, "verifyingContract": USDC_BASE_SEPOLIA, }, + "rpcs": ( + "https://sepolia.base.org", + "https://base-sepolia-rpc.publicnode.com", + ), }, } # The pre-CAIP alias this SDK accepted for the testnet. @@ -122,6 +138,16 @@ def get_usdc_domain_name(network: str) -> str: return evm_network(network)["domain"]["name"] +def signature_hex(signed: Any) -> str: + """0x-prefixed hex of an eth-account signature, whichever hexbytes is installed. + + ``HexBytes.hex()`` dropped its ``0x`` prefix in hexbytes 1.0, so the prefix + is added only when it is missing. + """ + raw = str(signed.signature.hex()) + return raw if raw.startswith("0x") else "0x" + raw + + def create_nonce() -> str: """Generate a random bytes32 nonce.""" return "0x" + secrets.token_hex(32) @@ -223,11 +249,7 @@ def create_payment_payload( "extra": {"name": domain["name"], "version": domain["version"]}, }, "payload": { - "signature": ( - "0x" + signed.signature.hex() - if not signed.signature.hex().startswith("0x") - else signed.signature.hex() - ), + "signature": signature_hex(signed), "authorization": { "from": account.address, "to": recipient, @@ -278,8 +300,14 @@ def extract_payment_details(payment_required: dict[str, Any]) -> dict[str, Any]: if not accepts: raise ValueError("No payment options in payment required response") - # Take the first option - option = accepts[0] + # The first option this signer can pay with EIP-3009. A gateway that also + # offers `upto` (Permit2) lists `exact` first, but nothing obliges it to: + # an upto requirement signed as an EIP-3009 authorization is a signature + # the facilitator rejects. Upto is chosen separately, in x402_upto. + option = next( + (o for o in accepts if isinstance(o, dict) and o.get("scheme", "exact") != "upto"), + accepts[0], + ) # Support both v1 (maxAmountRequired) and v2 (amount) formats amount = option.get("amount") or option.get("maxAmountRequired") diff --git a/blockrun_llm/x402_upto.py b/blockrun_llm/x402_upto.py new file mode 100644 index 0000000..b774de5 --- /dev/null +++ b/blockrun_llm/x402_upto.py @@ -0,0 +1,788 @@ +""" +x402 ``upto`` scheme (Permit2) for BlockRun's EVM gateways. + +Under ``exact`` the payer signs an EIP-3009 authorization for a fixed pre-call +quote, so a discount the gateway only learns after the call — a prompt-cache +hit, a short answer — can never reach an x402 caller. Under ``upto`` the payer +signs a Permit2 ``PermitWitnessTransferFrom`` for a CEILING, and the gateway +settles the ACTUAL amount (never more than the ceiling) once the call is done. + +A gateway that offers upto lists it after ``exact`` in ``accepts``. This module +decides whether to take it, and signs it. The rule is that choosing upto must +never be worse than today's ``exact``: + +* It is taken only when the 402 offers an EVM upto option carrying + ``extra.facilitatorAddress`` for a network in :data:`EVM_NETWORKS`, and +* the wallet holds at least the ceiling in USDC, and +* Permit2 may already pull the ceiling (USDC ``allowance(owner, Permit2)``), or + the 402 declares the ``eip2612GasSponsoring`` extension, in which case the + wallet signs a gasless EIP-2612 permit to Permit2 and the facilitator submits + the approval — so a wallet holding no ETH can still use upto. + +Anything else — no RPC answer, a signing error, a ceiling over a spend limit, a +sponsored permit already in flight for this wallet — falls back to ``exact`` +silently (debug log only). Solana is untouched: it only ever pays ``exact``. + +Semantics match the official ``@x402/evm`` 2.28.0 client (``UptoEvmScheme``, +``createUptoPermit2Payload``, ``trySignEip2612PermitExtension``); the unit tests +pin a vector generated by that client (``scripts/gen-upto-vector.mjs``). + +The private key is used ONLY for local signing and NEVER leaves the client. +""" + +from __future__ import annotations + +import base64 +import json +import logging +import os +import re +import secrets +import threading +import time +from collections.abc import Awaitable +from dataclasses import dataclass +from typing import Any, Callable + +import httpx +from eth_account.messages import encode_typed_data +from eth_utils.address import to_checksum_address + +from .x402 import ( + _NETWORK_ALIASES, + EVM_NETWORKS, + signature_hex, + with_builder_code_service_code, +) + +logger = logging.getLogger(__name__) + +# Canonical Uniswap Permit2 — the same address on every EVM chain. +PERMIT2_ADDRESS = "0x000000000022D473030F116dDEE9F6B43aC78BA3" +# x402's upto proxy: the Permit2 `spender`. It only lets the facilitator bound +# in the witness settle, and only up to the permitted amount. +X402_UPTO_PERMIT2_PROXY_ADDRESS = "0x4020A4f3b7b90ccA423B9fabCc0CE57C6C240002" + +EIP2612_GAS_SPONSORING_KEY = "eip2612GasSponsoring" +# The EIP-2612 permit approves Permit2 for exactly the per-call ceiling, as the +# official client does. Not MaxUint256: the upto proxy's `_executePermit` +# reverts with Permit2612AmountMismatch unless permit.value == permitted.amount, +# and CDP /verify rejects a MaxUint256 permit. +EIP2612_GAS_SPONSORING_VERSION = "1" + +# `payment_scheme` values a client accepts. "auto" prefers upto when the rule +# above allows it; "exact" never signs upto. +PAYMENT_SCHEMES = ("auto", "exact") +PAYMENT_SCHEME_ENV = "BLOCKRUN_PAYMENT_SCHEME" + +# Per-RPC timeout for the allowance / balance / nonce reads. These run on every +# upto-eligible call before signing, so they are kept short: a slow RPC falls +# through to the next one, and all of them failing falls back to exact. +RPC_TIMEOUT_SECONDS = 3.0 + +# ERC-20 / EIP-2612 view selectors. +_SEL_BALANCE_OF = "0x70a08231" # balanceOf(address) +_SEL_ALLOWANCE = "0xdd62ed3e" # allowance(address,address) +_SEL_NONCES = "0x7ecebe00" # nonces(address) + +_ADDRESS_RE = re.compile(r"^0x[0-9a-fA-F]{40}$") +_UINT_RE = re.compile(r"^[0-9]+$") + +UPTO_PERMIT2_WITNESS_TYPES: dict[str, list[dict[str, str]]] = { + "PermitWitnessTransferFrom": [ + {"name": "permitted", "type": "TokenPermissions"}, + {"name": "spender", "type": "address"}, + {"name": "nonce", "type": "uint256"}, + {"name": "deadline", "type": "uint256"}, + {"name": "witness", "type": "Witness"}, + ], + "TokenPermissions": [ + {"name": "token", "type": "address"}, + {"name": "amount", "type": "uint256"}, + ], + "Witness": [ + {"name": "to", "type": "address"}, + {"name": "facilitator", "type": "address"}, + {"name": "validAfter", "type": "uint256"}, + ], +} + +EIP2612_PERMIT_TYPES: dict[str, list[dict[str, str]]] = { + "Permit": [ + {"name": "owner", "type": "address"}, + {"name": "spender", "type": "address"}, + {"name": "value", "type": "uint256"}, + {"name": "nonce", "type": "uint256"}, + {"name": "deadline", "type": "uint256"}, + ], +} + + +class UptoUnavailable(Exception): + """Upto cannot be used for this call; the caller falls back to exact.""" + + +def resolve_payment_scheme(value: str | None) -> str: + """The client's ``payment_scheme``: the argument, else ``BLOCKRUN_PAYMENT_SCHEME``, else "auto". + + Raises: + ValueError: For anything other than "auto" or "exact". + """ + raw = value if value is not None else os.environ.get(PAYMENT_SCHEME_ENV) + scheme = (raw or "auto").strip().lower() + if scheme not in PAYMENT_SCHEMES: + source = "payment_scheme" if value is not None else PAYMENT_SCHEME_ENV + raise ValueError(f'{source} must be one of {", ".join(PAYMENT_SCHEMES)}; got "{raw}"') + return scheme + + +# --------------------------------------------------------------------------- +# Selecting the offer +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class UptoOption: + """A 402's upto requirement, validated against the SDK's own chain table.""" + + requirement: dict[str, Any] # the accepts entry, echoed back as `accepted` + network: str # canonical CAIP-2 + chain_id: int + usdc: str + domain: dict[str, Any] # USDC's EIP-712 domain (SDK-owned, never the 402's) + rpcs: tuple[str, ...] + amount: int # ceiling, micro-USDC + pay_to: str + facilitator: str + max_timeout_seconds: int + gas_sponsoring: bool # the 402 declared extensions.eip2612GasSponsoring + + @property + def ceiling_usd(self) -> float: + return self.amount / 1e6 + + +def gas_sponsoring_declared(payment_required: dict[str, Any]) -> bool: + """Whether the 402's top-level ``extensions`` declares ``eip2612GasSponsoring``.""" + extensions = payment_required.get("extensions") + return isinstance(extensions, dict) and bool(extensions.get(EIP2612_GAS_SPONSORING_KEY)) + + +def offers_upto(payment_required: dict[str, Any]) -> bool: + """Whether any ``accepts`` entry is an upto requirement (usable or not).""" + accepts = payment_required.get("accepts") + return isinstance(accepts, list) and any( + isinstance(o, dict) and o.get("scheme") == "upto" for o in accepts + ) + + +def find_upto_option(payment_required: dict[str, Any]) -> UptoOption | None: + """The first upto requirement this SDK can sign, or ``None``. + + Same trust rule as ``exact`` (see ``EVM_NETWORKS``): the 402 selects a + network the SDK knows and must ask for that network's USDC; the chain id and + token domain come from the SDK's table. ``extra.facilitatorAddress`` is + required — without it the witness cannot bind a settler, and the official + client refuses too. + """ + accepts = payment_required.get("accepts") + if not isinstance(accepts, list): + return None + sponsoring = gas_sponsoring_declared(payment_required) + for opt in accepts: + if not isinstance(opt, dict) or opt.get("scheme") != "upto": + continue + try: + option = _validate_upto_option(opt, sponsoring) + except (TypeError, ValueError) as exc: + logger.debug("x402 upto: ignoring upto offer (%s)", exc) + continue + return option + return None + + +def _validate_upto_option(opt: dict[str, Any], sponsoring: bool) -> UptoOption: + network = str(opt.get("network") or "") + net = EVM_NETWORKS.get(_NETWORK_ALIASES.get(network, network)) + if net is None: + raise ValueError(f"network {network!r} is not an EVM network this SDK signs on") + + asset = opt.get("asset") + if not isinstance(asset, str) or asset.lower() != net["usdc"].lower(): + raise ValueError(f"asset {asset!r} is not USDC on {network}") + + raw_extra = opt.get("extra") + extra: dict[str, Any] = raw_extra if isinstance(raw_extra, dict) else {} + facilitator = extra.get("facilitatorAddress") + if not isinstance(facilitator, str) or not _ADDRESS_RE.match(facilitator): + raise ValueError("no extra.facilitatorAddress") + + pay_to = opt.get("payTo") + if not isinstance(pay_to, str) or not _ADDRESS_RE.match(pay_to): + raise ValueError(f"payTo {pay_to!r} is not an address") + + raw_amount = opt.get("amount") or opt.get("maxAmountRequired") + if not _UINT_RE.match(str(raw_amount or "")) or int(str(raw_amount)) <= 0: + raise ValueError(f"amount {raw_amount!r} is not a positive integer") + + timeout = opt.get("maxTimeoutSeconds", 300) + if isinstance(timeout, bool) or not isinstance(timeout, int) or timeout <= 0: + raise ValueError(f"maxTimeoutSeconds {timeout!r} is not a positive integer") + + return UptoOption( + requirement=dict(opt), + network=_NETWORK_ALIASES.get(network, network), + chain_id=int(net["chain_id"]), + usdc=to_checksum_address(net["usdc"]), + domain=dict(net["domain"]), + rpcs=tuple(net.get("rpcs") or ()), + amount=int(str(raw_amount)), + pay_to=to_checksum_address(pay_to), + facilitator=to_checksum_address(facilitator), + max_timeout_seconds=timeout, + gas_sponsoring=sponsoring, + ) + + +# --------------------------------------------------------------------------- +# Chain reads (public RPC, eth_call only) +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class UptoChainState: + balance: int # USDC balanceOf(owner) + allowance: int # USDC allowance(owner, Permit2) + permit_nonce: int | None # USDC nonces(owner); read only when a permit is needed + + +def _word(address: str) -> str: + return address[2:].lower().rjust(64, "0") + + +def _eth_call_body(to: str, data: str) -> dict[str, Any]: + return { + "jsonrpc": "2.0", + "id": 1, + "method": "eth_call", + "params": [{"to": to, "data": data}, "latest"], + } + + +def _parse_uint(payload: Any) -> int: + """A uint256 eth_call result, strictly: an RPC error or empty return raises. + + Strict on purpose — a nonce misread as 0 signs a permit the token rejects, + which would fail a call that ``exact`` would have paid for. + """ + if not isinstance(payload, dict) or payload.get("error"): + raise UptoUnavailable(f"eth_call error: {payload!r:.200}") + result = payload.get("result") + if not isinstance(result, str) or not result.startswith("0x") or len(result) <= 2: + raise UptoUnavailable(f"eth_call returned {result!r:.80}") + return int(result, 16) + + +def _state_calls(option: UptoOption, owner: str) -> tuple[tuple[str, str], tuple[str, str]]: + return ( + (option.usdc, _SEL_BALANCE_OF + _word(owner)), + (option.usdc, _SEL_ALLOWANCE + _word(owner) + _word(PERMIT2_ADDRESS)), + ) + + +def _nonce_call(option: UptoOption, owner: str) -> tuple[str, str]: + return option.usdc, _SEL_NONCES + _word(owner) + + +def _needs_permit_nonce(option: UptoOption, allowance: int) -> bool: + return option.gas_sponsoring and allowance < option.amount + + +def _read_state_sync( + http: httpx.Client, url: str, option: UptoOption, owner: str +) -> UptoChainState: + def call(to: str, data: str) -> int: + resp = http.post(url, json=_eth_call_body(to, data)) + resp.raise_for_status() + return _parse_uint(resp.json()) + + (b_to, b_data), (a_to, a_data) = _state_calls(option, owner) + balance = call(b_to, b_data) + allowance = call(a_to, a_data) + nonce = call(*_nonce_call(option, owner)) if _needs_permit_nonce(option, allowance) else None + return UptoChainState(balance, allowance, nonce) + + +async def _read_state_async( + http: httpx.AsyncClient, url: str, option: UptoOption, owner: str +) -> UptoChainState: + async def call(to: str, data: str) -> int: + resp = await http.post(url, json=_eth_call_body(to, data)) + resp.raise_for_status() + return _parse_uint(resp.json()) + + (b_to, b_data), (a_to, a_data) = _state_calls(option, owner) + balance = await call(b_to, b_data) + allowance = await call(a_to, a_data) + nonce = ( + await call(*_nonce_call(option, owner)) if _needs_permit_nonce(option, allowance) else None + ) + return UptoChainState(balance, allowance, nonce) + + +def read_upto_chain_state( + option: UptoOption, owner: str, *, timeout: float = RPC_TIMEOUT_SECONDS +) -> UptoChainState: + """Read balance, Permit2 allowance and (if a permit is needed) the EIP-2612 nonce. + + Tries each of the network's public RPCs in turn; raises + :class:`UptoUnavailable` when none answers. + """ + if not option.rpcs: + raise UptoUnavailable(f"no public RPC configured for {option.network}") + last: Exception | None = None + for url in option.rpcs: + try: + with httpx.Client(timeout=timeout) as http: + return _read_state_sync(http, url, option, owner) + except Exception as exc: # next RPC + last = exc + raise UptoUnavailable(f"every RPC for {option.network} failed: {last}") from last + + +async def aread_upto_chain_state( + option: UptoOption, owner: str, *, timeout: float = RPC_TIMEOUT_SECONDS +) -> UptoChainState: + """Async :func:`read_upto_chain_state` — the same reads, without blocking the loop.""" + if not option.rpcs: + raise UptoUnavailable(f"no public RPC configured for {option.network}") + last: Exception | None = None + for url in option.rpcs: + try: + async with httpx.AsyncClient(timeout=timeout) as http: + return await _read_state_async(http, url, option, owner) + except Exception as exc: # next RPC + last = exc + raise UptoUnavailable(f"every RPC for {option.network} failed: {last}") from last + + +# --------------------------------------------------------------------------- +# Deciding +# --------------------------------------------------------------------------- + +# Sponsored permits this process signed and has not yet seen consumed: +# (network, owner) → (USDC nonce signed over, permit deadline). A permit lands +# only when the facilitator settles the call that carried it, and it approves +# just that call's ceiling, so Permit2's allowance is back near 0 afterwards and +# every sponsored call needs a fresh permit. Until one lands the chain still +# shows the old USDC nonce, and a second call would sign another permit over the +# SAME nonce: the first settlement consumes it and the second reverts on-chain +# (seen live on Base mainnet). So per wallet and network only one gas-sponsored +# upto payment is in flight; a call that would need a second pays exact. +# +# Read on every sponsored preflight: on-chain nonce > signed nonce → the permit +# was consumed, clear; ≤ and before the deadline → still in flight, pay exact; +# past the deadline → it can never land, clear. +_PERMITS_IN_FLIGHT: dict[tuple[str, str], tuple[int, int]] = {} +_PERMITS_LOCK = threading.Lock() + + +def _now() -> float: + return time.time() + + +def _claim_permit_slot(network: str, owner: str, onchain_nonce: int, deadline: int) -> bool: + """Atomically: if no permit is in flight for this wallet+network, record one + over ``onchain_nonce`` (valid until ``deadline``) and return True.""" + key = (network, owner.lower()) + now = _now() + with _PERMITS_LOCK: + pending = _PERMITS_IN_FLIGHT.get(key) + if pending is not None: + signed_nonce, pending_deadline = pending + if onchain_nonce <= signed_nonce and now < pending_deadline: + return False + _PERMITS_IN_FLIGHT[key] = (onchain_nonce, deadline) + return True + + +def _release_permit_slot(network: str, owner: str, signed_nonce: int) -> None: + """Forget a permit that was never sent (signing failed).""" + key = (network, owner.lower()) + with _PERMITS_LOCK: + pending = _PERMITS_IN_FLIGHT.get(key) + if pending is not None and pending[0] == signed_nonce: + del _PERMITS_IN_FLIGHT[key] + + +def plan_upto(option: UptoOption, state: UptoChainState) -> bool | None: + """``False`` = upto with no permit, ``True`` = upto with a sponsored permit, + ``None`` = fall back to exact.""" + if state.balance < option.amount: + logger.debug( + "x402 upto: USDC balance %s below ceiling %s; using exact", + state.balance, + option.amount, + ) + return None + if state.allowance >= option.amount: + return False + if option.gas_sponsoring and state.permit_nonce is not None: + return True + logger.debug( + "x402 upto: Permit2 allowance %s below ceiling %s and no gas sponsoring; using exact", + state.allowance, + option.amount, + ) + return None + + +# --------------------------------------------------------------------------- +# Signing +# --------------------------------------------------------------------------- + + +def permit2_witness_typed_data( + *, + chain_id: int, + token: str, + amount: int, + nonce: int, + deadline: int, + pay_to: str, + facilitator: str, + valid_after: int = 0, +) -> dict[str, Any]: + """The full EIP-712 message the upto payer signs (Permit2 domain, no version).""" + return { + "types": { + "EIP712Domain": [ + {"name": "name", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"}, + ], + **UPTO_PERMIT2_WITNESS_TYPES, + }, + "primaryType": "PermitWitnessTransferFrom", + "domain": { + "name": "Permit2", + "chainId": chain_id, + "verifyingContract": PERMIT2_ADDRESS, + }, + "message": { + "permitted": {"token": token, "amount": amount}, + "spender": X402_UPTO_PERMIT2_PROXY_ADDRESS, + "nonce": nonce, + "deadline": deadline, + "witness": {"to": pay_to, "facilitator": facilitator, "validAfter": valid_after}, + }, + } + + +def eip2612_permit_typed_data( + *, domain: dict[str, Any], owner: str, value: int, nonce: int, deadline: int +) -> dict[str, Any]: + """The full EIP-712 message for a USDC ``permit(owner, Permit2, value, …)``.""" + return { + "types": { + "EIP712Domain": [ + {"name": "name", "type": "string"}, + {"name": "version", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"}, + ], + **EIP2612_PERMIT_TYPES, + }, + "primaryType": "Permit", + "domain": dict(domain), + "message": { + "owner": owner, + "spender": PERMIT2_ADDRESS, + "value": value, + "nonce": nonce, + "deadline": deadline, + }, + } + + +def _sign(account: Any, full_message: dict[str, Any]) -> str: + return signature_hex(account.sign_message(encode_typed_data(full_message=full_message))) + + +def merge_extensions( + server: dict[str, Any] | None, client: dict[str, Any] | None +) -> dict[str, Any]: + """Port of ``x402Client.mergeExtensions`` (@x402/core): client data adds + fields, server-declared fields stay intact; a non-object replaces.""" + if not client: + return dict(server or {}) + if not server: + return dict(client) + merged = dict(server) + for key, client_value in client.items(): + server_value = merged.get(key) + if not isinstance(server_value, dict) or not isinstance(client_value, dict): + merged[key] = client_value + continue + merged[key] = _merge_server_wins(server_value, client_value) + return merged + + +def _merge_server_wins(server: dict[str, Any], client: dict[str, Any]) -> dict[str, Any]: + out = dict(server) + for field, client_field in client.items(): + server_field = out.get(field) + if isinstance(server_field, dict) and isinstance(client_field, dict): + out[field] = _merge_server_wins(server_field, client_field) + elif field not in out: + out[field] = client_field + return out + + +def create_upto_payment_payload( + account: Any, + option: UptoOption, + *, + resource_url: str, + resource_description: str, + extensions: dict[str, Any] | None = None, + permit_nonce: int | None = None, + now: int | None = None, + nonce: int | None = None, +) -> str: + """Sign an upto payment and return the base64 ``PAYMENT-SIGNATURE`` value. + + ``permit_nonce`` set → also sign the EIP-2612 permit (USDC → Permit2 for the + ceiling) and attach it as ``extensions.eip2612GasSponsoring.info``. + ``now`` / ``nonce`` exist for deterministic tests. + """ + now = int(time.time()) if now is None else now + deadline = now + option.max_timeout_seconds + permit2_nonce = int.from_bytes(secrets.token_bytes(32), "big") if nonce is None else nonce + owner = to_checksum_address(account.address) + + signature = _sign( + account, + permit2_witness_typed_data( + chain_id=option.chain_id, + token=option.usdc, + amount=option.amount, + nonce=permit2_nonce, + deadline=deadline, + pay_to=option.pay_to, + facilitator=option.facilitator, + ), + ) + + merged = with_builder_code_service_code(extensions) + if permit_nonce is not None: + info = { + "from": owner, + "asset": option.usdc, + "spender": PERMIT2_ADDRESS, + # Must equal permitted.amount: the proxy reverts otherwise. + "amount": str(option.amount), + "nonce": str(permit_nonce), + "deadline": str(deadline), + "signature": _sign( + account, + eip2612_permit_typed_data( + domain=option.domain, + owner=owner, + value=option.amount, + nonce=permit_nonce, + deadline=deadline, + ), + ), + "version": EIP2612_GAS_SPONSORING_VERSION, + } + merged = merge_extensions(merged, {EIP2612_GAS_SPONSORING_KEY: {"info": info}}) + + payment = { + "x402Version": 2, + "resource": { + "url": resource_url, + "description": resource_description, + "mimeType": "application/json", + }, + # The requirement exactly as offered: a server may match `accepted` + # against its own accepts entry field for field. + "accepted": dict(option.requirement), + "payload": { + "signature": signature, + "permit2Authorization": { + "from": owner, + "permitted": {"token": option.usdc, "amount": str(option.amount)}, + "spender": X402_UPTO_PERMIT2_PROXY_ADDRESS, + "nonce": str(permit2_nonce), + "deadline": str(deadline), + "witness": { + "to": option.pay_to, + "facilitator": option.facilitator, + "validAfter": "0", + }, + }, + }, + "extensions": merged, + } + return base64.b64encode(json.dumps(payment).encode()).decode() + + +# --------------------------------------------------------------------------- +# One call: decide, then sign — or return None for exact +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class UptoPayment: + header: str # base64 PAYMENT-SIGNATURE + amount: int # signed ceiling, micro-USDC + permit_attached: bool + + @property + def ceiling_usd(self) -> float: + return self.amount / 1e6 + + +def _sign_planned( + account: Any, + option: UptoOption, + state: UptoChainState, + *, + resource_url: str, + resource_description: str, + extensions: dict[str, Any] | None, +) -> UptoPayment | None: + needs_permit = plan_upto(option, state) + if needs_permit is None: + return None + if not needs_permit: + # Permit2 can already pull the ceiling: no permit, whatever is in flight. + header = create_upto_payment_payload( + account, + option, + resource_url=resource_url, + resource_description=resource_description, + extensions=extensions, + ) + return UptoPayment(header, option.amount, permit_attached=False) + + assert state.permit_nonce is not None # plan_upto returns True only with one + now = int(_now()) + if not _claim_permit_slot( + option.network, account.address, state.permit_nonce, now + option.max_timeout_seconds + ): + logger.debug("x402 upto: a sponsored permit is still in flight; using exact") + return None + try: + header = create_upto_payment_payload( + account, + option, + resource_url=resource_url, + resource_description=resource_description, + extensions=extensions, + permit_nonce=state.permit_nonce, + now=now, + ) + except Exception: + _release_permit_slot(option.network, account.address, state.permit_nonce) + raise + return UptoPayment(header, option.amount, permit_attached=True) + + +def try_upto_payment( + account: Any, + option: UptoOption, + payment_required: dict[str, Any], + *, + resource_url: str, + resource_description: str, + within_limits: Callable[[float], bool], + read_state: Callable[[UptoOption, str], UptoChainState] | None = None, +) -> UptoPayment | None: + """Sign upto if the policy allows it; ``None`` (never an exception) means use exact.""" + try: + if not within_limits(option.ceiling_usd): + logger.debug("x402 upto: ceiling $%.6f breaches a spend limit", option.ceiling_usd) + return None + reader = read_state or read_upto_chain_state + state = reader(option, account.address) + return _sign_planned( + account, + option, + state, + resource_url=resource_url, + resource_description=resource_description, + extensions=payment_required.get("extensions"), + ) + except Exception as exc: + logger.debug("x402 upto: falling back to exact (%s: %s)", type(exc).__name__, exc) + return None + + +async def atry_upto_payment( + account: Any, + option: UptoOption, + payment_required: dict[str, Any], + *, + resource_url: str, + resource_description: str, + within_limits: Callable[[float], bool], + read_state: Callable[[UptoOption, str], Awaitable[UptoChainState]] | None = None, +) -> UptoPayment | None: + """Async :func:`try_upto_payment`.""" + try: + if not within_limits(option.ceiling_usd): + logger.debug("x402 upto: ceiling $%.6f breaches a spend limit", option.ceiling_usd) + return None + reader = read_state or aread_upto_chain_state + state = await reader(option, account.address) + return _sign_planned( + account, + option, + state, + resource_url=resource_url, + resource_description=resource_description, + extensions=payment_required.get("extensions"), + ) + except Exception as exc: + logger.debug("x402 upto: falling back to exact (%s: %s)", type(exc).__name__, exc) + return None + + +def settled_upto_amount(settlement: dict[str, Any] | None, ceiling: int) -> int | None: + """The settled micro-USDC a ``PAYMENT-RESPONSE`` reports, if it reports one + that is plausible for this ceiling (0 ≤ amount ≤ ceiling); else ``None``.""" + if not settlement: + return None + raw = settlement.get("amount_micro_usdc") + if raw is None or not _UINT_RE.match(str(raw)): + return None + amount = int(str(raw)) + return amount if amount <= ceiling else None + + +__all__ = [ + "EIP2612_GAS_SPONSORING_KEY", + "PAYMENT_SCHEMES", + "PERMIT2_ADDRESS", + "X402_UPTO_PERMIT2_PROXY_ADDRESS", + "UptoChainState", + "UptoOption", + "UptoPayment", + "UptoUnavailable", + "aread_upto_chain_state", + "atry_upto_payment", + "create_upto_payment_payload", + "eip2612_permit_typed_data", + "find_upto_option", + "gas_sponsoring_declared", + "merge_extensions", + "offers_upto", + "permit2_witness_typed_data", + "plan_upto", + "read_upto_chain_state", + "resolve_payment_scheme", + "settled_upto_amount", + "try_upto_payment", +] diff --git a/scripts/gen-upto-vector.mjs b/scripts/gen-upto-vector.mjs new file mode 100644 index 0000000..e734541 --- /dev/null +++ b/scripts/gen-upto-vector.mjs @@ -0,0 +1,144 @@ +// Generates tests/unit/x402_upto_reference_vector.json from the official +// @x402/evm 2.28.0 upto client (UptoEvmScheme + trySignEip2612PermitExtension), +// which tests/unit/test_x402_upto.py checks the Python signer against. +// Deterministic: Date.now and crypto.getRandomValues are pinned. +// +// Not run by CI. To regenerate: +// npm pack @x402/evm@2.28.0 && tar xzf x402-evm-2.28.0.tgz # → ./package +// (cd package && npm i viem@2 --no-save) +// cp scripts/gen-upto-vector.mjs package/ && node package/gen-upto-vector.mjs \ +// > tests/unit/x402_upto_reference_vector.json +// The chunk file names below are those of the 2.28.0 build; the package index +// is avoided because it pulls in @x402/core. +import { privateKeyToAccount } from "viem/accounts"; +import { hashTypedData } from "viem"; +import { UptoEvmScheme } from "./dist/esm/chunk-3LEU2NZ6.mjs"; +import { PERMIT2_ADDRESS, x402UptoPermit2ProxyAddress } from "./dist/esm/chunk-GPMYUGHX.mjs"; + +const NOW_MS = 1790000000000; +Date.now = () => NOW_MS; +Object.defineProperty(globalThis, "crypto", { + value: { getRandomValues: (arr) => { arr.fill(0x11); return arr; } }, + configurable: true, +}); + +const account = privateKeyToAccount( + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", +); + +const requirements = { + scheme: "upto", + network: "eip155:8453", + amount: "123456", + asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + payTo: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + maxTimeoutSeconds: 300, + extra: { + name: "USD Coin", + version: "2", + facilitatorAddress: "0x97AcCe27D5069544480BDe0F04D9F47d7422a016", + }, +}; + +const reads = []; +const signer = { + address: account.address, + signTypedData: (msg) => account.signTypedData(msg), + readContract: async (args) => { + reads.push(args.functionName); + if (args.functionName === "allowance") return 0n; + if (args.functionName === "nonces") return 7n; + throw new Error("unexpected read " + args.functionName); + }, +}; + +const scheme = new UptoEvmScheme(signer); + +// 1) No gas sponsoring declared → bare Permit2 payload. +const bare = await scheme.createPaymentPayload(2, requirements, { extensions: {} }); + +// 2) Gas sponsoring declared, allowance 0 → EIP-2612 permit attached. Its +// value is the per-call ceiling (permitted.amount): the upto proxy reverts +// with Permit2612AmountMismatch on anything else. +const sponsored = await scheme.createPaymentPayload(2, requirements, { + extensions: { eip2612GasSponsoring: { info: { version: "1" } } }, +}); + +const a = bare.payload.permit2Authorization; +const permit2Digest = hashTypedData({ + domain: { name: "Permit2", chainId: 8453, verifyingContract: PERMIT2_ADDRESS }, + types: { + PermitWitnessTransferFrom: [ + { name: "permitted", type: "TokenPermissions" }, + { name: "spender", type: "address" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" }, + { name: "witness", type: "Witness" }, + ], + TokenPermissions: [ + { name: "token", type: "address" }, + { name: "amount", type: "uint256" }, + ], + Witness: [ + { name: "to", type: "address" }, + { name: "facilitator", type: "address" }, + { name: "validAfter", type: "uint256" }, + ], + }, + primaryType: "PermitWitnessTransferFrom", + message: { + permitted: { token: a.permitted.token, amount: BigInt(a.permitted.amount) }, + spender: a.spender, + nonce: BigInt(a.nonce), + deadline: BigInt(a.deadline), + witness: { + to: a.witness.to, + facilitator: a.witness.facilitator, + validAfter: BigInt(a.witness.validAfter), + }, + }, +}); + +const eip2612DigestOf = (info) => hashTypedData({ + domain: { + name: "USD Coin", + version: "2", + chainId: 8453, + verifyingContract: requirements.asset, + }, + types: { + Permit: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" }, + { name: "value", type: "uint256" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" }, + ], + }, + primaryType: "Permit", + message: { + owner: info.from, + spender: info.spender, + value: BigInt(info.amount), + nonce: BigInt(info.nonce), + deadline: BigInt(info.deadline), + }, +}); +const eip2612Digest = eip2612DigestOf(sponsored.extensions.eip2612GasSponsoring.info); + +console.log( + JSON.stringify( + { + source: "@x402/evm 2.28.0 UptoEvmScheme (viem " + (await import("viem/package.json", { with: { type: "json" } })).default.version + ")", + inputs: { privateKey: "hardhat #0", nowSeconds: NOW_MS / 1000, randomBytes: "0x11 * 32", requirements, usdcNonce: "7", allowance: "0" }, + constants: { PERMIT2_ADDRESS, x402UptoPermit2ProxyAddress }, + bare, + sponsored, + permit2Digest, + eip2612Digest, + reads, + }, + null, + 2, + ), +); diff --git a/tests/unit/test_x402_upto.py b/tests/unit/test_x402_upto.py new file mode 100644 index 0000000..f8ef8fd --- /dev/null +++ b/tests/unit/test_x402_upto.py @@ -0,0 +1,1094 @@ +"""x402 ``upto`` (Permit2) payments. + +What these tests pin: + +1. **Wire compatibility** — for fixed inputs the Python signer produces the + byte-identical Permit2 witness signature, EIP-2612 permit signature and + payload fields as the official ``@x402/evm`` 2.28.0 client. The vector in + ``x402_upto_reference_vector.json`` was generated by that client + (``scripts/gen-upto-vector.mjs``), not by this code. +2. **Selection policy** — upto is chosen only when the 402 offers it with a + facilitator, the wallet holds the ceiling, and Permit2 may already pull it + or the gateway sponsors the approval. Every other case — and every error — + signs ``exact`` exactly as before. +3. **Rejection** — an upto payment the gateway refuses before serving anything + is retried once with exact; a 2xx is never retried. +4. **Accounting** — the upto ceiling is never booked as a confirmed charge. +""" + +from __future__ import annotations + +import asyncio +import base64 +import json +from pathlib import Path +from typing import Any + +import httpx +import pytest +from eth_account import Account +from eth_account.messages import _hash_eip191_message, encode_typed_data + +from blockrun_llm import AsyncLLMClient, LLMClient, cache +from blockrun_llm import x402_upto as upto +from blockrun_llm.tx_log import format_row +from blockrun_llm.types import PaymentError +from blockrun_llm.x402_upto import ( + PERMIT2_ADDRESS, + X402_UPTO_PERMIT2_PROXY_ADDRESS, + UptoChainState, + create_upto_payment_payload, + eip2612_permit_typed_data, + find_upto_option, + merge_extensions, + permit2_witness_typed_data, + plan_upto, + resolve_payment_scheme, +) + +from ..helpers import TEST_ACCOUNT, TEST_PRIVATE_KEY, TEST_RECIPIENT, build_chat_response + +# The real chain readers, captured before the autouse fixture replaces them. +REAL_READ = upto.read_upto_chain_state +REAL_AREAD = upto.aread_upto_chain_state + +VECTOR = json.loads((Path(__file__).parent / "x402_upto_reference_vector.json").read_text()) +REQ = VECTOR["inputs"]["requirements"] +NOW = int(VECTOR["inputs"]["nowSeconds"]) +NONCE_0x11 = int.from_bytes(b"\x11" * 32, "big") + +USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" +FACILITATOR = "0x97AcCe27D5069544480BDe0F04D9F47d7422a016" +MESSAGES = [{"role": "user", "content": "hi"}] + +EXACT_AMOUNT = "1000" # $0.001 — what exact signs +UPTO_CEILING = "50000" # $0.05 — the upto ceiling (full max_tokens) + +SPONSORING_DECL = { + "info": { + "description": "The facilitator accepts EIP-2612 gasless Permit to `Permit2` canonical contract.", + "version": "1", + }, + "schema": {"type": "object"}, +} + + +# --------------------------------------------------------------------------- +# Builders +# --------------------------------------------------------------------------- + + +def exact_entry(amount: str = EXACT_AMOUNT) -> dict[str, Any]: + return { + "scheme": "exact", + "network": "eip155:8453", + "amount": amount, + "asset": USDC_BASE, + "payTo": TEST_RECIPIENT, + "maxTimeoutSeconds": 300, + "extra": {"name": "USD Coin", "version": "2"}, + } + + +def upto_entry(amount: str = UPTO_CEILING, **overrides: Any) -> dict[str, Any]: + entry: dict[str, Any] = { + "scheme": "upto", + "network": "eip155:8453", + "amount": amount, + "asset": USDC_BASE, + "payTo": TEST_RECIPIENT, + "maxTimeoutSeconds": 300, + "extra": {"name": "USD Coin", "version": "2", "facilitatorAddress": FACILITATOR}, + } + entry.update(overrides) + return entry + + +def payment_required( + *, with_upto: bool = True, sponsoring: bool = False, upto: dict | None = None +) -> dict[str, Any]: + accepts = [exact_entry()] + if with_upto: + accepts.append(upto or upto_entry()) + extensions: dict[str, Any] = {"builder-code": {"info": {"a": ["app"]}}} + if sponsoring: + extensions["eip2612GasSponsoring"] = SPONSORING_DECL + return { + "x402Version": 2, + "accepts": accepts, + "resource": { + "url": "https://blockrun.ai/api/v1/chat/completions", + "description": "AI model inference", + }, + "extensions": extensions, + } + + +def b64(obj: dict) -> str: + return base64.b64encode(json.dumps(obj).encode()).decode() + + +def decode(header: str) -> dict[str, Any]: + return json.loads(base64.b64decode(header)) + + +def settlement_header(amount: str | None = None) -> str: + data: dict[str, Any] = { + "success": True, + "transaction": "0x" + "ab" * 32, + "network": "eip155:8453", + } + if amount is not None: + data["amount"] = amount + return b64(data) + + +class Gateway: + """A MockTransport gateway: 402 on the unpaid call, then scripted answers + for each paid call. Records every PAYMENT-SIGNATURE it receives.""" + + def __init__( + self, + pr: dict[str, Any], + paid: list[httpx.Response] | None = None, + *, + price_usd: str | None = None, + header: bool = True, + ): + self.pr = pr + self.paid = list(paid or []) + self.signed: list[dict[str, Any]] = [] + self.price_usd = price_usd + self.header = header + + def __call__(self, request: httpx.Request) -> httpx.Response: + sig = request.headers.get("PAYMENT-SIGNATURE") + if sig is None: + if self.header: + return httpx.Response( + 402, + json={"error": "Payment Required"}, + headers={"payment-required": b64(self.pr)}, + ) + body: dict[str, Any] = {**self.pr, "x402": True} + if self.price_usd is not None: + body["price"] = {"amount": self.price_usd, "currency": "USD"} + return httpx.Response(402, json=body) + self.signed.append(decode(sig)) + if self.paid: + return self.paid.pop(0) + return httpx.Response( + 200, json=build_chat_response(), headers={"PAYMENT-RESPONSE": settlement_header()} + ) + + @property + def schemes(self) -> list[str]: + return [s["accepted"]["scheme"] for s in self.signed] + + +def ok(amount: str | None = None) -> httpx.Response: + return httpx.Response( + 200, json=build_chat_response(), headers={"PAYMENT-RESPONSE": settlement_header(amount)} + ) + + +def verify_failed() -> httpx.Response: + return httpx.Response( + 402, json={"error": "Payment verification failed", "debug": "permit2_allowance_required"} + ) + + +def sync_client(gw: Gateway, **kwargs: Any) -> LLMClient: + client = LLMClient(private_key=TEST_PRIVATE_KEY, **kwargs) + client._client = httpx.Client(transport=httpx.MockTransport(gw)) + return client + + +def async_client(gw: Gateway, **kwargs: Any) -> AsyncLLMClient: + client = AsyncLLMClient(private_key=TEST_PRIVATE_KEY, **kwargs) + client._client = httpx.AsyncClient(transport=httpx.MockTransport(gw)) + return client + + +@pytest.fixture(autouse=True) +def _isolate(tmp_path, monkeypatch): + """Keep the local archive out of ~/.blockrun, clear the permit registry, + and make any unmocked chain read fail loudly rather than hit the network.""" + monkeypatch.setattr(cache, "CACHE_DIR", tmp_path / "cache") + monkeypatch.setattr(cache, "DATA_DIR", tmp_path / "data") + monkeypatch.setattr(cache, "COST_LOG_PATH", tmp_path / "cost_log.jsonl") + monkeypatch.delenv(upto.PAYMENT_SCHEME_ENV, raising=False) + upto._PERMITS_IN_FLIGHT.clear() + + def no_network(*_a: Any, **_kw: Any) -> Any: + raise AssertionError("unmocked chain read") + + monkeypatch.setattr(upto, "read_upto_chain_state", no_network) + monkeypatch.setattr(upto, "aread_upto_chain_state", no_network) + yield + upto._PERMITS_IN_FLIGHT.clear() + + +class Chain: + """Scripted chain state for the upto reads (sync and async).""" + + def __init__( + self, + *, + balance: int = 10**9, + allowance: int = 10**9, + nonce: int = 7, + fail: bool = False, + ): + self.balance, self.allowance, self.nonce, self.fail = balance, allowance, nonce, fail + self.reads = 0 + + def state(self, option: upto.UptoOption, owner: str) -> UptoChainState: + self.reads += 1 + if self.fail: + raise upto.UptoUnavailable("every RPC failed") + assert owner == TEST_ACCOUNT.address + need = option.gas_sponsoring and self.allowance < option.amount + return UptoChainState(self.balance, self.allowance, self.nonce if need else None) + + def install(self, monkeypatch: pytest.MonkeyPatch) -> Chain: + monkeypatch.setattr(upto, "read_upto_chain_state", self.state) + + async def astate(option: upto.UptoOption, owner: str) -> UptoChainState: + return self.state(option, owner) + + monkeypatch.setattr(upto, "aread_upto_chain_state", astate) + return self + + +# --------------------------------------------------------------------------- +# 1. Wire compatibility with the official client +# --------------------------------------------------------------------------- + + +class TestReferenceVector: + def _option(self, sponsoring: bool): + pr = {"x402Version": 2, "accepts": [REQ]} + if sponsoring: + pr["extensions"] = {"eip2612GasSponsoring": {"info": {"version": "1"}}} + option = find_upto_option(pr) + assert option is not None + return option, pr + + def test_constants_match_reference(self): + assert PERMIT2_ADDRESS == VECTOR["constants"]["PERMIT2_ADDRESS"] + assert X402_UPTO_PERMIT2_PROXY_ADDRESS == VECTOR["constants"]["x402UptoPermit2ProxyAddress"] + + def test_permit2_typed_data_hash_matches_viem(self): + option, _ = self._option(False) + typed = permit2_witness_typed_data( + chain_id=8453, + token=option.usdc, + amount=option.amount, + nonce=NONCE_0x11, + deadline=NOW + 300, + pay_to=option.pay_to, + facilitator=option.facilitator, + ) + digest = _hash_eip191_message(encode_typed_data(full_message=typed)) + assert "0x" + digest.hex() == VECTOR["permit2Digest"] + + def test_eip2612_typed_data_hash_matches_viem(self): + option, _ = self._option(True) + typed = eip2612_permit_typed_data( + domain=option.domain, + owner=TEST_ACCOUNT.address, + value=option.amount, + nonce=7, + deadline=NOW + 300, + ) + digest = _hash_eip191_message(encode_typed_data(full_message=typed)) + assert "0x" + digest.hex() == VECTOR["eip2612Digest"] + + def test_bare_payload_is_byte_identical(self): + option, pr = self._option(False) + payment = decode( + create_upto_payment_payload( + TEST_ACCOUNT, + option, + resource_url="https://blockrun.ai/api/v1/chat/completions", + resource_description="d", + extensions=pr.get("extensions"), + now=NOW, + nonce=NONCE_0x11, + ) + ) + assert payment["payload"] == VECTOR["bare"]["payload"] + assert "eip2612GasSponsoring" not in payment["extensions"] + + def test_sponsored_payload_is_byte_identical(self): + option, pr = self._option(True) + payment = decode( + create_upto_payment_payload( + TEST_ACCOUNT, + option, + resource_url="https://blockrun.ai/api/v1/chat/completions", + resource_description="d", + extensions=pr["extensions"], + permit_nonce=7, + now=NOW, + nonce=NONCE_0x11, + ) + ) + expected = VECTOR["sponsored"] + assert payment["payload"] == expected["payload"] + info = payment["extensions"]["eip2612GasSponsoring"]["info"] + assert info == expected["extensions"]["eip2612GasSponsoring"]["info"] + + def test_permit_value_equals_permitted_amount(self): + """The upto proxy reverts (Permit2612AmountMismatch) unless the EIP-2612 + permit's value is exactly the Permit2 permitted amount — so MaxUint256 + (or anything but the ceiling) is a signed payment that cannot settle.""" + option, pr = self._option(True) + payment = decode( + create_upto_payment_payload( + TEST_ACCOUNT, + option, + resource_url="u", + resource_description="d", + extensions=pr["extensions"], + permit_nonce=7, + ) + ) + info = payment["extensions"]["eip2612GasSponsoring"]["info"] + permitted = payment["payload"]["permit2Authorization"]["permitted"]["amount"] + assert info["amount"] == permitted == REQ["amount"] + # …and the signature is over that value, not just the JSON field. + typed = eip2612_permit_typed_data( + domain=option.domain, + owner=TEST_ACCOUNT.address, + value=int(permitted), + nonce=7, + deadline=int(info["deadline"]), + ) + signer = Account.recover_message( + encode_typed_data(full_message=typed), signature=info["signature"] + ) + assert signer == TEST_ACCOUNT.address + + def test_signatures_recover_to_the_payer(self): + auth = VECTOR["sponsored"]["payload"]["permit2Authorization"] + typed = permit2_witness_typed_data( + chain_id=8453, + token=auth["permitted"]["token"], + amount=int(auth["permitted"]["amount"]), + nonce=int(auth["nonce"]), + deadline=int(auth["deadline"]), + pay_to=auth["witness"]["to"], + facilitator=auth["witness"]["facilitator"], + ) + signer = Account.recover_message( + encode_typed_data(full_message=typed), + signature=VECTOR["sponsored"]["payload"]["signature"], + ) + assert signer == TEST_ACCOUNT.address + + +class TestPayloadEnvelope: + def test_envelope_matches_exact_and_echoes_the_requirement(self): + pr = payment_required(sponsoring=True) + option = find_upto_option(pr) + payment = decode( + create_upto_payment_payload( + TEST_ACCOUNT, + option, + resource_url="https://blockrun.ai/api/v1/chat/completions", + resource_description="AI model inference", + extensions=pr["extensions"], + permit_nonce=3, + ) + ) + assert payment["x402Version"] == 2 + assert payment["accepted"] == upto_entry() # verbatim, facilitatorAddress included + assert payment["resource"]["url"] == "https://blockrun.ai/api/v1/chat/completions" + # BlockRun's builder service code merged in, the server's app code kept. + assert payment["extensions"]["builder-code"]["info"] == {"a": ["app"], "s": ["blockrun"]} + # @x402/core merge semantics: server-declared fields stay, client fields added. + info = payment["extensions"]["eip2612GasSponsoring"]["info"] + assert info["description"] == SPONSORING_DECL["info"]["description"] + assert info["version"] == "1" + assert info["spender"] == PERMIT2_ADDRESS + assert info["amount"] == UPTO_CEILING # must equal permitted.amount + assert info["nonce"] == "3" + assert info["deadline"] == payment["payload"]["permit2Authorization"]["deadline"] + assert payment["extensions"]["eip2612GasSponsoring"]["schema"] == {"type": "object"} + + def test_nonce_is_random_256_bit_decimal(self): + option = find_upto_option(payment_required()) + nonces = { + decode( + create_upto_payment_payload( + TEST_ACCOUNT, option, resource_url="u", resource_description="d" + ) + )["payload"]["permit2Authorization"]["nonce"] + for _ in range(3) + } + assert len(nonces) == 3 + assert all(n.isdigit() and int(n) < 2**256 for n in nonces) + + def test_merge_extensions_non_object_replaces(self): + assert merge_extensions( + {"a": 1, "b": {"x": 1}}, {"a": {"y": 2}, "b": {"x": 9, "z": 3}} + ) == { + "a": {"y": 2}, + "b": {"x": 1, "z": 3}, + } + + +# --------------------------------------------------------------------------- +# 2. Selection policy +# --------------------------------------------------------------------------- + + +class TestFindOption: + def test_valid_offer(self): + option = find_upto_option(payment_required(sponsoring=True)) + assert option is not None + assert option.amount == int(UPTO_CEILING) + assert option.facilitator == FACILITATOR + assert option.gas_sponsoring is True + assert option.chain_id == 8453 + + @pytest.mark.parametrize( + "bad", + [ + upto_entry(extra={"name": "USD Coin", "version": "2"}), # no facilitatorAddress + upto_entry(asset="0x0000000000000000000000000000000000000001"), # not USDC + upto_entry(network="eip155:1"), # a network this SDK does not sign on + upto_entry(network="solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"), # Solana: exact only + upto_entry(amount="0"), + upto_entry(amount="-5"), + upto_entry(payTo="nope"), + upto_entry(maxTimeoutSeconds=0), + ], + ) + def test_unusable_offer_is_ignored(self, bad): + assert find_upto_option(payment_required(upto=bad)) is None + + def test_no_upto_offer(self): + assert find_upto_option(payment_required(with_upto=False)) is None + + def test_v1_max_amount_required(self): + entry = upto_entry() + entry["maxAmountRequired"] = entry.pop("amount") + option = find_upto_option(payment_required(upto=entry)) + assert option is not None and option.amount == int(UPTO_CEILING) + + +class TestPlan: + option = find_upto_option(payment_required()) + sponsored = find_upto_option(payment_required(sponsoring=True)) + ceiling = int(UPTO_CEILING) + + def test_allowance_covers_ceiling(self): + assert plan_upto(self.option, UptoChainState(self.ceiling, self.ceiling, None)) is False + + def test_no_allowance_no_sponsoring_is_exact(self): + assert plan_upto(self.option, UptoChainState(10**9, self.ceiling - 1, None)) is None + + def test_no_allowance_with_sponsoring_needs_permit(self): + assert plan_upto(self.sponsored, UptoChainState(10**9, 0, 4)) is True + + def test_balance_below_ceiling_is_exact(self): + """Exact might fit a wallet the ceiling does not: never worse than today.""" + assert plan_upto(self.sponsored, UptoChainState(self.ceiling - 1, 10**9, None)) is None + + +class TestResolveScheme: + def test_default_auto(self): + assert resolve_payment_scheme(None) == "auto" + + def test_env(self, monkeypatch): + monkeypatch.setenv(upto.PAYMENT_SCHEME_ENV, "EXACT") + assert resolve_payment_scheme(None) == "exact" + + def test_argument_beats_env(self, monkeypatch): + monkeypatch.setenv(upto.PAYMENT_SCHEME_ENV, "exact") + assert resolve_payment_scheme("auto") == "auto" + + def test_invalid(self): + with pytest.raises(ValueError, match="payment_scheme"): + resolve_payment_scheme("upto") + + def test_client_rejects_invalid(self): + with pytest.raises(ValueError): + LLMClient(private_key=TEST_PRIVATE_KEY, payment_scheme="cheapest") + + +class TestClientSelection: + def test_prefers_upto_with_allowance(self, monkeypatch): + chain = Chain().install(monkeypatch) + gw = Gateway(payment_required()) + response = sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["upto"] + sent = gw.signed[0] + auth = sent["payload"]["permit2Authorization"] + assert auth["permitted"] == {"token": USDC_BASE, "amount": UPTO_CEILING} + assert auth["spender"] == X402_UPTO_PERMIT2_PROXY_ADDRESS + assert auth["witness"]["facilitator"] == FACILITATOR + assert "eip2612GasSponsoring" not in sent["extensions"] + assert response.payment_scheme == "upto" + assert chain.reads == 1 + + def test_sponsored_permit_attached_when_no_allowance(self, monkeypatch): + Chain(allowance=0, nonce=11).install(monkeypatch) + gw = Gateway(payment_required(sponsoring=True)) + sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["upto"] + info = gw.signed[0]["extensions"]["eip2612GasSponsoring"]["info"] + assert info["nonce"] == "11" and info["amount"] == UPTO_CEILING + # The permit's value must equal Permit2's permitted amount. + assert ( + gw.signed[0]["payload"]["permit2Authorization"]["permitted"]["amount"] == UPTO_CEILING + ) + assert info["from"] == TEST_ACCOUNT.address + + def test_sponsoring_declared_but_allowance_suffices_skips_permit(self, monkeypatch): + Chain(allowance=10**9).install(monkeypatch) + gw = Gateway(payment_required(sponsoring=True)) + sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["upto"] + # The server's declaration is echoed (as @x402/core does) but carries no signed permit. + assert "signature" not in gw.signed[0]["extensions"]["eip2612GasSponsoring"]["info"] + + def test_no_allowance_no_sponsoring_falls_back(self, monkeypatch): + Chain(allowance=0).install(monkeypatch) + gw = Gateway(payment_required(sponsoring=False)) + response = sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["exact"] + assert gw.signed[0]["payload"]["authorization"]["value"] == EXACT_AMOUNT + assert response.payment_scheme == "exact" + assert response.cost_usd == pytest.approx(0.001) + + def test_rpc_failure_falls_back(self, monkeypatch): + Chain(fail=True).install(monkeypatch) + gw = Gateway(payment_required(sponsoring=True)) + sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["exact"] + + def test_signing_error_falls_back(self, monkeypatch): + Chain().install(monkeypatch) + + def boom(*_a, **_kw): + raise RuntimeError("signer exploded") + + monkeypatch.setattr(upto, "create_upto_payment_payload", boom) + gw = Gateway(payment_required()) + sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["exact"] + + def test_missing_facilitator_never_reads_chain(self, monkeypatch): + chain = Chain().install(monkeypatch) + gw = Gateway(payment_required(upto=upto_entry(extra={"name": "USD Coin", "version": "2"}))) + sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["exact"] + assert chain.reads == 0 + + def test_opt_out_argument(self, monkeypatch): + chain = Chain().install(monkeypatch) + gw = Gateway(payment_required(sponsoring=True)) + sync_client(gw, payment_scheme="exact").chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["exact"] + assert chain.reads == 0 + + def test_opt_out_env(self, monkeypatch): + monkeypatch.setenv(upto.PAYMENT_SCHEME_ENV, "exact") + chain = Chain().install(monkeypatch) + gw = Gateway(payment_required(sponsoring=True)) + sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["exact"] + assert chain.reads == 0 + + def test_insufficient_balance_falls_back(self, monkeypatch): + Chain(balance=int(UPTO_CEILING) - 1).install(monkeypatch) + gw = Gateway(payment_required(sponsoring=True)) + sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["exact"] + + def test_ceiling_over_per_call_limit_signs_exact_instead_of_refusing(self, monkeypatch): + """The cap is checked against the ceiling; a ceiling over the cap is not a + refusal when exact fits under it.""" + chain = Chain().install(monkeypatch) + gw = Gateway(payment_required()) + response = sync_client(gw, max_cost_per_call=0.01).chat_completion( + "deepseek/deepseek-chat", MESSAGES + ) + assert gw.schemes == ["exact"] + assert response.cost_usd == pytest.approx(0.001) + assert chain.reads == 0 # refused on the ceiling before any chain read + + def test_exact_fallback_books_its_own_amount_not_the_ceiling_price(self, monkeypatch): + """A gateway offering upto quotes `price` at the ceiling; exact must not + book (or cap on) that number.""" + Chain(allowance=0).install(monkeypatch) + gw = Gateway(payment_required(), header=False, price_usd="0.050000") + client = sync_client(gw, max_cost_per_call=0.01) + response = client.chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["exact"] + assert response.cost_usd == pytest.approx(0.001) + assert client.get_spending()["total_usd"] == pytest.approx(0.001) + + def test_exact_only_402_is_unchanged(self, monkeypatch): + chain = Chain().install(monkeypatch) + gw = Gateway(payment_required(with_upto=False)) + response = sync_client(gw).chat_completion("openai/gpt-5.2", MESSAGES) + assert gw.schemes == ["exact"] + assert chain.reads == 0 + assert response.payment_scheme == "exact" + assert response.cost_is_ceiling is False + + def test_permit_in_flight_pays_exact_until_nonce_is_consumed(self, monkeypatch): + """Per wallet only one gas-sponsored upto payment is in flight: while the + on-chain USDC nonce still equals the one signed over (and the permit has + not expired), a call that would need another permit pays exact. Once the + nonce moves, the next permit is signed over the new one.""" + chain = Chain(allowance=0, nonce=5).install(monkeypatch) + gw = Gateway(payment_required(sponsoring=True)) + client = sync_client(gw) + client.chat_completion("deepseek/deepseek-chat", MESSAGES) # permit over nonce 5 + client.chat_completion("deepseek/deepseek-chat", MESSAGES) # nonce still 5 → exact + chain.nonce = 6 # the first permit settled (and its allowance was spent) + client.chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["upto", "exact", "upto"] + nonces = [ + s["extensions"]["eip2612GasSponsoring"]["info"]["nonce"] + for s in gw.signed + if s["accepted"]["scheme"] == "upto" + ] + assert nonces == ["5", "6"] + + def test_permit_in_flight_cleared_after_deadline(self, monkeypatch): + clock = [1_000_000.0] + monkeypatch.setattr(upto, "_now", lambda: clock[0]) + Chain(allowance=0, nonce=5).install(monkeypatch) + gw = Gateway(payment_required(sponsoring=True)) # maxTimeoutSeconds 300 + client = sync_client(gw) + client.chat_completion("deepseek/deepseek-chat", MESSAGES) # permit, deadline +300 + clock[0] += 299 + client.chat_completion("deepseek/deepseek-chat", MESSAGES) # in flight → exact + clock[0] += 1 # at the deadline the permit can no longer land + client.chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["upto", "exact", "upto"] + assert gw.signed[2]["extensions"]["eip2612GasSponsoring"]["info"]["nonce"] == "5" + + def test_allowance_covering_ceiling_needs_no_permit_even_in_flight(self, monkeypatch): + chain = Chain(allowance=0, nonce=5).install(monkeypatch) + gw = Gateway(payment_required(sponsoring=True)) + client = sync_client(gw) + client.chat_completion("deepseek/deepseek-chat", MESSAGES) # permit over nonce 5 + chain.allowance = int(UPTO_CEILING) # e.g. an approval made elsewhere + client.chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["upto", "upto"] + assert "signature" not in gw.signed[1]["extensions"]["eip2612GasSponsoring"]["info"] + + def test_permit_guard_is_per_wallet_and_network(self): + far = int(upto._now()) + 300 + assert upto._claim_permit_slot("eip155:8453", "0xAAA", 5, far) + assert not upto._claim_permit_slot("eip155:8453", "0xaaa", 5, far) + assert upto._claim_permit_slot("eip155:8453", "0xBBB", 5, far) + assert upto._claim_permit_slot("eip155:84532", "0xAAA", 5, far) + assert upto._claim_permit_slot("eip155:8453", "0xAAA", 6, far) # nonce moved + + def test_signing_failure_releases_the_slot(self, monkeypatch): + Chain(allowance=0, nonce=5).install(monkeypatch) + real = upto.create_upto_payment_payload + calls = [0] + + def flaky(*a, **kw): + calls[0] += 1 + if calls[0] == 1: + raise RuntimeError("signer hiccup") + return real(*a, **kw) + + monkeypatch.setattr(upto, "create_upto_payment_payload", flaky) + gw = Gateway(payment_required(sponsoring=True)) + client = sync_client(gw) + client.chat_completion("deepseek/deepseek-chat", MESSAGES) # error → exact + client.chat_completion("deepseek/deepseek-chat", MESSAGES) # slot free → permit + assert gw.schemes == ["exact", "upto"] + + +# --------------------------------------------------------------------------- +# 3. Rejection → one exact retry +# --------------------------------------------------------------------------- + + +class TestRejection: + def test_upto_rejected_then_exact_succeeds_and_is_remembered(self, monkeypatch): + chain = Chain().install(monkeypatch) + gw = Gateway(payment_required(), paid=[verify_failed(), ok()]) + client = sync_client(gw) + response = client.chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["upto", "exact"] + assert response.payment_scheme == "exact" + assert response.cost_usd == pytest.approx(0.001) + assert client.get_spending() == { + "total_usd": pytest.approx(0.001), + "calls": 1, + "ceiling_usd": 0.0, + } + # Remembered for the life of the client: straight to exact, no chain read. + reads = chain.reads + client.chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["upto", "exact", "exact"] + assert chain.reads == reads + + def test_rejection_is_per_client(self, monkeypatch): + Chain().install(monkeypatch) + gw = Gateway(payment_required(), paid=[verify_failed(), ok()]) + sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["upto", "exact", "upto"] + + def test_payment_code_4xx_body_counts_as_rejection(self, monkeypatch): + Chain().install(monkeypatch) + refused = httpx.Response(400, json={"error": "bad", "code": "PAYMENT_INVALID"}) + gw = Gateway(payment_required(), paid=[refused, ok()]) + sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["upto", "exact"] + + def test_exact_retry_also_rejected_surfaces_original_error(self, monkeypatch): + Chain().install(monkeypatch) + original = httpx.Response(400, json={"error": "Payment verification failed", "x": "upto"}) + second = httpx.Response(402, json={"error": "Payment verification failed", "x": "exact"}) + gw = Gateway(payment_required(), paid=[original, second]) + client = sync_client(gw) + from blockrun_llm.types import APIError + + with pytest.raises(APIError) as exc: + client.chat_completion("deepseek/deepseek-chat", MESSAGES) + assert exc.value.status_code == 400 # the upto refusal, not the exact one + assert gw.schemes == ["upto", "exact"] # no third attempt + assert client.get_spending()["calls"] == 0 + + def test_exact_payment_rejected_is_never_retried(self, monkeypatch): + Chain(allowance=0).install(monkeypatch) + gw = Gateway(payment_required(), paid=[verify_failed(), ok()]) + with pytest.raises(PaymentError): + sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["exact"] + + def test_2xx_never_triggers_a_retry(self, monkeypatch): + Chain().install(monkeypatch) + # A 200 whose body even mentions payment failure is served, not retried. + served = httpx.Response( + 200, + json={**build_chat_response(), "code": "PAYMENT_INVALID"}, + headers={"PAYMENT-RESPONSE": settlement_header()}, + ) + gw = Gateway(payment_required(), paid=[served]) + client = sync_client(gw) + response = client.chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["upto"] + assert response.payment_scheme == "upto" + assert client._upto_rejected == set() + + def test_non_payment_error_after_upto_is_not_retried(self, monkeypatch): + Chain().install(monkeypatch) + gw = Gateway(payment_required(), paid=[httpx.Response(400, json={"error": "bad request"})]) + from blockrun_llm.types import APIError + + with pytest.raises(APIError): + sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["upto"] + + def test_async_upto_rejected_then_exact(self, monkeypatch): + Chain().install(monkeypatch) + gw = Gateway(payment_required(), paid=[verify_failed(), ok()]) + + async def run(): + client = async_client(gw) + first = await client.chat_completion("deepseek/deepseek-chat", MESSAGES) + await client.chat_completion("deepseek/deepseek-chat", MESSAGES) + return first + + first = asyncio.run(run()) + assert gw.schemes == ["upto", "exact", "exact"] + assert first.payment_scheme == "exact" + + def test_async_exact_retry_rejected_surfaces_original(self, monkeypatch): + Chain().install(monkeypatch) + gw = Gateway(payment_required(), paid=[verify_failed(), verify_failed()]) + + async def run(): + await async_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + + with pytest.raises(PaymentError): + asyncio.run(run()) + assert gw.schemes == ["upto", "exact"] + + +# --------------------------------------------------------------------------- +# 4. Accounting: a ceiling is not a charge +# --------------------------------------------------------------------------- + + +class TestAccounting: + def test_ceiling_booked_and_labeled_without_settled_amount(self, monkeypatch): + Chain().install(monkeypatch) + gw = Gateway(payment_required()) + client = sync_client(gw) + response = client.chat_completion("deepseek/deepseek-chat", MESSAGES) + assert response.cost_usd == pytest.approx(0.05) + assert response.cost_is_ceiling is True + assert client.get_spending() == { + "total_usd": pytest.approx(0.05), + "calls": 1, + "ceiling_usd": pytest.approx(0.05), + } + row = json.loads(cache.COST_LOG_PATH.read_text().splitlines()[-1]) + assert row["cost_basis"] == "upto_ceiling" + + def test_settled_amount_from_payment_response_is_booked(self, monkeypatch): + Chain().install(monkeypatch) + gw = Gateway(payment_required(), paid=[ok(amount="1234")]) + client = sync_client(gw) + response = client.chat_completion("deepseek/deepseek-chat", MESSAGES) + assert response.cost_usd == pytest.approx(0.001234) + assert response.cost_is_ceiling is False + assert client.get_spending()["ceiling_usd"] == 0.0 + row = json.loads(cache.COST_LOG_PATH.read_text().splitlines()[-1]) + assert row["cost_basis"] == "upto_settled" + + def test_settled_amount_above_ceiling_is_not_trusted(self, monkeypatch): + Chain().install(monkeypatch) + gw = Gateway(payment_required(), paid=[ok(amount=str(int(UPTO_CEILING) + 1))]) + response = sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert response.cost_usd == pytest.approx(0.05) + assert response.cost_is_ceiling is True + + def test_exact_rows_unchanged(self, monkeypatch): + Chain(allowance=0).install(monkeypatch) + sync_client(Gateway(payment_required())).chat_completion("deepseek/deepseek-chat", MESSAGES) + row = json.loads(cache.COST_LOG_PATH.read_text().splitlines()[-1]) + assert "cost_basis" not in row + + def test_session_limit_counts_the_ceiling(self, monkeypatch): + """Unconfirmed ceilings count against max_session_cost: the cap errs safe.""" + Chain().install(monkeypatch) + gw = Gateway(payment_required()) + client = sync_client(gw, max_session_cost=0.06) + client.chat_completion("deepseek/deepseek-chat", MESSAGES) # upto, books 0.05 + client.chat_completion("deepseek/deepseek-chat", MESSAGES) # ceiling would breach + assert gw.schemes == ["upto", "exact"] + + def test_tx_log_marks_ceiling(self): + row = format_row( + endpoint="/v1/chat/completions", + model="deepseek/deepseek-chat", + in_tokens=1, + out_tokens=1, + cost_usd=0.05, + tx_hash=None, + cost_basis="upto_ceiling", + ) + assert row.endswith("(upto ceiling)") + plain = format_row( + endpoint="/v1/chat/completions", + model="m", + in_tokens=1, + out_tokens=1, + cost_usd=0.05, + tx_hash=None, + ) + assert "ceiling" not in plain + + +# --------------------------------------------------------------------------- +# Streaming +# --------------------------------------------------------------------------- + + +def _sse() -> bytes: + chunk = { + "id": "c", + "object": "chat.completion.chunk", + "created": 1, + "model": "deepseek/deepseek-chat", + "choices": [{"index": 0, "delta": {"content": "hi"}, "finish_reason": "stop"}], + } + return f"data: {json.dumps(chunk)}\n\ndata: [DONE]\n\n".encode() + + +def sse_ok() -> httpx.Response: + return httpx.Response( + 200, + content=_sse(), + headers={"content-type": "text/event-stream", "PAYMENT-RESPONSE": settlement_header()}, + ) + + +class TestStreaming: + def test_upto_stream_chunks_are_labeled(self, monkeypatch): + Chain().install(monkeypatch) + gw = Gateway(payment_required(), paid=[sse_ok()]) + client = sync_client(gw) + chunks = list(client.chat_completion_stream("deepseek/deepseek-chat", MESSAGES)) + assert gw.schemes == ["upto"] + assert chunks and all(c.payment_scheme == "upto" for c in chunks) + assert all(c.cost_is_ceiling is True for c in chunks) + assert client.get_spending()["ceiling_usd"] == pytest.approx(0.05) + + def test_stream_upto_rejected_retries_exact_before_any_chunk(self, monkeypatch): + Chain().install(monkeypatch) + gw = Gateway(payment_required(), paid=[verify_failed(), sse_ok()]) + client = sync_client(gw) + chunks = list(client.chat_completion_stream("deepseek/deepseek-chat", MESSAGES)) + assert gw.schemes == ["upto", "exact"] + assert chunks[0].payment_scheme == "exact" + assert chunks[0].cost_usd == pytest.approx(0.001) + + def test_stream_exact_retry_rejected_raises_once(self, monkeypatch): + Chain().install(monkeypatch) + gw = Gateway(payment_required(), paid=[verify_failed(), verify_failed(), sse_ok()]) + with pytest.raises(PaymentError): + list(sync_client(gw).chat_completion_stream("deepseek/deepseek-chat", MESSAGES)) + assert gw.schemes == ["upto", "exact"] + + def test_async_stream_upto_rejected_then_exact(self, monkeypatch): + Chain().install(monkeypatch) + gw = Gateway(payment_required(), paid=[verify_failed(), sse_ok()]) + + async def run(): + client = async_client(gw) + return [ + c async for c in client.chat_completion_stream("deepseek/deepseek-chat", MESSAGES) + ] + + chunks = asyncio.run(run()) + assert gw.schemes == ["upto", "exact"] + assert chunks[0].payment_scheme == "exact" + + def test_async_stream_upto(self, monkeypatch): + Chain(allowance=0).install(monkeypatch) + gw = Gateway(payment_required(sponsoring=True), paid=[sse_ok()]) + + async def run(): + client = async_client(gw) + return [ + c async for c in client.chat_completion_stream("deepseek/deepseek-chat", MESSAGES) + ] + + chunks = asyncio.run(run()) + assert gw.schemes == ["upto"] + assert "eip2612GasSponsoring" in gw.signed[0]["extensions"] + assert chunks[0].cost_is_ceiling is True + + +# --------------------------------------------------------------------------- +# Chain reads (eth_call over a mocked RPC) +# --------------------------------------------------------------------------- + + +def _word(n: int) -> str: + return "0x" + format(n, "064x") + + +class TestChainReads: + option = find_upto_option(payment_required()) + sponsored = find_upto_option(payment_required(sponsoring=True)) + + def _rpc(self, answers: dict[str, Any], calls: list[str]): + def handler(request: httpx.Request) -> httpx.Response: + body = json.loads(request.content) + data = body["params"][0]["data"] + calls.append(data[:10]) + answer = answers[data[:10]] + return httpx.Response(200, json=answer) + + return handler + + def test_reads_balance_and_allowance_with_correct_calldata(self): + seen: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + body = json.loads(request.content) + assert body["params"][0]["to"] == USDC_BASE + seen.append(body["params"][0]["data"]) + return httpx.Response(200, json={"jsonrpc": "2.0", "id": 1, "result": _word(5)}) + + with httpx.Client(transport=httpx.MockTransport(handler)) as http: + state = upto._read_state_sync(http, "https://rpc", self.option, TEST_ACCOUNT.address) + owner = TEST_ACCOUNT.address[2:].lower().rjust(64, "0") + permit2 = PERMIT2_ADDRESS[2:].lower().rjust(64, "0") + assert seen == ["0x70a08231" + owner, "0xdd62ed3e" + owner + permit2] + assert state == UptoChainState(5, 5, None) # no nonce read without sponsoring + + def test_nonce_read_only_when_permit_needed(self): + calls: list[str] = [] + answers = { + "0x70a08231": {"result": _word(10**9)}, + "0xdd62ed3e": {"result": _word(0)}, + "0x7ecebe00": {"result": _word(42)}, + } + with httpx.Client(transport=httpx.MockTransport(self._rpc(answers, calls))) as http: + state = upto._read_state_sync(http, "https://rpc", self.sponsored, TEST_ACCOUNT.address) + assert calls == ["0x70a08231", "0xdd62ed3e", "0x7ecebe00"] + assert state.permit_nonce == 42 + + @pytest.mark.parametrize( + "answer", + [{"error": {"code": -32000, "message": "boom"}}, {"result": "0x"}, {"result": None}], + ) + def test_bad_rpc_answers_raise(self, answer): + with ( + httpx.Client( + transport=httpx.MockTransport(lambda r: httpx.Response(200, json=answer)) + ) as http, + pytest.raises(upto.UptoUnavailable), + ): + upto._read_state_sync(http, "https://rpc", self.option, TEST_ACCOUNT.address) + + def test_falls_through_rpcs(self, monkeypatch): + tried: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + tried.append(str(request.url)) + if "publicnode" in str(request.url): + return httpx.Response(503) + return httpx.Response(200, json={"result": _word(9)}) + + real_client = httpx.Client + monkeypatch.setattr( + upto.httpx, + "Client", + lambda **kw: real_client(transport=httpx.MockTransport(handler), **kw), + ) + state = REAL_READ(self.option, TEST_ACCOUNT.address) + assert state.balance == 9 + assert tried[0].startswith("https://base.publicnode.com") + assert tried[-1].startswith("https://mainnet.base.org") + + def test_async_reads(self, monkeypatch): + answers = {"0x70a08231": _word(10**9), "0xdd62ed3e": _word(0), "0x7ecebe00": _word(3)} + + def handler(request: httpx.Request) -> httpx.Response: + data = json.loads(request.content)["params"][0]["data"] + return httpx.Response(200, json={"result": answers[data[:10]]}) + + real_async = httpx.AsyncClient + monkeypatch.setattr( + upto.httpx, + "AsyncClient", + lambda **kw: real_async(transport=httpx.MockTransport(handler), **kw), + ) + state = asyncio.run(REAL_AREAD(self.sponsored, TEST_ACCOUNT.address)) + assert state == UptoChainState(10**9, 0, 3) + + def test_network_without_rpcs_is_unavailable(self): + """Arc has no pinned public RPC, so it stays exact.""" + arc = find_upto_option( + payment_required( + upto=upto_entry( + network="eip155:5042", asset="0x3600000000000000000000000000000000000000" + ) + ) + ) + assert arc is not None and arc.rpcs == () + with pytest.raises(upto.UptoUnavailable): + REAL_READ(arc, TEST_ACCOUNT.address) diff --git a/tests/unit/x402_upto_reference_vector.json b/tests/unit/x402_upto_reference_vector.json new file mode 100644 index 0000000..66032bd --- /dev/null +++ b/tests/unit/x402_upto_reference_vector.json @@ -0,0 +1,89 @@ +{ + "source": "@x402/evm 2.28.0 UptoEvmScheme (viem 2.56.3)", + "inputs": { + "privateKey": "hardhat #0", + "nowSeconds": 1790000000, + "randomBytes": "0x11 * 32", + "requirements": { + "scheme": "upto", + "network": "eip155:8453", + "amount": "123456", + "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "payTo": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "maxTimeoutSeconds": 300, + "extra": { + "name": "USD Coin", + "version": "2", + "facilitatorAddress": "0x97AcCe27D5069544480BDe0F04D9F47d7422a016" + } + }, + "usdcNonce": "7", + "allowance": "0" + }, + "constants": { + "PERMIT2_ADDRESS": "0x000000000022D473030F116dDEE9F6B43aC78BA3", + "x402UptoPermit2ProxyAddress": "0x4020A4f3b7b90ccA423B9fabCc0CE57C6C240002" + }, + "bare": { + "x402Version": 2, + "payload": { + "signature": "0x5ff801e766700c7f476d107973c648fc4299ce414ddc0be54a16f4d6aec07ce421e36e59d4d768f5056c0d2e5e7539353d24a87075de9d3e53899a8bf6671a5f1b", + "permit2Authorization": { + "from": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "permitted": { + "token": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "amount": "123456" + }, + "spender": "0x4020A4f3b7b90ccA423B9fabCc0CE57C6C240002", + "nonce": "7719472615821079694904732333912527190217998977709370935963838933860875309329", + "deadline": "1790000300", + "witness": { + "to": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "facilitator": "0x97AcCe27D5069544480BDe0F04D9F47d7422a016", + "validAfter": "0" + } + } + } + }, + "sponsored": { + "x402Version": 2, + "payload": { + "signature": "0x5ff801e766700c7f476d107973c648fc4299ce414ddc0be54a16f4d6aec07ce421e36e59d4d768f5056c0d2e5e7539353d24a87075de9d3e53899a8bf6671a5f1b", + "permit2Authorization": { + "from": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "permitted": { + "token": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "amount": "123456" + }, + "spender": "0x4020A4f3b7b90ccA423B9fabCc0CE57C6C240002", + "nonce": "7719472615821079694904732333912527190217998977709370935963838933860875309329", + "deadline": "1790000300", + "witness": { + "to": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "facilitator": "0x97AcCe27D5069544480BDe0F04D9F47d7422a016", + "validAfter": "0" + } + } + }, + "extensions": { + "eip2612GasSponsoring": { + "info": { + "from": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "spender": "0x000000000022D473030F116dDEE9F6B43aC78BA3", + "amount": "123456", + "nonce": "7", + "deadline": "1790000300", + "signature": "0x4064c682120c9f01f9491bc73f0f786742cda0e6382d8f040399ca317ca0282023aa153f6852f7879175539ae63cff5501b9c413b5158201a36e2e57b3e399be1c", + "version": "1" + } + } + } + }, + "permit2Digest": "0x94ab2e45bed05e03c203e5ee7397f25bb42f2e962ab5b55c86a31206f9cfae9c", + "eip2612Digest": "0x46d46661b7f43243a2686ffe061b0154464c5c30fb8902b5c31cc3fc18b22a35", + "reads": [ + "allowance", + "nonces" + ] +} From 0fc47078c9cd1bc3a2a00cfe1b141e92e5315dc3 Mon Sep 17 00:00:00 2001 From: 1bcMax Date: Wed, 30 Sep 2026 22:56:29 +0800 Subject: [PATCH 2/6] fix(x402): claim the permit slot before the chain read, so concurrent calls sign one permit Three concurrent calls in one client all read the same USDC nonce before any recorded a pending permit and each signed one over it; live, one settled and two reverted. A call that might sign a permit now claims a per wallet+network preflight marker (under a lock, no await inside) before its read. A call that finds it taken never signs a permit: upto only if its own read shows the allowance covers the ceiling, else exact. Released when the preflight ends; a signed permit continues as the nonce record. --- CHANGELOG.md | 5 +- README.md | 3 +- blockrun_llm/x402_upto.py | 83 ++++++++++++++++++++++++------- tests/unit/test_x402_upto.py | 95 ++++++++++++++++++++++++++++++++++++ 4 files changed, 166 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e3c5d5..5341001 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,10 @@ All notable changes to blockrun-llm will be documented in this file. second permit over the same USDC nonce reverted on-chain in a live test of the TS SDK. The SDK records the nonce each permit signs over and, while the on-chain nonce has not moved and the permit's deadline has not passed, pays - exact for any call that would need another. Solana, the Anthropic client and + exact for any call that would need another. Concurrent calls in one client + (threads or asyncio) claim a per-wallet preflight marker before their chain + read, so only one of them can sign a permit; the others pay upto only if + Permit2's allowance already covers their ceiling, else exact. Solana, the Anthropic client and every non-chat endpoint are unchanged. Signing matches the official `@x402/evm` 2.28.0 client byte for byte: the diff --git a/README.md b/README.md index e7a3659..596da74 100644 --- a/README.md +++ b/README.md @@ -420,7 +420,8 @@ from then on. Solana is unchanged (exact only). lands only when that call settles, and until then the chain still shows the old USDC permit nonce — a second permit over the same nonce would revert on-chain. So concurrent or not-yet-settled calls that would need a permit pay `exact` -(the SDK watches the on-chain nonce, and gives up on a permit at its deadline). +(the SDK watches the on-chain nonce, and gives up on a permit at its deadline; +concurrent calls in one client claim the permit slot before reading the chain). Calls where Permit2 can already pull the ceiling are unaffected. ```python diff --git a/blockrun_llm/x402_upto.py b/blockrun_llm/x402_upto.py index b774de5..a6bae40 100644 --- a/blockrun_llm/x402_upto.py +++ b/blockrun_llm/x402_upto.py @@ -415,6 +415,33 @@ def _release_permit_slot(network: str, owner: str, signed_nonce: int) -> None: del _PERMITS_IN_FLIGHT[key] +# Wallets+networks with a permit PREFLIGHT in progress: a call that might sign +# a permit claims this marker before its chain read. Without it, concurrent +# calls in one client all read the same USDC nonce before any of them recorded +# a permit, and each signed one over it (seen live: 1 settled, 2 reverted). A +# call that finds the marker taken never signs a permit: it pays upto only if +# its own read shows Permit2's allowance already covers the ceiling, else +# exact. The marker is released when the preflight ends; a signed permit lives +# on in _PERMITS_IN_FLIGHT (the nonce record) instead. +_PERMIT_PREFLIGHT: set[tuple[str, str]] = set() + + +def _reserve_preflight(network: str, owner: str) -> bool: + """Atomically claim the permit-preflight marker. A threading.Lock with no + await inside, so it is atomic for threads and for coroutines alike.""" + key = (network, owner.lower()) + with _PERMITS_LOCK: + if key in _PERMIT_PREFLIGHT: + return False + _PERMIT_PREFLIGHT.add(key) + return True + + +def _release_preflight(network: str, owner: str) -> None: + with _PERMITS_LOCK: + _PERMIT_PREFLIGHT.discard((network, owner.lower())) + + def plan_upto(option: UptoOption, state: UptoChainState) -> bool | None: """``False`` = upto with no permit, ``True`` = upto with a sponsored permit, ``None`` = fall back to exact.""" @@ -652,10 +679,14 @@ def _sign_planned( resource_url: str, resource_description: str, extensions: dict[str, Any] | None, + may_permit: bool = True, ) -> UptoPayment | None: needs_permit = plan_upto(option, state) if needs_permit is None: return None + if needs_permit and not may_permit: + logger.debug("x402 upto: another call holds the permit preflight; using exact") + return None if not needs_permit: # Permit2 can already pull the ceiling: no permit, whatever is in flight. header = create_upto_payment_payload( @@ -706,15 +737,23 @@ def try_upto_payment( logger.debug("x402 upto: ceiling $%.6f breaches a spend limit", option.ceiling_usd) return None reader = read_state or read_upto_chain_state - state = reader(option, account.address) - return _sign_planned( - account, - option, - state, - resource_url=resource_url, - resource_description=resource_description, - extensions=payment_required.get("extensions"), - ) + # Claim the permit preflight BEFORE reading, whenever a permit might + # be signed (see _PERMIT_PREFLIGHT). + holds = option.gas_sponsoring and _reserve_preflight(option.network, account.address) + try: + state = reader(option, account.address) + return _sign_planned( + account, + option, + state, + resource_url=resource_url, + resource_description=resource_description, + extensions=payment_required.get("extensions"), + may_permit=holds, + ) + finally: + if holds: + _release_preflight(option.network, account.address) except Exception as exc: logger.debug("x402 upto: falling back to exact (%s: %s)", type(exc).__name__, exc) return None @@ -736,15 +775,23 @@ async def atry_upto_payment( logger.debug("x402 upto: ceiling $%.6f breaches a spend limit", option.ceiling_usd) return None reader = read_state or aread_upto_chain_state - state = await reader(option, account.address) - return _sign_planned( - account, - option, - state, - resource_url=resource_url, - resource_description=resource_description, - extensions=payment_required.get("extensions"), - ) + # Claim the permit preflight BEFORE reading, whenever a permit might + # be signed (see _PERMIT_PREFLIGHT). + holds = option.gas_sponsoring and _reserve_preflight(option.network, account.address) + try: + state = await reader(option, account.address) + return _sign_planned( + account, + option, + state, + resource_url=resource_url, + resource_description=resource_description, + extensions=payment_required.get("extensions"), + may_permit=holds, + ) + finally: + if holds: + _release_preflight(option.network, account.address) except Exception as exc: logger.debug("x402 upto: falling back to exact (%s: %s)", type(exc).__name__, exc) return None diff --git a/tests/unit/test_x402_upto.py b/tests/unit/test_x402_upto.py index f8ef8fd..45e4967 100644 --- a/tests/unit/test_x402_upto.py +++ b/tests/unit/test_x402_upto.py @@ -219,6 +219,7 @@ def _isolate(tmp_path, monkeypatch): monkeypatch.setattr(cache, "COST_LOG_PATH", tmp_path / "cost_log.jsonl") monkeypatch.delenv(upto.PAYMENT_SCHEME_ENV, raising=False) upto._PERMITS_IN_FLIGHT.clear() + upto._PERMIT_PREFLIGHT.clear() def no_network(*_a: Any, **_kw: Any) -> Any: raise AssertionError("unmocked chain read") @@ -227,6 +228,7 @@ def no_network(*_a: Any, **_kw: Any) -> Any: monkeypatch.setattr(upto, "aread_upto_chain_state", no_network) yield upto._PERMITS_IN_FLIGHT.clear() + upto._PERMIT_PREFLIGHT.clear() class Chain: @@ -713,6 +715,99 @@ def flaky(*a, **kw): assert gw.schemes == ["exact", "upto"] +class TestConcurrentPermits: + """Three concurrent calls in ONE client, chain at allowance 0 / nonce 6: + exactly one signs a permit, the other two pay exact.""" + + @staticmethod + def _permits(gw: Gateway) -> list[str]: + return [ + s["extensions"]["eip2612GasSponsoring"]["info"]["nonce"] + for s in gw.signed + if s["accepted"]["scheme"] == "upto" + and "signature" in s["extensions"]["eip2612GasSponsoring"]["info"] + ] + + def test_threads(self, monkeypatch): + import threading + import time as _time + from concurrent.futures import ThreadPoolExecutor + + chain = Chain(allowance=0, nonce=6) + lock = threading.Lock() + + def slow_state(option, owner): + _time.sleep(0.1) # every call is inside its read at once + with lock: + return chain.state(option, owner) + + monkeypatch.setattr(upto, "read_upto_chain_state", slow_state) + gw = Gateway(payment_required(sponsoring=True)) + client = sync_client(gw) + with ThreadPoolExecutor(max_workers=3) as pool: + list( + pool.map( + lambda _: client.chat_completion("deepseek/deepseek-chat", MESSAGES), range(3) + ) + ) + assert self._permits(gw) == ["6"] + assert sorted(gw.schemes) == ["exact", "exact", "upto"] + assert upto._PERMIT_PREFLIGHT == set() + + def test_asyncio_gather(self, monkeypatch): + chain = Chain(allowance=0, nonce=6) + + async def slow_astate(option, owner): + await asyncio.sleep(0.05) + return chain.state(option, owner) + + monkeypatch.setattr(upto, "aread_upto_chain_state", slow_astate) + gw = Gateway(payment_required(sponsoring=True)) + + async def run(): + client = async_client(gw) + await asyncio.gather( + *(client.chat_completion("deepseek/deepseek-chat", MESSAGES) for _ in range(3)) + ) + + asyncio.run(run()) + assert self._permits(gw) == ["6"] + assert sorted(gw.schemes) == ["exact", "exact", "upto"] + assert upto._PERMIT_PREFLIGHT == set() + + def test_marker_holder_elsewhere_still_allows_upto_without_permit(self, monkeypatch): + Chain(allowance=10**9).install(monkeypatch) + assert upto._reserve_preflight("eip155:8453", TEST_ACCOUNT.address) + gw = Gateway(payment_required(sponsoring=True)) + sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["upto"] + assert "signature" not in gw.signed[0]["extensions"]["eip2612GasSponsoring"]["info"] + + def test_marker_holder_elsewhere_and_no_allowance_is_exact(self, monkeypatch): + chain = Chain(allowance=0, nonce=6).install(monkeypatch) + assert upto._reserve_preflight("eip155:8453", TEST_ACCOUNT.address) + gw = Gateway(payment_required(sponsoring=True)) + sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["exact"] + assert chain.reads == 1 + + def test_marker_released_when_no_permit_or_signing_fails(self, monkeypatch): + Chain(allowance=10**9).install(monkeypatch) + gw = Gateway(payment_required(sponsoring=True)) + sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert upto._PERMIT_PREFLIGHT == set() + + Chain(allowance=0, nonce=6).install(monkeypatch) + monkeypatch.setattr( + upto, + "create_upto_payment_payload", + lambda *a, **k: (_ for _ in ()).throw(RuntimeError()), + ) + sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert upto._PERMIT_PREFLIGHT == set() + assert upto._PERMITS_IN_FLIGHT == {} + + # --------------------------------------------------------------------------- # 3. Rejection → one exact retry # --------------------------------------------------------------------------- From 628c7305e1c4d394b80a71eb268b874efb665010 Mon Sep 17 00:00:00 2001 From: 1bcMax Date: Fri, 2 Oct 2026 17:03:56 +0800 Subject: [PATCH 3/6] fix(x402): never pay exact after a replayed upto, refuse upto-only 402s, book stream ceilings - A rejection that follows a 502/503 replay of the same upto payment is no longer answered with an exact payment. Upto settles after the call is served, so the replay's "verification failed" can mean the first send settled (Permit2 nonce used). Paying exact on top would charge twice. This applies to non-stream and stream, sync and async. - A 402 that offers only upto, when upto cannot be used, raises PaymentError. extract_payment_details falls back to accepts[0], so it was being signed as an EIP-3009 transfer for the whole ceiling. - Streams book the upto ceiling whatever their pre-settlement PAYMENT-RESPONSE carries. - Exact spend limits cap on the signed (header) amount, which includes the tx fee, not the body's lower base price. - An upto rejection keeps the client on exact for 10 minutes, not for its whole life. Co-Authored-By: Claude Opus 5.5 (1M context) --- blockrun_llm/client.py | 137 ++++++++++++++++++++++++++--------- tests/unit/test_x402_upto.py | 123 ++++++++++++++++++++++++++++++- 2 files changed, 222 insertions(+), 38 deletions(-) diff --git a/blockrun_llm/client.py b/blockrun_llm/client.py index 4cc2b0a..8d07ddc 100644 --- a/blockrun_llm/client.py +++ b/blockrun_llm/client.py @@ -43,6 +43,7 @@ import os import re import sys +import time from collections.abc import AsyncGenerator, AsyncIterator, Iterator from typing import Any, Callable, NamedTuple, NoReturn @@ -357,6 +358,13 @@ def _enforce_spend_limits(client: Any, cost_usd: float, model: str | None = None # --------------------------------------------------------------------------- +# How long a gateway's rejection of an upto payment keeps this client on exact +# for that wallet and network. Long enough not to hammer a gateway that has +# switched upto off, short enough that one transient "verification failed" +# does not cost the discount for the rest of a long-running process. +UPTO_REJECTION_TTL_SECONDS = 600.0 + + class _ChatPayment(NamedTuple): """A signed chat payment: the paid retry's headers, and what to book for it.""" @@ -400,6 +408,19 @@ def _is_payment_rejection(response: httpx.Response) -> bool: ) +def _may_fall_back_to_exact( + payment: _ChatPayment, response: httpx.Response, *, replayed: bool +) -> bool: + """May this rejection of an upto payment be answered with an exact one? + + Only when the refused send was the FIRST send of that upto signature. Upto + settles after the call is served, so if a 5xx made us replay the same + header, the replay's rejection can mean the first send was served and + settled (Permit2 nonce used). Paying exact then would charge twice. + """ + return payment.exact_fallback is not None and not replayed and _is_payment_rejection(response) + + def _exact_after_upto_rejection(client: Any, payment: _ChatPayment) -> _ChatPayment: """The gateway rejected an upto payment: remember that for this wallet and network (for the life of the client), and sign the 402's exact requirement. @@ -409,10 +430,10 @@ def _exact_after_upto_rejection(client: Any, payment: _ChatPayment) -> _ChatPaym """ assert payment.exact_fallback is not None if client.account is not None and payment.network: - client._upto_rejected.add((client.account.address.lower(), payment.network)) + client._upto_rejected[(client.account.address.lower(), payment.network)] = time.monotonic() sys.stderr.write( - f"[blockrun_llm] upto payment rejected on {payment.network}; " - "retrying once with exact, and using exact for the rest of this client's life\n" + f"[blockrun_llm] upto payment rejected on {payment.network}; retrying once " + f"with exact, and using exact on that network for {UPTO_REJECTION_TTL_SECONDS:.0f}s\n" ) return payment.exact_fallback() @@ -474,8 +495,11 @@ def _upto_offer( option = find_upto_option(payment_required) if option is None: return None - if (client.account.address.lower(), option.network) in getattr(client, "_upto_rejected", ()): - return None # this gateway already refused upto from this wallet + rejected_at = getattr(client, "_upto_rejected", {}).get( + (client.account.address.lower(), option.network) + ) + if rejected_at is not None and time.monotonic() - rejected_at < UPTO_REJECTION_TTL_SECONDS: + return None # this gateway recently refused upto from this wallet resource = details.get("resource") or {} try: resource_url = validate_resource_url( @@ -520,15 +544,34 @@ def _exact_chat_payment( details: dict[str, Any], ) -> _ChatPayment: """Sign the exact (EIP-3009) requirement — the path every release has used.""" + if details.get("scheme") == "upto": + # extract_payment_details falls back to accepts[0] when no exact entry + # exists. Signing an upto requirement as EIP-3009 would authorize the + # whole upto CEILING as a fixed transfer, so refuse instead. + raise PaymentError( + "This 402 offers only the x402 'upto' scheme, and upto could not be used " + "here (payment_scheme='exact', no Permit2 allowance and no gas " + "sponsoring, insufficient balance, or a ceiling over a spend limit). " + "Nothing was signed." + ) + try: + signed_usd = int(str(details.get("amount", 0))) / 1e6 + except ValueError: + signed_usd = 0.0 # A gateway offering upto quotes its `price` at the upto CEILING, which is - # not what exact signs; book and cap exact on its own amount then. + # not what exact signs; book exact on its own amount then. cost_usd = ( float(price_info.get("amount", 0)) if price_info and not offers_upto(payment_required) - else float(details.get("amount", 0)) / 1e6 + else signed_usd + ) + # Before signing, and on what is SIGNED: the body's `price` is the base + # price without the transaction fee, so capping on it would let a call + # through that signs slightly more than the limit. A refused quote is + # never sent, so nothing settles. + _enforce_spend_limits( + client, max(cost_usd, signed_usd), body.get("model") if isinstance(body, dict) else None ) - # Before signing: a refused quote is never sent, so nothing settles. - _enforce_spend_limits(client, cost_usd, body.get("model") if isinstance(body, dict) else None) # SECURITY: Signing happens locally - only the signature is sent to server resource = details.get("resource") or {} @@ -660,8 +703,11 @@ def __init__( (prompt-cache discounts included) — but only when the wallet already has a Permit2 allowance or the gateway sponsors the approval gaslessly; otherwise, or on any error, it signs - ``exact`` as before. ``"exact"`` never signs upto. ``None`` - honors the ``BLOCKRUN_PAYMENT_SCHEME`` env var. + ``exact`` as before. The actual cost can be MORE than the + exact quote, which prices output at a tenth of + ``max_tokens``; it is never more than the ceiling (the full + ``max_tokens``). ``"exact"`` never signs upto and pays the + fixed quote. ``None`` honors ``BLOCKRUN_PAYMENT_SCHEME``. Raises: ValueError: If no wallet is configured. For agent use, call setup_agent_wallet() first. @@ -742,9 +788,9 @@ def __init__( # x402 scheme preference (see x402_upto). Resolved eagerly so a typo in # the argument or BLOCKRUN_PAYMENT_SCHEME fails at construction. self._payment_scheme = resolve_payment_scheme(payment_scheme) - # (wallet, network) pairs whose gateway rejected an upto payment: those - # go straight to exact for the life of this client. - self._upto_rejected: set[tuple[str, str]] = set() + # (wallet, network) -> monotonic time the gateway last rejected an upto + # payment: that pair goes straight to exact for UPTO_REJECTION_TTL_SECONDS. + self._upto_rejected: dict[tuple[str, str], float] = {} self._session_calls: int = 0 self._last_call_cost: float = 0.0 @@ -1463,9 +1509,11 @@ def _stream_paid_phase( basis: str | None = None if cost_usd > 0: # A stream's PAYMENT-RESPONSE arrives before the upto - # settle, so an upto stream books its ceiling, labeled. + # settle, so an upto stream books its ceiling, labeled, + # whatever amount that header carries. + settlement = self._capture_settlement(resp2) cost_usd, basis = self._book_paid_call( - payment, self._capture_settlement(resp2) + payment, None if payment.scheme == "upto" else settlement ) yield from self._iter_and_archive( resp2, @@ -1478,7 +1526,7 @@ def _stream_paid_phase( return resp2.read() if _is_payment_rejection(resp2): - if payment.exact_fallback is not None: + if _may_fall_back_to_exact(payment, resp2, replayed=attempt > 0): upto_refusal = resp2 # nothing streamed yet: retry exact break self._raise_payment_rejection(rejected if rejected is not None else resp2) @@ -1637,15 +1685,21 @@ def _post_paid( body: dict[str, Any], headers: dict[str, str], timeout: float | None, - ) -> httpx.Response: - """The paid POST, with one automatic retry on 502/503.""" + ) -> tuple[httpx.Response, bool]: + """The paid POST, with one automatic retry on 502/503. + + Returns ``(response, replayed)``. ``replayed`` means the same signed + payment went out twice, so a rejection of the second send may only be + the first one having settled (nonce used): see _may_fall_back_to_exact. + """ response = self._client.post(url, json=body, headers=headers, timeout=timeout) if response.status_code in (502, 503): import time time.sleep(1) response = self._client.post(url, json=body, headers=headers, timeout=timeout) - return response + return response, True + return response, False def _sign_payment_from_response( self, @@ -1771,14 +1825,14 @@ def _handle_payment_and_retry( is_search_request = "search_parameters" in body or body.get("search") is True request_timeout = self.search_timeout if is_search_request else self.timeout - retry_response = self._post_paid(url, body, payment.headers, request_timeout) - if payment.exact_fallback is not None and _is_payment_rejection(retry_response): + retry_response, replayed = self._post_paid(url, body, payment.headers, request_timeout) + if _may_fall_back_to_exact(payment, retry_response, replayed=replayed): # The gateway refused the upto payment before serving anything: # one retry with the same 402's exact requirement. If exact is # refused too, the original refusal is what surfaces. rejected = retry_response payment = _exact_after_upto_rejection(self, payment) - retry_response = self._post_paid(url, body, payment.headers, request_timeout) + retry_response, _ = self._post_paid(url, body, payment.headers, request_timeout) if _is_payment_rejection(retry_response): retry_response = rejected @@ -2914,8 +2968,11 @@ def __init__( (prompt-cache discounts included) — but only when the wallet already has a Permit2 allowance or the gateway sponsors the approval gaslessly; otherwise, or on any error, it signs - ``exact`` as before. ``"exact"`` never signs upto. ``None`` - honors the ``BLOCKRUN_PAYMENT_SCHEME`` env var. + ``exact`` as before. The actual cost can be MORE than the + exact quote, which prices output at a tenth of + ``max_tokens``; it is never more than the ceiling (the full + ``max_tokens``). ``"exact"`` never signs upto and pays the + fixed quote. ``None`` honors ``BLOCKRUN_PAYMENT_SCHEME``. Raises: ValueError: If no wallet is configured @@ -2990,9 +3047,9 @@ def __init__( # x402 scheme preference (see x402_upto). Resolved eagerly so a typo in # the argument or BLOCKRUN_PAYMENT_SCHEME fails at construction. self._payment_scheme = resolve_payment_scheme(payment_scheme) - # (wallet, network) pairs whose gateway rejected an upto payment: those - # go straight to exact for the life of this client. - self._upto_rejected: set[tuple[str, str]] = set() + # (wallet, network) -> monotonic time the gateway last rejected an upto + # payment: that pair goes straight to exact for UPTO_REJECTION_TTL_SECONDS. + self._upto_rejected: dict[tuple[str, str], float] = {} log_dir = _resolve_log_dir(transaction_log) self._tx_logger: TransactionLogger | None = ( @@ -3461,7 +3518,12 @@ async def _astream_paid_phase( # chat_completion convention). basis: str | None = None if cost_usd > 0: - cost_usd, basis = _booked_cost(payment, self._capture_settlement(resp2)) + # Same as the sync stream: an upto ceiling, never the + # pre-settle header's amount. + settlement = self._capture_settlement(resp2) + cost_usd, basis = _booked_cost( + payment, None if payment.scheme == "upto" else settlement + ) self._last_call_cost = cost_usd async for chunk in self._aiter_and_archive( resp2, @@ -3475,7 +3537,7 @@ async def _astream_paid_phase( return await resp2.aread() if _is_payment_rejection(resp2): - if payment.exact_fallback is not None: + if _may_fall_back_to_exact(payment, resp2, replayed=attempt > 0): upto_refusal = resp2 # nothing streamed yet: retry exact break self._raise_payment_rejection(rejected if rejected is not None else resp2) @@ -3620,15 +3682,16 @@ async def _apost_paid( body: dict[str, Any], headers: dict[str, str], timeout: float | None, - ) -> httpx.Response: - """The paid POST, with one automatic retry on 502/503.""" + ) -> tuple[httpx.Response, bool]: + """Async :meth:`LLMClient._post_paid`: ``(response, replayed)``.""" response = await self._client.post(url, json=body, headers=headers, timeout=timeout) if response.status_code in (502, 503): import asyncio await asyncio.sleep(1) response = await self._client.post(url, json=body, headers=headers, timeout=timeout) - return response + return response, True + return response, False async def _asign_chat_payment( self, body: dict[str, Any], response: httpx.Response @@ -3708,13 +3771,15 @@ async def _handle_payment_and_retry( is_search_request = "search_parameters" in body or body.get("search") is True request_timeout = self.search_timeout if is_search_request else self.timeout - retry_response = await self._apost_paid(url, body, payment.headers, request_timeout) - if payment.exact_fallback is not None and _is_payment_rejection(retry_response): + retry_response, replayed = await self._apost_paid( + url, body, payment.headers, request_timeout + ) + if _may_fall_back_to_exact(payment, retry_response, replayed=replayed): # Upto refused before anything was served: one exact retry; if exact # is refused too, the original refusal surfaces (see the sync path). rejected = retry_response payment = _exact_after_upto_rejection(self, payment) - retry_response = await self._apost_paid(url, body, payment.headers, request_timeout) + retry_response, _ = await self._apost_paid(url, body, payment.headers, request_timeout) if _is_payment_rejection(retry_response): retry_response = rejected diff --git a/tests/unit/test_x402_upto.py b/tests/unit/test_x402_upto.py index 45e4967..00bc94c 100644 --- a/tests/unit/test_x402_upto.py +++ b/tests/unit/test_x402_upto.py @@ -827,7 +827,7 @@ def test_upto_rejected_then_exact_succeeds_and_is_remembered(self, monkeypatch): "calls": 1, "ceiling_usd": 0.0, } - # Remembered for the life of the client: straight to exact, no chain read. + # Remembered (for UPTO_REJECTION_TTL_SECONDS): straight to exact, no chain read. reads = chain.reads client.chat_completion("deepseek/deepseek-chat", MESSAGES) assert gw.schemes == ["upto", "exact", "exact"] @@ -861,6 +861,49 @@ def test_exact_retry_also_rejected_surfaces_original_error(self, monkeypatch): assert gw.schemes == ["upto", "exact"] # no third attempt assert client.get_spending()["calls"] == 0 + def test_rejection_expires_after_the_ttl(self, monkeypatch): + import blockrun_llm.client as client_mod + + Chain().install(monkeypatch) + clock = {"t": 1000.0} + monkeypatch.setattr(client_mod.time, "monotonic", lambda: clock["t"]) + gw = Gateway(payment_required(), paid=[verify_failed(), ok()]) + client = sync_client(gw) + client.chat_completion("deepseek/deepseek-chat", MESSAGES) + client.chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["upto", "exact", "exact"] + clock["t"] += client_mod.UPTO_REJECTION_TTL_SECONDS + 1 + client.chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["upto", "exact", "exact", "upto"] + + def test_rejection_after_a_5xx_replay_never_pays_exact(self, monkeypatch): + # The first send may have been served and settled behind the 502; the + # replay's "verification failed" is then the used Permit2 nonce. Paying + # exact on top would charge twice. + monkeypatch.setattr("time.sleep", lambda _s: None) + Chain().install(monkeypatch) + gw = Gateway(payment_required(), paid=[httpx.Response(502), verify_failed(), ok()]) + client = sync_client(gw) + with pytest.raises(PaymentError): + client.chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["upto", "upto"] + assert client._upto_rejected == {} + + def test_async_rejection_after_a_5xx_replay_never_pays_exact(self, monkeypatch): + async def no_sleep(_s): + return None + + monkeypatch.setattr("asyncio.sleep", no_sleep) + Chain().install(monkeypatch) + gw = Gateway(payment_required(), paid=[httpx.Response(503), verify_failed(), ok()]) + + async def run(): + await async_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + + with pytest.raises(PaymentError): + asyncio.run(run()) + assert gw.schemes == ["upto", "upto"] + def test_exact_payment_rejected_is_never_retried(self, monkeypatch): Chain(allowance=0).install(monkeypatch) gw = Gateway(payment_required(), paid=[verify_failed(), ok()]) @@ -881,7 +924,7 @@ def test_2xx_never_triggers_a_retry(self, monkeypatch): response = client.chat_completion("deepseek/deepseek-chat", MESSAGES) assert gw.schemes == ["upto"] assert response.payment_scheme == "upto" - assert client._upto_rejected == set() + assert client._upto_rejected == {} def test_non_payment_error_after_upto_is_not_retried(self, monkeypatch): Chain().install(monkeypatch) @@ -1045,6 +1088,33 @@ def test_stream_exact_retry_rejected_raises_once(self, monkeypatch): list(sync_client(gw).chat_completion_stream("deepseek/deepseek-chat", MESSAGES)) assert gw.schemes == ["upto", "exact"] + def test_stream_rejection_after_a_5xx_replay_never_pays_exact(self, monkeypatch): + monkeypatch.setattr("time.sleep", lambda _s: None) + Chain().install(monkeypatch) + gw = Gateway(payment_required(), paid=[httpx.Response(502), verify_failed(), sse_ok()]) + with pytest.raises(PaymentError): + list(sync_client(gw).chat_completion_stream("deepseek/deepseek-chat", MESSAGES)) + assert gw.schemes == ["upto", "upto"] + + def test_stream_books_the_ceiling_even_when_the_header_names_an_amount(self, monkeypatch): + # A stream's PAYMENT-RESPONSE is sent before the upto settle, so an + # amount on it is not the settled one. + Chain().install(monkeypatch) + early = httpx.Response( + 200, + content=_sse(), + headers={ + "content-type": "text/event-stream", + "PAYMENT-RESPONSE": settlement_header("700"), + }, + ) + gw = Gateway(payment_required(), paid=[early]) + client = sync_client(gw) + chunks = list(client.chat_completion_stream("deepseek/deepseek-chat", MESSAGES)) + assert chunks[0].cost_is_ceiling is True + assert chunks[0].cost_usd == pytest.approx(0.05) + assert client.get_spending()["ceiling_usd"] == pytest.approx(0.05) + def test_async_stream_upto_rejected_then_exact(self, monkeypatch): Chain().install(monkeypatch) gw = Gateway(payment_required(), paid=[verify_failed(), sse_ok()]) @@ -1187,3 +1257,52 @@ def test_network_without_rpcs_is_unavailable(self): assert arc is not None and arc.rpcs == () with pytest.raises(upto.UptoUnavailable): REAL_READ(arc, TEST_ACCOUNT.address) + + +# --------------------------------------------------------------------------- +# An upto-only 402 that upto cannot serve is refused, never signed as EIP-3009 +# --------------------------------------------------------------------------- + + +class TestUptoOnly402: + def _upto_only(self) -> dict[str, Any]: + pr = payment_required() + pr["accepts"] = [upto_entry()] + return pr + + def test_exact_preference_refuses_instead_of_signing_the_ceiling(self, monkeypatch): + Chain().install(monkeypatch) + gw = Gateway(self._upto_only()) + with pytest.raises(PaymentError, match="only the x402 'upto' scheme"): + sync_client(gw, payment_scheme="exact").chat_completion( + "deepseek/deepseek-chat", MESSAGES + ) + assert gw.signed == [] + + def test_unusable_upto_refuses_instead_of_signing_the_ceiling(self, monkeypatch): + Chain(allowance=0).install(monkeypatch) # no allowance, no sponsoring + gw = Gateway(self._upto_only()) + with pytest.raises(PaymentError, match="Nothing was signed"): + sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.signed == [] + + def test_usable_upto_only_402_still_pays_upto(self, monkeypatch): + Chain().install(monkeypatch) + gw = Gateway(self._upto_only()) + sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["upto"] + + +class TestExactSpendCapOnSignedAmount: + def test_cap_uses_the_signed_amount_not_the_lower_body_price(self, monkeypatch): + # Body price is the base price ($0.0009); the header amount signed is + # $0.001 (base + tx fee). A $0.00095 cap must refuse. + from blockrun_llm.types import SpendLimitError + + Chain().install(monkeypatch) + gw = Gateway(payment_required(with_upto=False), header=False, price_usd="0.0009") + with pytest.raises(SpendLimitError): + sync_client(gw, max_cost_per_call=0.00095).chat_completion( + "deepseek/deepseek-chat", MESSAGES + ) + assert gw.signed == [] From ec154191e6dbe8fe9801c6c46de70f94bb1ba71e Mon Sep 17 00:00:00 2001 From: 1bcMax Date: Fri, 2 Oct 2026 17:03:57 +0800 Subject: [PATCH 4/6] fix(llama-index): surface upto cost_is_ceiling, so a ceiling is never reported as a charge Co-Authored-By: Claude Opus 5.5 (1M context) --- .../llama-index-llms-blockrun/README.md | 2 ++ .../llama_index/llms/blockrun/base.py | 25 +++++++++++++++---- .../tests/test_llms_blockrun.py | 11 ++++++++ 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/integrations/llama-index-llms-blockrun/README.md b/integrations/llama-index-llms-blockrun/README.md index b14d90a..34f140d 100644 --- a/integrations/llama-index-llms-blockrun/README.md +++ b/integrations/llama-index-llms-blockrun/README.md @@ -58,6 +58,8 @@ response = llm.chat( ) print(response.message.content) print(response.additional_kwargs.get("cost_usd")) # USD charged, where the SDK reports it (Base) +# Under x402 upto, cost_usd may be the signed ceiling, not the charge: +print(response.additional_kwargs.get("cost_is_ceiling")) for chunk in llm.stream_complete("Write a haiku about USDC."): print(chunk.delta, end="", flush=True) diff --git a/integrations/llama-index-llms-blockrun/llama_index/llms/blockrun/base.py b/integrations/llama-index-llms-blockrun/llama_index/llms/blockrun/base.py index 36db0fe..e5c64fe 100644 --- a/integrations/llama-index-llms-blockrun/llama_index/llms/blockrun/base.py +++ b/integrations/llama-index-llms-blockrun/llama_index/llms/blockrun/base.py @@ -2,8 +2,10 @@ BlockRun is an OpenAI-compatible gateway where every call pays for itself in USDC over x402. There is no API key: the gateway answers an unpaid request with -HTTP 402 and a price, the client signs a payment for exactly that amount with a -local wallet, and the request is retried with the signature attached. +HTTP 402 and a price, the client signs a payment with a local wallet, and the +request is retried with the signature attached. That payment is either the +quoted amount (x402 ``exact``) or, on Base when the gateway offers it, a +ceiling the gateway settles at the actual cost (x402 ``upto``). That signing step is why this is a package rather than an ``OpenAILike`` snippet. ``OpenAILike(api_base=..., api_key="fake")`` reaches the gateway and @@ -518,7 +520,10 @@ def _check_solana_params(params: Iterable[str]) -> None: def _response_kwargs( - usage: Any, cost_usd: float | None = None, citations: list[str] | None = None + usage: Any, + cost_usd: float | None = None, + citations: list[str] | None = None, + source: Any = None, ) -> dict[str, Any]: out: dict[str, Any] = {} if usage is not None: @@ -527,6 +532,13 @@ def _response_kwargs( out["total_tokens"] = usage.total_tokens if cost_usd is not None: out["cost_usd"] = cost_usd + # Under x402 upto, cost_usd can be the signed CEILING rather than the + # settled charge (always, for streams). Carry the SDK's label so a + # caller never reports an upper bound as money spent. + scheme = getattr(source, "payment_scheme", None) + if scheme is not None: + out["payment_scheme"] = scheme + out["cost_is_ceiling"] = bool(getattr(source, "cost_is_ceiling", False)) if citations: out["citations"] = citations return out @@ -551,7 +563,10 @@ def _to_chat_response(response: Any) -> ChatResponse: message=ChatMessage(role=MessageRole.ASSISTANT, blocks=blocks), raw=response, additional_kwargs=_response_kwargs( - response.usage, getattr(response, "cost_usd", None), response.citations + response.usage, + getattr(response, "cost_usd", None), + response.citations, + source=response, ), ) @@ -598,7 +613,7 @@ def step(self, chunk: Any) -> ChatResponse: # The Base SDK attaches the real x402 charge to every chunk; read it # with getattr because it is an extra attribute, not a declared field. additional = _response_kwargs( - chunk.usage, getattr(chunk, "cost_usd", None), chunk.citations + chunk.usage, getattr(chunk, "cost_usd", None), chunk.citations, source=chunk ) if reasoning_delta: additional["thinking_delta"] = reasoning_delta diff --git a/integrations/llama-index-llms-blockrun/tests/test_llms_blockrun.py b/integrations/llama-index-llms-blockrun/tests/test_llms_blockrun.py index 4c3b00d..9f29288 100644 --- a/integrations/llama-index-llms-blockrun/tests/test_llms_blockrun.py +++ b/integrations/llama-index-llms-blockrun/tests/test_llms_blockrun.py @@ -395,6 +395,17 @@ def test_charge_attached_to_chunks_is_surfaced(self) -> None: llm, _ = make_llm(FakeClient(chunks=[paid])) (response,) = list(llm.stream_chat([ChatMessage(role="user", content="hi")])) assert response.additional_kwargs["cost_usd"] == 0.0021 + assert "cost_is_ceiling" not in response.additional_kwargs + + def test_upto_ceiling_is_labeled_not_reported_as_a_charge(self) -> None: + paid = chunk("hi") + paid.cost_usd = 0.05 # type: ignore[attr-defined] + paid.payment_scheme = "upto" # type: ignore[attr-defined] + paid.cost_is_ceiling = True # type: ignore[attr-defined] + llm, _ = make_llm(FakeClient(chunks=[paid])) + (response,) = list(llm.stream_chat([ChatMessage(role="user", content="hi")])) + assert response.additional_kwargs["payment_scheme"] == "upto" + assert response.additional_kwargs["cost_is_ceiling"] is True def test_stream_complete(self) -> None: llm, _ = make_llm(FakeClient(chunks=[chunk("a"), chunk("b")])) From 2b20d6bdb3ed41e925e5f43a48f660460b361cc3 Mon Sep 17 00:00:00 2001 From: 1bcMax Date: Fri, 2 Oct 2026 17:03:57 +0800 Subject: [PATCH 5/6] docs(x402): upto settles the actual cost, which can exceed the exact quote; note per-process guards The exact quote prices output at a tenth of max_tokens (OUTPUT_QUOTE_FACTOR), so a long answer settles for more under upto than exact would have charged. The "never worse than exact" claim was false, and the docs now say so. They also say the in-flight permit guards are per process. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 14 ++++++++++++-- README.md | 22 +++++++++++++++++----- blockrun_llm/x402_upto.py | 17 +++++++++++++++-- 3 files changed, 44 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5341001..f30d54a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,11 @@ All notable changes to blockrun-llm will be documented in this file. (non-stream and stream) now sign a Permit2 `PermitWitnessTransferFrom` for the ceiling and the gateway settles the actual amount. - Taken only when it cannot be worse than `exact`: the offer carries + Upto settles the actual cost, which is not always below the exact quote: + exact prices output at a tenth of `max_tokens`, so a long answer settles for + more under upto (never more than the ceiling, the full `max_tokens`), and a + short or cache-hit one for less. `payment_scheme="exact"` keeps the fixed + quote. Upto is taken when the offer carries `extra.facilitatorAddress` for USDC on a network in `EVM_NETWORKS`; the wallet holds the ceiling; and Permit2 may already pull it, or the 402 declares `eip2612GasSponsoring` — then a gasless EIP-2612 permit approving Permit2 for @@ -24,7 +28,13 @@ All notable changes to blockrun-llm will be documented in this file. each. Anything else — no RPC, a signing error, a ceiling over a spend limit — signs `exact` as before (debug log only). A payment the gateway rejects before serving anything is retried once with `exact`, and that client stays - on exact for that network. + on exact for that network for 10 minutes. A rejection that follows a 502/503 + replay of the same upto payment is never retried with exact (the first send + may have settled; paying exact too would charge twice). A 402 offering only + upto, when upto cannot be used, raises rather than signing the ceiling as an + exact transfer. Exact spend limits cap on the signed amount (the header's, + which includes the transaction fee), not the body's base price. Streams book + the ceiling whatever their pre-settlement `PAYMENT-RESPONSE` says. Per wallet and network only one gas-sponsored upto payment is in flight: a second permit over the same USDC nonce reverted on-chain in a live test of diff --git a/README.md b/README.md index 087debe..5ff59cf 100644 --- a/README.md +++ b/README.md @@ -422,8 +422,14 @@ By default (`payment_scheme="auto"`) a chat call whose 402 also offers the x402 `upto` scheme pays with a Permit2 signature for a **ceiling** instead of an `exact` EIP-3009 transfer for a fixed quote. The gateway then settles the **actual** cost after the call — never more than the ceiling — so discounts it -only learns afterwards, such as prompt-cache hits, reach you. The gateway lists -`exact` first; upto is taken only when all of these hold: +only learns afterwards, such as prompt-cache hits, reach you. + +Upto is not always cheaper. The `exact` quote prices output at a tenth of your +`max_tokens`; under upto you pay for the output you actually got. A short or +cache-hit answer costs less than exact, a long one costs more (up to the +ceiling, which prices the full `max_tokens`). If you need the fixed quote, pass +`payment_scheme="exact"`. The gateway lists `exact` first; upto is taken only +when all of these hold: - the 402 offers an EVM `upto` requirement with `extra.facilitatorAddress`, for USDC on a network the SDK signs on (Base, Base Sepolia); @@ -438,7 +444,10 @@ only learns afterwards, such as prompt-cache hits, reach you. The gateway lists Otherwise — or on any RPC or signing error — the SDK signs `exact` exactly as before. If the gateway rejects an upto payment before serving anything, the request is retried once with `exact`, and that client pays exact on that network -from then on. Solana is unchanged (exact only). +for the next 10 minutes. It is never retried with exact after a 502/503 replay +of the same upto payment, because the first send may already have settled. +A 402 that offers only upto, when upto cannot be used, raises instead of being +signed as exact. Solana is unchanged (exact only). **Per wallet, only one gas-sponsored upto payment can be in flight.** Its permit lands only when that call settles, and until then the chain still shows the old @@ -446,7 +455,10 @@ USDC permit nonce — a second permit over the same nonce would revert on-chain. So concurrent or not-yet-settled calls that would need a permit pay `exact` (the SDK watches the on-chain nonce, and gives up on a permit at its deadline; concurrent calls in one client claim the permit slot before reading the chain). -Calls where Permit2 can already pull the ceiling are unaffected. +Calls where Permit2 can already pull the ceiling are unaffected. These guards +are per process: several processes paying from one wallet can still collide +(a call fails; nobody is overcharged), so run one paying process per wallet or +use `exact` there. ```python LLMClient(payment_scheme="exact") # opt out; or BLOCKRUN_PAYMENT_SCHEME=exact @@ -1693,7 +1705,7 @@ optional. | `SOLANA_RPC_URL` / `SOLANA_RPC_HEADERS` / `SOLANA_RPC_API_KEY` | RPC for blockhash + mint info while signing | BlockRun's free proxy | | `BLOCKRUN_CHAT_TIMEOUT` | Chat HTTP timeout, in seconds | `600` | | `BLOCKRUN_MAX_COST_PER_CALL` / `BLOCKRUN_MAX_SESSION_COST` | Opt-in spend limits (wallet rail) | unlimited | -| `BLOCKRUN_PAYMENT_SCHEME` | `auto` (prefer x402 `upto` when the gateway offers it and it is safe) or `exact` | `auto` | +| `BLOCKRUN_PAYMENT_SCHEME` | `auto` (prefer x402 `upto`, which settles the actual cost, when the gateway offers it and the wallet can use it) or `exact` (always the fixed quote) | `auto` | `BLOCKRUN_API_KEY_URL` is deliberately not `BLOCKRUN_API_URL`: that one names an x402 gateway, and an API-key client must never follow it and send your key to a diff --git a/blockrun_llm/x402_upto.py b/blockrun_llm/x402_upto.py index a6bae40..90a906b 100644 --- a/blockrun_llm/x402_upto.py +++ b/blockrun_llm/x402_upto.py @@ -8,8 +8,14 @@ settles the ACTUAL amount (never more than the ceiling) once the call is done. A gateway that offers upto lists it after ``exact`` in ``accepts``. This module -decides whether to take it, and signs it. The rule is that choosing upto must -never be worse than today's ``exact``: +decides whether to take it, and signs it. + +Upto is NOT always cheaper than exact. BlockRun's exact quote prices output at a +fraction of ``max_tokens`` (``OUTPUT_QUOTE_FACTOR``, 0.1 today), so a response +that runs long settles for more under upto than exact would have charged; a +short or cache-hit response settles for less. Upto is bounded by its ceiling +(the full ``max_tokens``), never by the exact quote. Pass +``payment_scheme="exact"`` to keep paying the fixed quote. Upto is taken when: * It is taken only when the 402 offers an EVM upto option carrying ``extra.facilitatorAddress`` for a network in :data:`EVM_NETWORKS`, and @@ -23,6 +29,13 @@ sponsored permit already in flight for this wallet — falls back to ``exact`` silently (debug log only). Solana is untouched: it only ever pays ``exact``. +The in-flight guards below (one sponsored permit per wallet and network, and +the preflight marker) are per PROCESS. Several processes paying from one wallet +can each sign a permit over the same USDC nonce, and all but one revert; nor is +the wallet's balance checked against the SUM of concurrent ceilings. Either +case fails a call rather than overcharging; run one paying process per wallet, +or pass ``payment_scheme="exact"``, to avoid it. + Semantics match the official ``@x402/evm`` 2.28.0 client (``UptoEvmScheme``, ``createUptoPermit2Payload``, ``trySignEip2612PermitExtension``); the unit tests pin a vector generated by that client (``scripts/gen-upto-vector.mjs``). From e0b4320d679240bb89fb42414e4cede86a6107f6 Mon Sep 17 00:00:00 2001 From: 1bcMax Date: Fri, 2 Oct 2026 22:19:51 +0800 Subject: [PATCH 6/6] fix(x402): never pay exact after PAYMENT_REPLAY; refuse upto-only 402s in the shared signer - The upto->exact fallback now needs a definite verification failure: the gateway's "Payment verification failed" body, a verify-failure code the chat route emits (PAYMENT_INVALID / PAYMENT_UNFUNDED), or a fresh payment-required challenge. A first-send PAYMENT_REPLAY, or a body naming an earlier paid use (recoverable / poll_url / job_id), means that authorization was already served and paid for (e.g. a duplicated send); paying exact on top charged twice. Applies to sync/async, stream/non-stream (shared _may_fall_back_to_exact). _is_payment_rejection is unchanged. - A PAYMENT_REPLAY now raises a PaymentError with the gateway's message and poll_url instead of "Check your wallet balance". - extract_payment_details raises ValueError for an upto-only 402 (chat opts in with allow_upto=True and keeps its own clearer PaymentError), and create_payment_payload refuses a non-exact scheme; every signer passes the requirement's scheme. No non-chat endpoint signs an upto ceiling as EIP-3009. - The chat upto-only refusal says so when upto was sent and the gateway refused it, instead of listing only local reasons. --- CHANGELOG.md | 11 ++ blockrun_llm/anthropic_client.py | 1 + blockrun_llm/client.py | 178 ++++++++++++++++++++++----- blockrun_llm/image.py | 1 + blockrun_llm/music.py | 1 + blockrun_llm/phone.py | 1 + blockrun_llm/portrait.py | 1 + blockrun_llm/price.py | 1 + blockrun_llm/realface.py | 1 + blockrun_llm/rpc.py | 1 + blockrun_llm/search.py | 1 + blockrun_llm/speech.py | 1 + blockrun_llm/surf.py | 1 + blockrun_llm/video.py | 1 + blockrun_llm/voice.py | 1 + blockrun_llm/x402.py | 33 +++++- tests/unit/test_x402_upto.py | 198 +++++++++++++++++++++++++++++++ 17 files changed, 401 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f30d54a..60bc786 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,17 @@ All notable changes to blockrun-llm will be documented in this file. async client books a paid chat call the same way the sync one does. - `extract_payment_details` picks the first non-`upto` requirement instead of blindly taking `accepts[0]`. +- The upto→exact retry happens only on a definite verification failure + (`Payment verification failed`, `PAYMENT_INVALID` / `PAYMENT_UNFUNDED`, or a + fresh `payment-required` challenge), never on `PAYMENT_REPLAY` or a body + pointing at an earlier paid use (`recoverable` / `poll_url` / `job_id`): that + authorization was already served and paid for, and exact would charge twice. + A `PAYMENT_REPLAY` raises a `PaymentError` carrying the gateway's message and + `poll_url`, not "check your wallet balance". `extract_payment_details` now + raises `ValueError` for an upto-only 402 (chat opts in with + `allow_upto=True`), and `create_payment_payload` refuses a non-`exact` + `scheme`, so no non-chat endpoint signs an upto ceiling as an EIP-3009 + transfer. - `get_balance()` reads its USDC contract and RPC list from `EVM_NETWORKS`. ## 1.17.1 — 2026-09-30 diff --git a/blockrun_llm/anthropic_client.py b/blockrun_llm/anthropic_client.py index 8242356..f6a81ae 100644 --- a/blockrun_llm/anthropic_client.py +++ b/blockrun_llm/anthropic_client.py @@ -86,6 +86,7 @@ def handle_request(self, request: httpx.Request) -> httpx.Response: account=self._account, recipient=details["recipient"], amount=details["amount"], + scheme=details.get("scheme"), network=details.get("network", "eip155:8453"), resource_url=resource.get("url", f"{self._api_url}/v1/messages"), resource_description=resource.get("description", "BlockRun AI API call"), diff --git a/blockrun_llm/client.py b/blockrun_llm/client.py index 8d07ddc..1275536 100644 --- a/blockrun_llm/client.py +++ b/blockrun_llm/client.py @@ -381,44 +381,142 @@ class _ChatPayment(NamedTuple): exact_fallback: Callable[[], _ChatPayment] | None = None +def _rejection_body(response: httpx.Response) -> dict[str, Any] | None: + """The JSON object body of a refusal, or ``None`` when it has none.""" + try: + body = response.json() + except Exception: + return None + return body if isinstance(body, dict) else None + + +def _rejection_error_text(body: dict[str, Any]) -> str: + error = body.get("error") + if isinstance(error, dict): + error = error.get("message") + return error if isinstance(error, str) else "" + + def _is_payment_rejection(response: httpx.Response) -> bool: """Did the gateway refuse the payment itself (before serving anything)? A 402 on the paid request, or a 4xx whose body is the gateway's payment-verification failure (``error: "Payment verification failed"`` / a ``PAYMENT_*`` code). + + This decides how a refusal is RAISED. Whether it may be answered with a + second, exact payment is narrower: see :func:`_is_verify_failure`. """ status = response.status_code if status == 402: return True if not 400 <= status < 500: return False - try: - body = response.json() - except Exception: + body = _rejection_body(response) + if body is None: return False - if not isinstance(body, dict): - return False - error = body.get("error") - if isinstance(error, dict): - error = error.get("message") code = body.get("code") - return (isinstance(error, str) and "payment verification failed" in error.lower()) or ( + return "payment verification failed" in _rejection_error_text(body).lower() or ( isinstance(code, str) and code.upper().startswith("PAYMENT_") ) +# The codes the gateway's chat route puts on a VERIFY-phase refusal +# (blockrun src/app/api/v1/chat/completions/route.ts → verifyFailureFields in +# src/lib/x402.ts). Verify runs before the nonce is claimed and before anything +# is served or settled, so these prove the signed payment was never used. +_VERIFY_FAILURE_CODES = frozenset({"PAYMENT_INVALID", "PAYMENT_UNFUNDED"}) + + +def _is_payment_replay(response: httpx.Response) -> bool: + """Did the gateway refuse the payment because its authorization was + ALREADY USED (``code: "PAYMENT_REPLAY"``, or a body pointing at the earlier + request's result: ``recoverable`` / ``poll_url`` / ``job_id``)? + + That earlier use was served and paid for: a proxy or load balancer may + have duplicated the first send. Nothing about this request may be paid + again. + """ + if response.status_code < 400: + return False + body = _rejection_body(response) + if body is None: + return False + code = body.get("code") + if isinstance(code, str) and code.upper() == "PAYMENT_REPLAY": + return True + return any(body.get(k) for k in ("recoverable", "poll_url", "job_id")) + + +def _is_verify_failure(response: httpx.Response) -> bool: + """Is this a DEFINITE pre-use refusal of the payment — one that proves the + signed authorization was never consumed? + + An allowlist, not "any 402": the gateway's verify-failure body + (``error: "Payment verification failed"`` or a verify-failure ``code``), or + a 402 that answers the paid request with a fresh ``payment-required`` + challenge (the payment was not accepted at all). Never a replay. + """ + if not 400 <= response.status_code < 500 or _is_payment_replay(response): + return False + body = _rejection_body(response) + if body is not None: + code = body.get("code") + if "payment verification failed" in _rejection_error_text(body).lower(): + return True + if isinstance(code, str) and code.upper() in _VERIFY_FAILURE_CODES: + return True + return response.status_code == 402 and bool(response.headers.get("payment-required")) + + def _may_fall_back_to_exact( payment: _ChatPayment, response: httpx.Response, *, replayed: bool ) -> bool: """May this rejection of an upto payment be answered with an exact one? - Only when the refused send was the FIRST send of that upto signature. Upto - settles after the call is served, so if a 5xx made us replay the same - header, the replay's rejection can mean the first send was served and - settled (Permit2 nonce used). Paying exact then would charge twice. + Only when the refused send was the FIRST send of that upto signature, and + the refusal is a definite verification failure (:func:`_is_verify_failure`). + + * Upto settles after the call is served, so if a 5xx made us replay the + same header, the replay's rejection can mean the first send was served + and settled (Permit2 nonce used). + * ``PAYMENT_REPLAY`` on the first send means the authorization was already + used by an earlier request that completed and was paid for (e.g. a + duplicated send). + + Paying exact in either case would charge twice. + """ + return payment.exact_fallback is not None and not replayed and _is_verify_failure(response) + + +def _payment_rejected_error(response: httpx.Response) -> PaymentError: + """The error for a paid request whose payment the gateway refused (a 402). + + A ``PAYMENT_REPLAY`` is not a balance problem and must not read as "just + retry": a retry signs a fresh authorization, and is charged again for a + request that was already paid for. Keep the gateway's message and, when it + names one, the earlier result's ``poll_url``. """ - return payment.exact_fallback is not None and not replayed and _is_payment_rejection(response) + if not _is_payment_replay(response): + return PaymentError("Payment was rejected. Check your wallet balance.") + body = _rejection_body(response) or {} + gateway_message = body.get("message") + poll_url = body.get("poll_url") + detail: dict[str, Any] = sanitize_error_response(body) + for key in ("poll_url", "job_id", "recoverable"): + if body.get(key) is not None: + detail[key] = body[key] + text = ( + "The gateway refused this payment as already used (PAYMENT_REPLAY): an earlier " + "request with the same authorization was accepted and may have been charged. " + "Nothing more was signed. Retrying signs a new authorization and is billed " + "again, so check what the earlier request returned first." + ) + if isinstance(poll_url, str) and poll_url: + text += f" Its result can be collected at {poll_url}." + if isinstance(gateway_message, str) and gateway_message: + text += f" Gateway: {gateway_message}" + return PaymentError(text, status_code=response.status_code, response=detail) def _exact_after_upto_rejection(client: Any, payment: _ChatPayment) -> _ChatPayment: @@ -531,7 +629,7 @@ def _upto_chat_payment( signed.amount, network=option.network, exact_fallback=lambda: _exact_chat_payment( - client, body, payment_required, price_info, details + client, body, payment_required, price_info, details, upto_refused=True ), ) @@ -542,17 +640,32 @@ def _exact_chat_payment( payment_required: dict[str, Any], price_info: dict[str, Any], details: dict[str, Any], + *, + upto_refused: bool = False, ) -> _ChatPayment: - """Sign the exact (EIP-3009) requirement — the path every release has used.""" + """Sign the exact (EIP-3009) requirement — the path every release has used. + + ``upto_refused``: called as the fallback after the gateway refused an upto + payment for this same 402 (only the wording of the upto-only refusal). + """ if details.get("scheme") == "upto": - # extract_payment_details falls back to accepts[0] when no exact entry - # exists. Signing an upto requirement as EIP-3009 would authorize the - # whole upto CEILING as a fixed transfer, so refuse instead. + # Chat asks extract_payment_details for the upto entry when no exact + # one exists (allow_upto=True). Signing an upto requirement as EIP-3009 + # would authorize the whole upto CEILING as a fixed transfer, so refuse + # instead — with a message that says why upto did not happen. + if upto_refused: + raise PaymentError( + "This 402 offers only the x402 'upto' scheme. An upto payment was " + "signed and sent, and the gateway refused it before serving the " + "request; there is no exact option to fall back to, so no exact " + "payment was signed." + ) raise PaymentError( "This 402 offers only the x402 'upto' scheme, and upto could not be used " "here (payment_scheme='exact', no Permit2 allowance and no gas " - "sponsoring, insufficient balance, or a ceiling over a spend limit). " - "Nothing was signed." + "sponsoring, insufficient balance, a ceiling over a spend limit, or the " + "gateway refused an upto payment from this wallet in the last " + f"{UPTO_REJECTION_TTL_SECONDS / 60:.0f} minutes). Nothing was signed." ) try: signed_usd = int(str(details.get("amount", 0))) / 1e6 @@ -582,6 +695,7 @@ def _exact_chat_payment( account=client.account, recipient=details["recipient"], amount=details["amount"], + scheme=details.get("scheme"), network=details.get("network", "eip155:84532" if client.is_testnet() else "eip155:8453"), resource_url=validate_resource_url( resource.get("url", f"{client.api_url}/v1/chat/completions"), client.api_url @@ -1555,7 +1669,7 @@ def _raise_payment_rejection(self, response: httpx.Response) -> NoReturn: # Account rail: a 402 is the account being out of credit, not a # challenge to sign. Nothing here can sign, so say so plainly. raise_for_api_key_402(response, self.api_key) - raise PaymentError("Payment was rejected. Check your wallet balance.") + raise _payment_rejected_error(response) self._raise_stream_error(response, after_payment=True) raise AssertionError("unreachable: _raise_stream_error always raises") @@ -1725,7 +1839,7 @@ def _sign_chat_payment(self, body: dict[str, Any], response: httpx.Response) -> Only the signature is sent - your private key never leaves. """ payment_required, price_info = _read_payment_required(response) - details = extract_payment_details(payment_required) + details = extract_payment_details(payment_required, allow_upto=True) offer = _upto_offer(self, body, payment_required, details) if offer is not None: option, kwargs = offer @@ -1841,7 +1955,7 @@ def _handle_payment_and_retry( # Account rail: a 402 is the account being out of credit, not a # challenge to sign. Nothing here can sign, so say so plainly. raise_for_api_key_402(retry_response, self.api_key) - raise PaymentError("Payment was rejected. Check your wallet balance.") + raise _payment_rejected_error(retry_response) if retry_response.status_code != 200: try: @@ -1996,6 +2110,7 @@ def _handle_payment_and_retry_raw( account=self.account, recipient=details["recipient"], amount=details["amount"], + scheme=details.get("scheme"), network=details.get("network", "eip155:84532" if self.is_testnet() else "eip155:8453"), resource_url=validate_resource_url(resource.get("url", url), self.api_url), resource_description=resource.get("description", "BlockRun AI API call"), @@ -2027,7 +2142,7 @@ def _handle_payment_and_retry_raw( # Account rail: a 402 is the account being out of credit, not a # challenge to sign. Nothing here can sign, so say so plainly. raise_for_api_key_402(retry_response, self.api_key) - raise PaymentError("Payment was rejected. Check your wallet balance.") + raise _payment_rejected_error(retry_response) if retry_response.status_code != 200: try: @@ -2146,6 +2261,7 @@ def _handle_get_payment_and_retry( account=self.account, recipient=details["recipient"], amount=details["amount"], + scheme=details.get("scheme"), network=details.get("network", "eip155:84532" if self.is_testnet() else "eip155:8453"), resource_url=validate_resource_url(resource.get("url", url), self.api_url), resource_description=resource.get("description", "BlockRun AI API call"), @@ -2175,7 +2291,7 @@ def _handle_get_payment_and_retry( # Account rail: a 402 is the account being out of credit, not a # challenge to sign. Nothing here can sign, so say so plainly. raise_for_api_key_402(retry_response, self.api_key) - raise PaymentError("Payment was rejected. Check your wallet balance.") + raise _payment_rejected_error(retry_response) if retry_response.status_code != 200: try: @@ -3699,7 +3815,7 @@ async def _asign_chat_payment( """Async :meth:`LLMClient._sign_chat_payment`: the upto chain reads do not block the event loop.""" payment_required, price_info = _read_payment_required(response) - details = extract_payment_details(payment_required) + details = extract_payment_details(payment_required, allow_upto=True) offer = _upto_offer(self, body, payment_required, details) if offer is not None: option, kwargs = offer @@ -3787,7 +3903,7 @@ async def _handle_payment_and_retry( # Account rail: a 402 is the account being out of credit, not a # challenge to sign. Nothing here can sign, so say so plainly. raise_for_api_key_402(retry_response, self.api_key) - raise PaymentError("Payment was rejected. Check your wallet balance.") + raise _payment_rejected_error(retry_response) if retry_response.status_code != 200: try: @@ -3920,6 +4036,7 @@ async def _handle_payment_and_retry_raw( account=self.account, recipient=details["recipient"], amount=details["amount"], + scheme=details.get("scheme"), network=details.get("network", "eip155:84532" if self.is_testnet() else "eip155:8453"), resource_url=validate_resource_url(resource.get("url", url), self.api_url), resource_description=resource.get("description", "BlockRun AI API call"), @@ -3951,7 +4068,7 @@ async def _handle_payment_and_retry_raw( # Account rail: a 402 is the account being out of credit, not a # challenge to sign. Nothing here can sign, so say so plainly. raise_for_api_key_402(retry_response, self.api_key) - raise PaymentError("Payment was rejected. Check your wallet balance.") + raise _payment_rejected_error(retry_response) if retry_response.status_code != 200: try: @@ -4054,6 +4171,7 @@ async def _handle_get_payment_and_retry( account=self.account, recipient=details["recipient"], amount=details["amount"], + scheme=details.get("scheme"), network=details.get("network", "eip155:84532" if self.is_testnet() else "eip155:8453"), resource_url=validate_resource_url(resource.get("url", url), self.api_url), resource_description=resource.get("description", "BlockRun AI API call"), @@ -4083,7 +4201,7 @@ async def _handle_get_payment_and_retry( # Account rail: a 402 is the account being out of credit, not a # challenge to sign. Nothing here can sign, so say so plainly. raise_for_api_key_402(retry_response, self.api_key) - raise PaymentError("Payment was rejected. Check your wallet balance.") + raise _payment_rejected_error(retry_response) if retry_response.status_code != 200: try: diff --git a/blockrun_llm/image.py b/blockrun_llm/image.py index 60c14bf..e9704f4 100644 --- a/blockrun_llm/image.py +++ b/blockrun_llm/image.py @@ -362,6 +362,7 @@ def _handle_payment_and_retry( account=self.account, recipient=details["recipient"], amount=details["amount"], + scheme=details.get("scheme"), network=details.get("network", "eip155:8453"), resource_url=validate_resource_url( resource.get("url", f"{self.api_url}/v1/images/generations"), self.api_url diff --git a/blockrun_llm/music.py b/blockrun_llm/music.py index e94b869..54bcfd1 100644 --- a/blockrun_llm/music.py +++ b/blockrun_llm/music.py @@ -252,6 +252,7 @@ def _handle_payment_and_retry( account=self.account, recipient=details["recipient"], amount=details["amount"], + scheme=details.get("scheme"), network=details.get("network", "eip155:8453"), resource_url=resource.get("url", f"{self.api_url}/v1/audio/generations"), resource_description=resource.get("description", "BlockRun Music Generation"), diff --git a/blockrun_llm/phone.py b/blockrun_llm/phone.py index 52db7dd..9b818d4 100644 --- a/blockrun_llm/phone.py +++ b/blockrun_llm/phone.py @@ -294,6 +294,7 @@ def _handle_payment_and_retry( account=self.account, recipient=details["recipient"], amount=details["amount"], + scheme=details.get("scheme"), network=details.get("network", "eip155:8453"), resource_url=resource.get("url", url), resource_description=resource.get("description", "BlockRun Phone"), diff --git a/blockrun_llm/portrait.py b/blockrun_llm/portrait.py index d31f46b..5e0f822 100644 --- a/blockrun_llm/portrait.py +++ b/blockrun_llm/portrait.py @@ -282,6 +282,7 @@ def _handle_payment_and_retry( account=self.account, recipient=details["recipient"], amount=details["amount"], + scheme=details.get("scheme"), network=details.get("network", "eip155:8453"), resource_url=validate_resource_url(resource.get("url", url), self.api_url), resource_description=resource.get( diff --git a/blockrun_llm/price.py b/blockrun_llm/price.py index ad24761..b01b140 100644 --- a/blockrun_llm/price.py +++ b/blockrun_llm/price.py @@ -300,6 +300,7 @@ def _pay_and_retry( account=self.account, recipient=details["recipient"], amount=details["amount"], + scheme=details.get("scheme"), network=details.get("network", "eip155:8453"), resource_url=resource.get("url", url), resource_description=resource.get("description", "BlockRun Price Data"), diff --git a/blockrun_llm/realface.py b/blockrun_llm/realface.py index c9b9899..cd3446a 100644 --- a/blockrun_llm/realface.py +++ b/blockrun_llm/realface.py @@ -436,6 +436,7 @@ def _handle_payment_and_retry( account=self.account, recipient=details["recipient"], amount=details["amount"], + scheme=details.get("scheme"), network=details.get("network", "eip155:8453"), resource_url=validate_resource_url(resource.get("url", url), self.api_url), resource_description=resource.get("description", "BlockRun RealFace Enrollment"), diff --git a/blockrun_llm/rpc.py b/blockrun_llm/rpc.py index 66d970e..ae04e64 100644 --- a/blockrun_llm/rpc.py +++ b/blockrun_llm/rpc.py @@ -380,6 +380,7 @@ def _handle_payment_and_retry( account=self.account, recipient=details["recipient"], amount=details["amount"], + scheme=details.get("scheme"), network=details.get("network", "eip155:8453"), resource_url=resource.get("url", f"{self.api_url}{endpoint}"), resource_description=resource.get("description", "BlockRun Multi-chain RPC"), diff --git a/blockrun_llm/search.py b/blockrun_llm/search.py index 38df411..24df9f6 100644 --- a/blockrun_llm/search.py +++ b/blockrun_llm/search.py @@ -200,6 +200,7 @@ def _handle_payment_and_retry( account=self.account, recipient=details["recipient"], amount=details["amount"], + scheme=details.get("scheme"), network=details.get("network", "eip155:8453"), resource_url=resource.get("url", url), resource_description=resource.get("description", "BlockRun Search"), diff --git a/blockrun_llm/speech.py b/blockrun_llm/speech.py index 04bfd1b..c2a3ee8 100644 --- a/blockrun_llm/speech.py +++ b/blockrun_llm/speech.py @@ -331,6 +331,7 @@ def _handle_payment_and_retry( account=self.account, recipient=details["recipient"], amount=details["amount"], + scheme=details.get("scheme"), network=details.get("network", "eip155:8453"), resource_url=resource.get("url", f"{self.api_url}{endpoint}"), resource_description=resource.get("description", "BlockRun Voice"), diff --git a/blockrun_llm/surf.py b/blockrun_llm/surf.py index c0b5dc5..1307976 100644 --- a/blockrun_llm/surf.py +++ b/blockrun_llm/surf.py @@ -385,6 +385,7 @@ def _handle_payment_and_retry( account=self.account, recipient=details["recipient"], amount=details["amount"], + scheme=details.get("scheme"), network=details.get("network", "eip155:8453"), resource_url=resource.get("url", url), resource_description=resource.get("description", "BlockRun Surf"), diff --git a/blockrun_llm/video.py b/blockrun_llm/video.py index 3ec36d5..63a4511 100644 --- a/blockrun_llm/video.py +++ b/blockrun_llm/video.py @@ -514,6 +514,7 @@ def _sign_from_challenge(self, resp402: httpx.Response, fallback_url: str) -> st account=self.account, recipient=details["recipient"], amount=details["amount"], + scheme=details.get("scheme"), network=details.get("network", "eip155:8453"), resource_url=resource.get("url", fallback_url), resource_description=resource.get("description", "BlockRun Video Generation"), diff --git a/blockrun_llm/voice.py b/blockrun_llm/voice.py index 6ce2de0..dd11288 100644 --- a/blockrun_llm/voice.py +++ b/blockrun_llm/voice.py @@ -343,6 +343,7 @@ def _handle_payment_and_retry( account=self.account, recipient=details["recipient"], amount=details["amount"], + scheme=details.get("scheme"), network=details.get("network", "eip155:8453"), resource_url=resource.get("url", f"{self.api_url}/v1/voice/call"), resource_description=resource.get("description", "BlockRun Voice Call"), diff --git a/blockrun_llm/x402.py b/blockrun_llm/x402.py index 4ac9f1d..c025241 100644 --- a/blockrun_llm/x402.py +++ b/blockrun_llm/x402.py @@ -164,6 +164,7 @@ def create_payment_payload( extra: dict[str, str] | None = None, extensions: dict[str, Any] | None = None, asset: str | None = None, + scheme: str | None = "exact", ) -> str: """ Create a signed x402 v2 payment payload. @@ -181,10 +182,20 @@ def create_payment_payload( max_timeout_seconds: Max timeout for the payment (default: 300) extra: The 402's `extra`. Accepted for compatibility; the domain comes from EVM_NETWORKS. asset: The 402's `asset`. Checked against the network's USDC; a mismatch raises ValueError. + scheme: The scheme of the requirement being paid (``details["scheme"]``). + This signs an EIP-3009 transfer, which is the ``exact`` scheme only; + anything else (e.g. an ``upto`` ceiling) raises ValueError, since it + would authorize that ceiling as a fixed transfer. ``None`` (a v1 + requirement without a scheme) is ``exact``. Returns: Base64-encoded signed payment payload """ + if scheme is not None and scheme != "exact": + raise ValueError( + f"x402 scheme {scheme!r} cannot be signed as an EIP-3009 (exact) transfer; " + "nothing was signed" + ) # Current timestamp now = int(time.time()) valid_after = now - 600 # 10 minutes before (allows for clock skew) @@ -284,7 +295,9 @@ def parse_payment_required(header_value: str) -> dict[str, Any]: raise ValueError("Failed to parse payment required header: invalid format") -def extract_payment_details(payment_required: dict[str, Any]) -> dict[str, Any]: +def extract_payment_details( + payment_required: dict[str, Any], *, allow_upto: bool = False +) -> dict[str, Any]: """ Extract payment details from parsed payment required response. @@ -292,9 +305,18 @@ def extract_payment_details(payment_required: dict[str, Any]) -> dict[str, Any]: Args: payment_required: Parsed payment required dict + allow_upto: Return the ``upto`` requirement when it is the only one + offered, instead of raising. Only for a caller that can pay upto + (chat, via :mod:`blockrun_llm.x402_upto`) and checks ``scheme`` + before signing anything exact. Returns: Dict with amount, recipient, network, asset, and extra info + + Raises: + ValueError: No options, no amount, or (unless ``allow_upto``) only + ``upto`` options, which an EIP-3009 signer must never sign: it + would authorize the whole upto ceiling as a fixed transfer. """ accepts = payment_required.get("accepts", []) if not accepts: @@ -306,8 +328,15 @@ def extract_payment_details(payment_required: dict[str, Any]) -> dict[str, Any]: # the facilitator rejects. Upto is chosen separately, in x402_upto. option = next( (o for o in accepts if isinstance(o, dict) and o.get("scheme", "exact") != "upto"), - accepts[0], + None, ) + if option is None: + if not allow_upto: + raise ValueError( + "This 402 offers only the x402 'upto' scheme, which this endpoint's " + "signer cannot pay; nothing was signed" + ) + option = accepts[0] # Support both v1 (maxAmountRequired) and v2 (amount) formats amount = option.get("amount") or option.get("maxAmountRequired") diff --git a/tests/unit/test_x402_upto.py b/tests/unit/test_x402_upto.py index 00bc94c..39f9adb 100644 --- a/tests/unit/test_x402_upto.py +++ b/tests/unit/test_x402_upto.py @@ -1306,3 +1306,201 @@ def test_cap_uses_the_signed_amount_not_the_lower_body_price(self, monkeypatch): "deepseek/deepseek-chat", MESSAGES ) assert gw.signed == [] + + +# --------------------------------------------------------------------------- +# PAYMENT_REPLAY on the first send is never answered with an exact payment +# --------------------------------------------------------------------------- + + +def replay(**extra: Any) -> httpx.Response: + """The chat route's nonce-claim refusal (blockrun src/lib/payment-nonce.ts).""" + return httpx.Response( + 402, + json={ + "error": "Payment authorization already used", + "message": "Sign a fresh payment authorization for each request.", + "code": "PAYMENT_REPLAY", + "payer": TEST_ACCOUNT.address, + **extra, + }, + ) + + +def _assert_replay_error(exc: PaymentError) -> None: + assert "PAYMENT_REPLAY" in str(exc) + assert "Check your wallet balance" not in str(exc) + assert exc.status_code == 402 + assert exc.response is not None and exc.response["code"] == "PAYMENT_REPLAY" + + +class TestReplayNeverPaysExact: + """A first-send ``PAYMENT_REPLAY`` means the authorization was already used + by an earlier request that was served and paid (e.g. a proxy duplicated + the send). Paying exact on top would charge twice.""" + + def test_sync(self, monkeypatch): + Chain().install(monkeypatch) + gw = Gateway(payment_required(), paid=[replay(), ok()]) + client = sync_client(gw) + with pytest.raises(PaymentError) as exc: + client.chat_completion("deepseek/deepseek-chat", MESSAGES) + _assert_replay_error(exc.value) + assert gw.schemes == ["upto"] # exactly one paid POST + assert client._upto_rejected == {} + + def test_async(self, monkeypatch): + Chain().install(monkeypatch) + gw = Gateway(payment_required(), paid=[replay(), ok()]) + + async def run(): + await async_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + + with pytest.raises(PaymentError) as exc: + asyncio.run(run()) + _assert_replay_error(exc.value) + assert gw.schemes == ["upto"] + + def test_sync_stream(self, monkeypatch): + Chain().install(monkeypatch) + gw = Gateway(payment_required(), paid=[replay(), sse_ok()]) + with pytest.raises(PaymentError) as exc: + list(sync_client(gw).chat_completion_stream("deepseek/deepseek-chat", MESSAGES)) + _assert_replay_error(exc.value) + assert gw.schemes == ["upto"] + + def test_async_stream(self, monkeypatch): + Chain().install(monkeypatch) + gw = Gateway(payment_required(), paid=[replay(), sse_ok()]) + + async def run(): + client = async_client(gw) + return [ + c async for c in client.chat_completion_stream("deepseek/deepseek-chat", MESSAGES) + ] + + with pytest.raises(PaymentError) as exc: + asyncio.run(run()) + _assert_replay_error(exc.value) + assert gw.schemes == ["upto"] + + def test_recoverable_prior_use_keeps_the_poll_url(self, monkeypatch): + Chain().install(monkeypatch) + prior = replay( + message="This authorization was already used by an earlier request.", + job_id="job_1", + poll_url="/api/v1/jobs/job_1", + recoverable=True, + ) + gw = Gateway(payment_required(), paid=[prior, ok()]) + with pytest.raises(PaymentError) as exc: + sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["upto"] + assert "/api/v1/jobs/job_1" in str(exc.value) + assert "already used by an earlier request" in str(exc.value) + assert exc.value.response["poll_url"] == "/api/v1/jobs/job_1" + assert exc.value.response["recoverable"] is True + + def test_prior_use_markers_without_the_code_still_block_exact(self, monkeypatch): + Chain().install(monkeypatch) + body = {"error": "Payment verification failed", "poll_url": "/api/v1/jobs/j"} + gw = Gateway(payment_required(), paid=[httpx.Response(402, json=body), ok()]) + with pytest.raises(PaymentError): + sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["upto"] + + def test_unclassified_402_is_not_a_verify_failure(self, monkeypatch): + Chain().install(monkeypatch) + gw = Gateway(payment_required(), paid=[httpx.Response(402, json={"error": "?"}), ok()]) + with pytest.raises(PaymentError): + sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["upto"] + + def test_verify_failure_codes_still_fall_back(self, monkeypatch): + Chain().install(monkeypatch) + unfunded = httpx.Response( + 402, json={"error": "Payment verification failed", "code": "PAYMENT_UNFUNDED"} + ) + gw = Gateway(payment_required(), paid=[unfunded, ok()]) + sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["upto", "exact"] + + def test_fresh_challenge_402_still_falls_back(self, monkeypatch): + # The gateway answered the upto payment with a new challenge: it was + # not accepted at all, so nothing was used. + Chain().install(monkeypatch) + challenge = httpx.Response( + 402, + json={"error": "Payment Required"}, + headers={"payment-required": b64(payment_required())}, + ) + gw = Gateway(payment_required(), paid=[challenge, ok()]) + sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["upto", "exact"] + + def test_upto_only_402_refused_by_the_gateway_says_so(self, monkeypatch): + Chain().install(monkeypatch) + pr = payment_required() + pr["accepts"] = [upto_entry()] + gw = Gateway(pr, paid=[verify_failed(), ok()]) + with pytest.raises(PaymentError, match="the gateway refused it") as exc: + sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert "Nothing was signed" not in str(exc.value) + assert gw.schemes == ["upto"] + + +# --------------------------------------------------------------------------- +# The shared EIP-3009 signer never signs an upto requirement +# --------------------------------------------------------------------------- + + +class TestSharedSignerRefusesUpto: + def _upto_only(self) -> dict[str, Any]: + pr = payment_required() + pr["accepts"] = [upto_entry()] + return pr + + def test_extract_payment_details_refuses_upto_only(self): + from blockrun_llm.x402 import extract_payment_details + + with pytest.raises(ValueError, match="only the x402 'upto' scheme"): + extract_payment_details(self._upto_only()) + + def test_extract_payment_details_allow_upto_returns_it(self): + from blockrun_llm.x402 import extract_payment_details + + details = extract_payment_details(self._upto_only(), allow_upto=True) + assert details["scheme"] == "upto" + assert details["amount"] == UPTO_CEILING + + def test_extract_payment_details_prefers_exact_over_a_leading_upto(self): + from blockrun_llm.x402 import extract_payment_details + + pr = payment_required() + pr["accepts"] = [upto_entry(), exact_entry()] + assert extract_payment_details(pr)["scheme"] == "exact" + + def test_create_payment_payload_refuses_a_non_exact_scheme(self): + from blockrun_llm.x402 import create_payment_payload + + with pytest.raises(ValueError, match="cannot be signed as an EIP-3009"): + create_payment_payload( + TEST_ACCOUNT, TEST_RECIPIENT, UPTO_CEILING, asset=USDC_BASE, scheme="upto" + ) + + def test_create_payment_payload_default_and_v1_none_are_exact(self): + from blockrun_llm.x402 import create_payment_payload + + for kwargs in ({}, {"scheme": None}, {"scheme": "exact"}): + payload = decode(create_payment_payload(TEST_ACCOUNT, TEST_RECIPIENT, "1000", **kwargs)) + assert payload["accepted"]["scheme"] == "exact" + + def test_image_client_signs_nothing_for_an_upto_only_402(self): + from blockrun_llm.image import ImageClient + + gw = Gateway(self._upto_only()) + client = ImageClient(private_key=TEST_PRIVATE_KEY) + client._client = httpx.Client(transport=httpx.MockTransport(gw)) + with pytest.raises(ValueError, match="only the x402 'upto' scheme"): + client.generate("a cat") + assert gw.signed == []