diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..0ba4720 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,19 @@ +# Normalize line endings in the repository. Without this the working tree's +# native endings leak into commits: 3 files were converted CRLF->LF inside an +# unrelated behavioural fix, inflating that diff from 51 real lines to 1045 and +# burying the change under formatting noise. 14 other tracked files were still +# CRLF, so the next contributor would have regenerated it. +* text=auto + +# Binary formats git must not touch. +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.pdf binary +*.woff binary +*.woff2 binary +*.mp3 binary +*.mp4 binary +*.wav binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05c0a56..c667fc7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,45 +1,45 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - branches: [main] - workflow_dispatch: - -jobs: - test: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ['3.9', '3.11', '3.12'] - - steps: - - uses: actions/checkout@v4 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Install dependencies - run: | - if python3 -c "import sys; exit(0 if sys.version_info >= (3, 10) else 1)"; then - pip install -e ".[dev,solana]" - else - pip install -e ".[dev]" - fi - - - name: Check formatting - run: black --check . - - - name: Lint - run: ruff check . - - - name: Run unit tests - run: | - if python3 -c "import sys; exit(0 if sys.version_info >= (3, 10) else 1)"; then - pytest tests/unit - else - pytest tests/unit --ignore=tests/unit/test_solana_client.py --ignore=tests/unit/test_solana_wallet.py -k "not SolanaX402" - fi +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.9', '3.11', '3.12'] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + if python3 -c "import sys; exit(0 if sys.version_info >= (3, 10) else 1)"; then + pip install -e ".[dev,solana]" + else + pip install -e ".[dev]" + fi + + - name: Check formatting + run: black --check . + + - name: Lint + run: ruff check . + + - name: Run unit tests + run: | + if python3 -c "import sys; exit(0 if sys.version_info >= (3, 10) else 1)"; then + pytest tests/unit + else + pytest tests/unit --ignore=tests/unit/test_solana_client.py --ignore=tests/unit/test_solana_wallet.py -k "not SolanaX402" + fi diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 632c997..5ee5c8e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -15,8 +15,40 @@ jobs: with: python-version: '3.11' - - name: Install build tools - run: pip install build + # 1.8.1 shipped with VERSION and __init__.py still reading 1.8.0. The + # guard test caught it, but only on the push-triggered CI run, after the + # release had been cut and published. PyPI does not allow overwriting a + # published file, so that artifact is permanently wrong. + # + # These steps run ci.yml's checks on the 3.11 leg only — black, ruff and + # the unit suite. They are NOT full CI: the 3.9 and 3.12 legs still run + # only on push, so version-incompatible syntax is caught there, not here. + # Extras match the 3.11 CI job so the gate covers the Solana paths; with + # the plain extra those tests importorskip and pass silently. + - name: Install package and test deps + run: pip install -e ".[dev,solana]" build + + # A release tag that disagrees with the package version means the wrong + # tree is being published. PyPI would reject a duplicate version, but + # only after the release is cut; fail here instead. + - name: Verify the release tag matches VERSION + run: | + tag="${{ github.event.release.tag_name }}" + declared="v$(tr -d '[:space:]' < VERSION)" + if [ "$tag" != "$declared" ]; then + echo "Release tag $tag does not match VERSION ($declared)." >&2 + exit 1 + fi + echo "Release tag $tag matches VERSION." + + - name: Check formatting + run: black --check blockrun_llm/ tests/ + + - name: Lint + run: ruff check blockrun_llm/ tests/ + + - name: Verify the release is publishable + run: pytest tests/unit -q - name: Build package run: python -m build diff --git a/.gitignore b/.gitignore index ff46dc3..df3eca7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,66 +1,66 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg - -# Virtual environments -venv/ -env/ -.venv/ -.env/ - -# Environment files -.env -.env.local -.env.*.local - -# IDE -.vscode/ -.idea/ -*.swp -*.swo - -# OS -.DS_Store -Thumbs.db - -# Test / coverage -.coverage -.pytest_cache/ -htmlcov/ -.tox/ -.nox/ - -# mypy -.mypy_cache/ - -# Jupyter -.ipynb_checkpoints/ - -# Local Claude Code config (machine-specific allowlists) -.claude/settings.local.json - -# Sweep / test-run artifacts -sweep-*.json -sweep-*.log - -# Ruff cache -.ruff_cache/ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +venv/ +env/ +.venv/ +.env/ + +# Environment files +.env +.env.local +.env.*.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Test / coverage +.coverage +.pytest_cache/ +htmlcov/ +.tox/ +.nox/ + +# mypy +.mypy_cache/ + +# Jupyter +.ipynb_checkpoints/ + +# Local Claude Code config (machine-specific allowlists) +.claude/settings.local.json + +# Sweep / test-run artifacts +sweep-*.json +sweep-*.log + +# Ruff cache +.ruff_cache/ diff --git a/CHANGELOG.md b/CHANGELOG.md index cc7cb8e..3263eea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,60 @@ All notable changes to blockrun-llm will be documented in this file. +## 1.8.2 — 2026-07-21 + +Supersedes 1.8.1, which was published from a tree where `VERSION` and +`__init__.py` still read 1.8.0. That wheel reports `__version__ == "1.8.0"` and +cannot be corrected in place, since PyPI does not allow overwriting a published +file. Install 1.8.2 to get a package whose self-reported version is truthful. + +### Security +- **A failed paid request no longer triggers a second payment, on either + chain.** Any error raised after the `PAYMENT-SIGNATURE` went out is now + refused for model fallback, for both Base (`_should_fallback`) and Solana + (`_should_fallback_solana`). Previously a post-settlement failure was + indistinguishable from a transient one, so the chain advanced to the next + model and signed again — `smart_chat` on the premium complex tier could + settle six payments and return no tokens, the "CHARGED BUT REQUEST FAILED" + outcome this file already documents under 1.7.1. This covers every error + escaping the paid leg, not + only timeouts: the dominant post-settlement failure is a paid 5xx, which + arrives as `APIError(503)` — exactly a status the fallback logic treats as + retriable. Callers catching `httpx.TimeoutException` are unaffected; the + marker is an attribute, not a new exception type. +- **The clamp warning cannot break the request it warns about.** Its parse ran + on `resource.description`, a server-controlled string, immediately before + signing. A non-string value raised `TypeError` and aborted the call, and the + pattern backtracked super-linearly on a long digit run (measured on CPython + 3.13: 0.49s at 8k digits, 1.95s at 16k, and it keeps squaring). The number is + now matched by a bounded pattern against a + length-capped slice, an ambiguous description (a per-unit rate alongside the + ceiling) stays silent instead of naming the wrong number, and the whole helper + swallows its own failures. +- **Removed a documented payment guard that does not exist.** + `chat_completion()` advertised `PaymentError: If budget is set and would be + exceeded`. There is no `budget` parameter anywhere in the SDK and no + client-side spend cap — every 402 quote is signed automatically. The + docstring now says that plainly and points at `get_spending()`. + +### Changed +- **`max_tokens` above a model's ceiling is no longer silently absorbed.** The + gateway does not reject an over-ceiling value; it clamps to the model's own + ceiling and quotes payment for the clamped value. Verified against the live + 402 leg on 2026-07-21: `claude-opus-4.8` sent 262144 and 1000000 both quote + the 128000 price, and `gpt-5.2` sent 1e12 returns a quote rather than a 400. + The 402 disclosed the clamp in `resource.description` and the SDK discarded + it while signing. Callers now get a warning naming what they asked for and + what they are being charged for, before the signature goes out. +- The comment and `ValueError` around `MAX_TOKENS_SANITY_LIMIT` claimed the + gateway "enforces the real per-model ceiling and reports it". It does not. + Both now describe clamping. + +### Fixed +- Line endings are normalized repo-wide via `.gitattributes` (`* text=auto`). + 17 tracked files were CRLF against an otherwise-LF tree, which turned a + 51-line change into a 1045-line diff in #27. + ## 1.8.1 — 2026-07-21 ### Fixed diff --git a/LICENSE b/LICENSE index 3f30420..7812798 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,21 @@ -MIT License - -Copyright (c) 2025 BlockRun - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +MIT License + +Copyright (c) 2025 BlockRun + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/VERSION b/VERSION index a8fdfda..53adb84 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.8.1 +1.8.2 diff --git a/blockrun_llm/__init__.py b/blockrun_llm/__init__.py index 08110f4..1dc80f0 100644 --- a/blockrun_llm/__init__.py +++ b/blockrun_llm/__init__.py @@ -178,7 +178,7 @@ ) from .tx_log import TransactionLogger, decode_settlement_header, format_row -__version__ = "1.8.1" +__version__ = "1.8.2" __all__ = [ "LLMClient", "AsyncLLMClient", diff --git a/blockrun_llm/client.py b/blockrun_llm/client.py index 4a3084e..64cabe9 100644 --- a/blockrun_llm/client.py +++ b/blockrun_llm/client.py @@ -38,6 +38,7 @@ """ import os +import re import sys import json as _json from typing import AsyncIterator, Iterator, List, Dict, Any, Optional, Tuple, Union @@ -160,6 +161,33 @@ def list_image_models(api_url: str = "https://blockrun.ai/api") -> List[Dict[str # ============================================================================= +_SETTLED_ATTR = "blockrun_payment_settled" + + +def _mark_settled(exc: BaseException) -> BaseException: + """Tag an exception raised after the x402 payment for this call was signed. + + Signing is settlement. Once the PAYMENT-SIGNATURE has gone out, a retry on + another model is not a free retry: it triggers a fresh 402, a fresh + signature, and a fresh settlement. A six-model fallback chain can therefore + settle six times and return nothing, which the CHANGELOG already records as + a live outcome class ("CHARGED BUT REQUEST FAILED"). The tag is an + attribute rather than a new exception type so callers catching + ``httpx.TimeoutException`` keep working unchanged. + + Applied to every exception escaping the paid leg, not just timeouts. The + dominant post-settlement failure is a paid 5xx, which surfaces as + ``APIError(status_code=503)`` — precisely a status :func:`_should_fallback` + treats as retriable, so tagging only timeouts left the six-settlement path + fully open. Over-tagging is the safe direction here: the handlers begin at an + already-read 402 response and ``create_payment_payload`` is local signing + with no network I/O, so the only exceptions that can be tagged without a + settlement are ones :func:`_should_fallback` already refuses. + """ + setattr(exc, _SETTLED_ATTR, True) + return exc + + def _should_fallback(exc: Exception) -> bool: """Whether ``exc`` is the kind of transient failure that warrants trying the next model in a fallback chain. @@ -167,9 +195,13 @@ def _should_fallback(exc: Exception) -> bool: True for: timeouts, network/connection errors, and APIError with 5xx status codes typically associated with upstream availability problems. - False for: 4xx client errors, PaymentError (wallet/balance issues), and - everything else — those are not "swap upstream and retry" situations. + False for: 4xx client errors, PaymentError (wallet/balance issues), + anything that already cost the caller a settled payment (see + :func:`_mark_settled`), and everything else — those are not "swap upstream + and retry" situations. """ + if getattr(exc, _SETTLED_ATTR, False): + return False if isinstance(exc, httpx.TimeoutException): return True if isinstance(exc, httpx.NetworkError): @@ -179,6 +211,72 @@ def _should_fallback(exc: Exception) -> bool: return False +# The gateway states the output-token ceiling it actually quoted in the 402's +# ``resource.description``, e.g. "claude-opus-4.8 ... 128000 max output tokens". +# +# The alternation is bounded on both branches and the leading lookbehind stops a +# match from starting mid-number, so there is no super-linear backtracking. The +# earlier `(\d[\d,]*)` was quadratic on a digit run — measured on CPython 3.13, +# a string of N '9's: 4k 0.13s, 8k 0.49s, 16k 1.95s, and it keeps squaring. This +# runs on a server-controlled string inside the payment path, so a long +# description would have stalled every paid call. Same input, bounded pattern: +# 0.0002s. `test_pattern_itself_is_not_backtracking` pins it. +_QUOTED_MAX_TOKENS_RE = re.compile( + r"(? None: + """Warn when the gateway quoted fewer output tokens than the caller asked for. + + An over-ceiling ``max_tokens`` is not rejected. The gateway silently clamps + to the model's ceiling and prices the clamped value, so the caller pays for + a ceiling they never asked for and never hears about it. The 402's + ``resource.description`` is the only disclosure, and it would otherwise be + passed straight into the signature and discarded. Surfacing it here is the + caller's one chance to learn their value was dropped before they pay. + + Best-effort by construction: if the description doesn't carry exactly one + recognizable ceiling, stay silent rather than guess. A missed warning costs + the caller nothing beyond today's behavior; a wrong one would erode trust in + all of them. The whole body is guarded because this runs on server-controlled + text immediately before signing, and a diagnostic must never be the reason a + paid request fails. + """ + try: + requested = body.get("max_tokens") + # bool is an int subclass; a stray True is not a token count. + if not isinstance(requested, int) or isinstance(requested, bool): + return + # The gateway sends a string here, but the field is server-controlled and + # JSON allows anything; a non-string must not reach re.search. + if not isinstance(resource_description, str) or not resource_description: + return + + matches = _QUOTED_MAX_TOKENS_RE.findall(resource_description[:_DESCRIPTION_SCAN_LIMIT]) + # Two candidates means the format is not what we think it is (a rate like + # "per 1000 max output tokens" would otherwise read as the ceiling). + if len(matches) != 1: + return + quoted = int(matches[0].replace(",", "")) + + if quoted < requested: + sys.stderr.write( + f"[blockrun_llm] max_tokens clamped by the gateway: you asked for " + f"{requested}, {body.get('model', 'this model')} tops out at {quoted}. " + f"You are being quoted for {quoted} output tokens, not {requested}.\n" + ) + except Exception: + # A warning that breaks the request it is warning about is worse than no + # warning. Includes a closed/broken stderr. + return + + 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`` @@ -568,7 +666,10 @@ def chat_completion( ChatResponse object with choices, usage, and citations (if search enabled) Raises: - PaymentError: If budget is set and would be exceeded + PaymentError: If the gateway rejects the signed payment (most often + an insufficient USDC balance). Note that the SDK enforces no + client-side spend cap: every 402 quote is signed automatically. + Check ``get_spending()`` if you need to bound a session yourself. Example: messages = [ @@ -848,7 +949,26 @@ def _stream_with_payment( raise APIError("stream probe exhausted retries", 0, None) # ----- Phase 2: stream with PAYMENT-SIGNATURE ----- + # 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 + try: + yield from self._stream_paid_phase(url, body, payment_headers, cost_usd, timeout) + except (httpx.HTTPError, APIError) as exc: + _mark_settled(exc) + raise + + def _stream_paid_phase( + self, + url: str, + body: Dict[str, Any], + payment_headers: Dict[str, str], + cost_usd: float, + timeout: Optional[float], + ) -> Iterator[ChatCompletionChunk]: + """Phase 2 of :meth:`_stream_with_payment`: the paid, already-settled leg.""" + 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 @@ -1022,6 +1142,7 @@ def _sign_payment_from_response( ) 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, @@ -1085,7 +1206,14 @@ def _request_with_payment(self, endpoint: str, body: Dict[str, Any]) -> ChatResp # Handle 402 Payment Required if response.status_code == 402: - return self._handle_payment_and_retry(url, body, response) + # Everything inside signs first, then makes the paid request, so a + # timeout or network error escaping it already cost a settlement. + # Tag it so the fallback chain doesn't settle again on the next model. + try: + return self._handle_payment_and_retry(url, body, response) + except (httpx.HTTPError, APIError) as exc: + _mark_settled(exc) + raise # Handle other errors if response.status_code != 200: @@ -1153,6 +1281,7 @@ def _handle_payment_and_retry( # 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( @@ -1269,7 +1398,11 @@ def _request_with_payment_raw(self, endpoint: str, body: Dict[str, Any]) -> Dict response = self._client.post(url, json=body, headers=req_headers) if response.status_code == 402: - result = self._handle_payment_and_retry_raw(url, body, response) + try: + result = self._handle_payment_and_retry_raw(url, body, response) + except (httpx.HTTPError, APIError) as exc: + _mark_settled(exc) + raise # Save paid response to cache save_to_cache( endpoint, @@ -1329,6 +1462,7 @@ def _handle_payment_and_retry_raw( ) 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, @@ -2481,6 +2615,13 @@ async def chat_completion_stream( f"[blockrun_llm] stream {attempt_model} -> {next_model} " f"({type(exc).__name__}: {str(exc)[:80]})\n" ) + finally: + # `async for` alone does not close `inner` when this generator + # is closed or an exception leaves the loop, so an abandoned + # stream would strand the paid `async with stream(...)` and its + # connection until GC. The sync path gets this from `yield + # from`; async has to ask. + await inner.aclose() assert last_exc is not None raise last_exc @@ -2530,7 +2671,34 @@ async def _stream_with_payment( raise APIError("stream probe exhausted retries", 0, None) # ----- Phase 2: stream with PAYMENT-SIGNATURE ----- + # Settled from here on; see the sync path. assert payment_headers 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) + try: + async for chunk in paid: + yield chunk + except (httpx.HTTPError, APIError) as exc: + _mark_settled(exc) + raise + finally: + await paid.aclose() + + async def _astream_paid_phase( + self, + url: str, + body: Dict[str, Any], + payment_headers: Dict[str, str], + cost_usd: float, + timeout: Optional[float], + ) -> AsyncIterator[ChatCompletionChunk]: + """Phase 2 of the async stream: the paid, already-settled leg.""" + 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 @@ -2670,7 +2838,12 @@ async def _request_with_payment(self, endpoint: str, body: Dict[str, Any]) -> Ch response = await self._client.post(url, json=body, headers=req_headers) if response.status_code == 402: - return await self._handle_payment_and_retry(url, body, response) + # See the sync path: past this point the payment is settled. + try: + return await self._handle_payment_and_retry(url, body, response) + except (httpx.HTTPError, APIError) as exc: + _mark_settled(exc) + raise if response.status_code != 200: try: @@ -2718,6 +2891,7 @@ async def _handle_payment_and_retry( # 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( @@ -2830,7 +3004,11 @@ async def _request_with_payment_raw( response = await self._client.post(url, json=body, headers=req_headers) if response.status_code == 402: - result = await self._handle_payment_and_retry_raw(url, body, response) + try: + result = await self._handle_payment_and_retry_raw(url, body, response) + except (httpx.HTTPError, APIError) as exc: + _mark_settled(exc) + raise save_to_cache( endpoint, body, @@ -2881,6 +3059,7 @@ async def _handle_payment_and_retry_raw( details = extract_payment_details(payment_required) 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, diff --git a/blockrun_llm/solana_client.py b/blockrun_llm/solana_client.py index ea0bc67..68b52b2 100644 --- a/blockrun_llm/solana_client.py +++ b/blockrun_llm/solana_client.py @@ -70,6 +70,12 @@ validate_video_input_type, ) +# Shared with the Base client: signing is settlement on either chain, so the +# "already paid, do not retry on another model" tag has to mean the same thing +# in both fallback chains. client.py does not import this module, so there is +# no cycle. +from .client import _SETTLED_ATTR, _mark_settled + try: from x402 import x402ClientSync from x402.mechanisms.svm import KeypairSigner @@ -344,7 +350,13 @@ def _should_fallback_solana(exc: Exception) -> bool: payment classification (``transaction_simulation_failed``, etc.) we do NOT fall back — re-signing a fresh request hits the same wall in seconds. The first failure surfaces immediately. + + Also refuses anything already tagged by + :func:`blockrun_llm.client._mark_settled`: SPL USDC has left the wallet, and + the next model would sign a second transfer for the same call. """ + if getattr(exc, _SETTLED_ATTR, False): + return False # PaymentError always carries the gateway reason now (v0.32.0+). if isinstance(exc, PaymentError): return False @@ -925,27 +937,35 @@ def _stream_once( # ----- Phase 2: stream with PAYMENT-SIGNATURE ----- assert payment_headers is not None - for attempt in range(len(backoffs) + 1): - with self._client.stream( - "POST", url, json=body, headers=payment_headers, timeout=eff_timeout - ) as resp2: - if resp2.status_code == 200: - 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) - return - resp2.read() - if resp2.status_code == 402: - raise build_payment_rejected_error(resp2) - if resp2.status_code in self._STREAM_5XX_STATUSES and attempt < len(backoffs): - import time + try: + for attempt in range(len(backoffs) + 1): + with self._client.stream( + "POST", url, json=body, headers=payment_headers, timeout=eff_timeout + ) as resp2: + if resp2.status_code == 200: + 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) + return + resp2.read() + if resp2.status_code == 402: + raise build_payment_rejected_error(resp2) + if resp2.status_code in self._STREAM_5XX_STATUSES and attempt < len(backoffs): + import time + + time.sleep(backoffs[attempt]) + continue + self._raise_stream_error(resp2, after_payment=True) - time.sleep(backoffs[attempt]) - continue - self._raise_stream_error(resp2, after_payment=True) + except (httpx.HTTPError, APIError) as exc: + # Signed above; SPL USDC is gone. Do not let the fallback + # chain buy a retry on the next model. Re-raise bare so the + # traceback and __context__ survive. + _mark_settled(exc) + raise def _iter_and_archive( self, @@ -1127,7 +1147,13 @@ def _request_once( response = self._client.post(url, json=body, headers=headers, timeout=eff_timeout) if response.status_code == 402: - return self._handle_payment_and_retry(url, body, response, timeout=eff_timeout) + # Past this point the SPL USDC transfer has been signed. Tag + # anything that escapes so no fallback chain can buy a retry. + try: + return self._handle_payment_and_retry(url, body, response, timeout=eff_timeout) + except (httpx.HTTPError, APIError) as exc: + _mark_settled(exc) + raise if not response.is_success: try: @@ -1241,7 +1267,15 @@ def _request_with_payment_raw( response = self._client.post(url, json=body, headers=headers, timeout=eff_timeout) if response.status_code == 402: - result = self._handle_payment_and_retry_raw(url, body, response, timeout=eff_timeout) + # Past this point the SPL USDC transfer has been signed. Tag + # anything that escapes so no fallback chain can buy a retry. + try: + result = self._handle_payment_and_retry_raw( + url, body, response, timeout=eff_timeout + ) + except (httpx.HTTPError, APIError) as exc: + _mark_settled(exc) + raise save_to_cache( endpoint, body, @@ -3138,28 +3172,36 @@ async def _stream_once( # ----- Phase 2: stream with PAYMENT-SIGNATURE ----- assert payment_headers is not None - for attempt in range(len(backoffs) + 1): - async with self._client.stream( - "POST", url, json=body, headers=payment_headers, timeout=eff_timeout - ) as resp2: - if resp2.status_code == 200: - if cost_usd > 0: - self._session_calls += 1 - self._session_total_usd += cost_usd - self._last_call_cost = cost_usd - self._capture_settlement(resp2) - async for chunk in self._aiter_and_archive(resp2, body, cost_usd): - yield chunk - return - await resp2.aread() - if resp2.status_code == 402: - raise build_payment_rejected_error(resp2) - if resp2.status_code in self._STREAM_5XX_STATUSES and attempt < len(backoffs): - import asyncio + try: + for attempt in range(len(backoffs) + 1): + async with self._client.stream( + "POST", url, json=body, headers=payment_headers, timeout=eff_timeout + ) as resp2: + if resp2.status_code == 200: + if cost_usd > 0: + self._session_calls += 1 + self._session_total_usd += cost_usd + self._last_call_cost = cost_usd + self._capture_settlement(resp2) + async for chunk in self._aiter_and_archive(resp2, body, cost_usd): + yield chunk + return + await resp2.aread() + if resp2.status_code == 402: + raise build_payment_rejected_error(resp2) + if resp2.status_code in self._STREAM_5XX_STATUSES and attempt < len(backoffs): + import asyncio + + await asyncio.sleep(backoffs[attempt]) + continue + self._raise_stream_error(resp2, after_payment=True) - await asyncio.sleep(backoffs[attempt]) - continue - self._raise_stream_error(resp2, after_payment=True) + except (httpx.HTTPError, APIError) as exc: + # Signed above; SPL USDC is gone. Do not let the fallback + # chain buy a retry on the next model. Re-raise bare so the + # traceback and __context__ survive. + _mark_settled(exc) + raise @staticmethod async def _aiter_sse_chunks(response: httpx.Response): @@ -3311,7 +3353,15 @@ async def _request_once( response = await self._client.post(url, json=body, headers=headers, timeout=eff_timeout) if response.status_code == 402: - return await self._handle_payment_and_retry(url, body, response, timeout=eff_timeout) + # Past this point the SPL USDC transfer has been signed. Tag + # anything that escapes so no fallback chain can buy a retry. + try: + return await self._handle_payment_and_retry( + url, body, response, timeout=eff_timeout + ) + except (httpx.HTTPError, APIError) as exc: + _mark_settled(exc) + raise if not response.is_success: try: diff --git a/blockrun_llm/solana_wallet.py b/blockrun_llm/solana_wallet.py index 88e9d92..17d5cff 100644 --- a/blockrun_llm/solana_wallet.py +++ b/blockrun_llm/solana_wallet.py @@ -1,577 +1,577 @@ -""" -BlockRun Solana Wallet Management. - -Stores keys as bs58-encoded strings at ~/.blockrun/.solana-session. -Requires: solders>=0.21.0, base58>=2.1.0 -""" - -from __future__ import annotations - -import json -import os -import time -from pathlib import Path -from typing import TYPE_CHECKING, Dict, List, Optional - -if TYPE_CHECKING: - from .solana_client import SolanaLLMClient - -WALLET_DIR = Path.home() / ".blockrun" -SOLANA_WALLET_FILE = WALLET_DIR / ".solana-session" - - -def _require_solders() -> None: - try: - import solders # noqa: F401 - except ImportError: - raise ImportError( - "Solana support requires 'solders' and 'base58' packages. " - "Install with: pip install blockrun-llm[solana]" - ) - - -def create_solana_wallet() -> Dict[str, str]: - """ - Create a new Solana wallet. - - Returns: - Dict with 'address' (base58 pubkey) and 'private_key' (bs58 secret key) - """ - _require_solders() - from solders.keypair import Keypair # type: ignore - - kp = Keypair() - return { - "address": str(kp.pubkey()), - "private_key": str(kp), # bs58-encoded 64-byte keypair - } - - -def solana_key_to_bytes(private_key: str) -> bytes: - """ - Convert a bs58 private key string to bytes (64 bytes). - - Accepts both 64-byte full keypairs and 32-byte seeds (from agentcash - and other providers). 32-byte seeds are automatically expanded. - - Args: - private_key: bs58-encoded Solana secret key (32 or 64 bytes) - - Returns: - 64-byte secret key as bytes - - Raises: - ValueError: If key is invalid - """ - try: - from solders.keypair import Keypair # type: ignore - - try: - kp = Keypair.from_base58_string(private_key) - decoded = bytes(kp) - if len(decoded) == 64: - return decoded - except Exception: - pass - - # Fallback: try as 32-byte seed - import base58 as b58 - - decoded = b58.b58decode(private_key) - if len(decoded) == 32: - kp = Keypair.from_seed(decoded) - return bytes(kp) - elif len(decoded) == 64: - kp = Keypair.from_seed(decoded[:32]) - return bytes(kp) - - raise ValueError(f"Expected 32 or 64 bytes, got {len(decoded)}") - except Exception as e: - # Wrap every failure — including the ``ValueError`` modern ``base58`` - # raises on invalid characters — in the documented message. A bare - # ``except ValueError: raise`` here used to leak base58's raw - # "Invalid character" error past the wrapper, breaking callers (and - # the test) that match on "Invalid Solana private key". - raise ValueError(f"Invalid Solana private key: {e}") from e - - -def get_solana_public_key(private_key: str) -> str: - """ - Get the Solana public key (address) from a bs58 private key. - - Accepts both 64-byte full keypairs and 32-byte seeds. - - Args: - private_key: bs58-encoded Solana secret key (32 or 64 bytes) - - Returns: - Base58 public key string - """ - _require_solders() - from solders.keypair import Keypair # type: ignore - - try: - secret = solana_key_to_bytes(private_key) - kp = Keypair.from_seed(secret[:32]) - return str(kp.pubkey()) - except ValueError: - # 32-byte seed - import base58 as b58 - - decoded = b58.b58decode(private_key) - if len(decoded) == 32: - kp = Keypair.from_seed(decoded) - return str(kp.pubkey()) - raise - - -def save_solana_wallet(private_key: str) -> Path: - WALLET_DIR.mkdir(exist_ok=True) - SOLANA_WALLET_FILE.write_text(private_key) - SOLANA_WALLET_FILE.chmod(0o600) - return SOLANA_WALLET_FILE - - -def _expand_solana_seed(private_key: str) -> str: - """If private_key is a 32-byte seed, expand to 64-byte keypair bs58 string.""" - import base58 as b58 - from solders.keypair import Keypair # type: ignore - - decoded = b58.b58decode(private_key) - if len(decoded) == 32: - kp = Keypair.from_seed(decoded) - return b58.b58encode(bytes(kp)).decode() - return private_key - - -def scan_solana_wallets() -> List[Dict[str, str]]: - """ - Discover ~/./solana-wallet.json files from other providers. - - Each file should contain JSON with "privateKey" and "address" fields. - Results are sorted by modification time (most recent first). Discovery is - opt-in and must never replace the canonical BlockRun wallet automatically. - 32-byte seeds are automatically converted to 64-byte keypairs. - - Returns: - List of dicts with 'private_key', 'address' and 'source', most recent - first. 'address' is the file's own claim — use - list_discovered_solana_wallets() for an address derived from the key. - """ - home = Path.home() - results: List[tuple] = [] # (mtime, private_key, address, source) - - try: - for entry in home.iterdir(): - if not entry.name.startswith(".") or not entry.is_dir(): - continue - wallet_file = entry / "solana-wallet.json" - if not wallet_file.is_file(): - continue - try: - data = json.loads(wallet_file.read_text()) - pk = data.get("privateKey", "") - addr = data.get("address", "") - if pk and addr: - # Expand 32-byte seeds to full keypairs - try: - pk = _expand_solana_seed(pk) - except Exception: - pass - mtime = wallet_file.stat().st_mtime - results.append((mtime, pk, addr, str(wallet_file))) - except (json.JSONDecodeError, OSError): - continue - except OSError: - pass - - # Sort by modification time, most recent first - results.sort(key=lambda x: x[0], reverse=True) - return [{"private_key": pk, "address": addr, "source": src} for _, pk, addr, src in results] - - -def list_discovered_solana_wallets() -> List[Dict[str, str]]: - """ - List Solana wallets from other applications, safe to show to a user. - - Solana counterpart of ``wallet.list_discovered_wallets``: no secret key is - returned and the address is derived from the key rather than trusted from - the file. Nothing here is active — adopt one with import_solana_wallet(). - - Returns: - List of dicts with 'address' and 'source', most recent first - """ - listed = [] - for entry in scan_solana_wallets(): - try: - address = get_solana_public_key(entry["private_key"]) - except Exception: - continue - listed.append({"address": address, "source": entry.get("source", "")}) - return listed - - -def import_solana_wallet(address: str) -> str: - """ - Adopt a discovered Solana wallet, making it the active BlockRun wallet. - - Solana counterpart of ``wallet.import_wallet``. Matching is done against the - address derived from each discovered key, and the current - ~/.blockrun/.solana-session is backed up before being overwritten. - - Args: - address: Address to adopt, as shown by list_discovered_solana_wallets() - - Returns: - The adopted address - - Raises: - ValueError: If no discovered wallet derives to that address - """ - wanted = address.strip() - - for entry in scan_solana_wallets(): - try: - derived = get_solana_public_key(entry["private_key"]) - except Exception: - continue - - # Base58 is case-sensitive — compare exactly, unlike EVM hex. - if derived != wanted: - continue - - if SOLANA_WALLET_FILE.exists(): - current = SOLANA_WALLET_FILE.read_text().strip() - if current and current != entry["private_key"]: - backup = SOLANA_WALLET_FILE.with_name(f".solana-session.backup-{int(time.time())}") - backup.write_text(current) - backup.chmod(0o600) - - save_solana_wallet(entry["private_key"]) - return derived - - available = [w["address"] for w in list_discovered_solana_wallets()] - raise ValueError( - f"No discovered wallet controls {address}. " - f"Available: {', '.join(available) if available else 'none'}" - ) - - -def load_solana_wallet() -> Optional[str]: - """ - Load Solana wallet private key. - - Priority: - 1. ~/.blockrun/.solana-session - """ - # The canonical BlockRun wallet always wins over a discovered provider key. - if SOLANA_WALLET_FILE.exists(): - try: - key = SOLANA_WALLET_FILE.read_text().strip() - except OSError: - return None # unreadable (bad perms/ownership) → treat as "no wallet" - if key: - return key - return None - - -def get_or_create_solana_wallet() -> Dict[str, object]: - """ - Get existing Solana wallet or create new one. - - Priority: - 1. SOLANA_WALLET_KEY env var - 2. ~/.blockrun/.solana-session - 3. Create new - - Returns: - Dict with 'address', 'private_key', 'is_new' - """ - # 1. Environment variable - env_key = os.environ.get("SOLANA_WALLET_KEY") - if env_key: - return {"private_key": env_key, "address": get_solana_public_key(env_key), "is_new": False} - - # 2. Canonical BlockRun session file. scan_solana_wallets() is exposed - # only for an explicit migration flow. - if SOLANA_WALLET_FILE.exists(): - file_key = SOLANA_WALLET_FILE.read_text().strip() - if file_key: - return { - "private_key": file_key, - "address": get_solana_public_key(file_key), - "is_new": False, - } - - # 3. Create new - wallet = create_solana_wallet() - save_solana_wallet(wallet["private_key"]) - return {**wallet, "is_new": True} - - -def format_solana_wallet_migration_notice(new_address: str) -> Optional[str]: - """ - Warn when a new Solana wallet was created while provider wallets exist. - - Solana counterpart of ``wallet.format_wallet_migration_notice``. Addresses - are derived from the discovered secret key rather than trusted from the - file's "address" field. - - Args: - new_address: Address of the wallet that was just created - - Returns: - Formatted notice, or None if nothing was discovered - """ - try: - discovered = scan_solana_wallets() - except Exception: - return None - - addresses = [] - for entry in discovered: - try: - addresses.append(get_solana_public_key(entry["private_key"])) - except Exception: - continue - - if not addresses: - return None - - found = "\n".join(f" {addr}" for addr in addresses) - return f""" -NOTICE: BlockRun created a new Solana wallet, but also found existing -wallet(s) belonging to other applications on this system: - -{found} - -BlockRun now uses only its own wallet: - - {new_address} - -Discovered wallets are never adopted automatically — one may belong to a -different application, or have been planted to make you fund an address you -do not control. - -If an address above is yours and holds your USDC, adopt it deliberately: - - from blockrun_llm import import_solana_wallet - import_solana_wallet("") - -Your current wallet is backed up first. You can also set -SOLANA_WALLET_KEY= for a single run without changing anything. -""" - - -def setup_agent_solana_wallet(silent: bool = False) -> "SolanaLLMClient": - """ - Set up Solana wallet for agent use and return a SolanaLLMClient. - - This is the entry point for Claude Code skills and other agent runtimes. - It auto-creates a Solana wallet if needed and prints address if new. - - Args: - silent: If True, don't print welcome message (default: False) - - Returns: - Configured SolanaLLMClient ready for use - - Example: - from blockrun_llm import setup_agent_solana_wallet - - client = setup_agent_solana_wallet() - response = client.chat("openai/gpt-5.2", "Hello!") - """ - import sys - - result = get_or_create_solana_wallet() - - if result["is_new"]: - # Printed even when silent: `silent` suppresses the welcome message, - # and losing sight of a funded wallet is not something to stay quiet - # about. - notice = format_solana_wallet_migration_notice(str(result["address"])) - if notice: - print(notice, file=sys.stderr) - - if not silent: - print(f"New Solana wallet created: {result['address']}", file=sys.stderr) - - from .solana_client import SolanaLLMClient - - return SolanaLLMClient(private_key=result["private_key"]) - - -def get_solana_usdc_balance(address: str, rpc_url: Optional[str] = None) -> float: - """ - Get USDC-SPL balance for a Solana address. - - Args: - address: Solana wallet address (base58) - rpc_url: Solana RPC endpoint (default: mainnet-beta) - - Returns: - USDC balance as float (6 decimals) - """ - import httpx - - rpc = rpc_url or "https://api.mainnet-beta.solana.com" - # USDC mint on Solana mainnet - usdc_mint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" - - try: - resp = httpx.post( - rpc, - json={ - "jsonrpc": "2.0", - "id": 1, - "method": "getTokenAccountsByOwner", - "params": [ - address, - {"mint": usdc_mint}, - {"encoding": "jsonParsed"}, - ], - }, - timeout=10, - ) - resp.raise_for_status() - data = resp.json() - - accounts = data.get("result", {}).get("value", []) - if not accounts: - return 0.0 - - # Sum all USDC token accounts (usually just one) - total = 0.0 - for acct in accounts: - info = acct.get("account", {}).get("data", {}).get("parsed", {}).get("info", {}) - token_amount = info.get("tokenAmount", {}) - total += float(token_amount.get("uiAmount", 0)) - return total - - except Exception: - return 0.0 - - -# QR code file paths for Solana -SOLANA_QR_FILE = WALLET_DIR / "solana_qr.png" -SOLANA_QR_ASCII_FILE = WALLET_DIR / "solana_qr.txt" - - -def generate_solana_qr_ascii(address: str) -> str: - """ - Generate ASCII QR code for Solana wallet funding. - Uses solana: URI scheme. Caches to ~/.blockrun/solana_qr.txt. - - Args: - address: Solana wallet address (base58) - - Returns: - ASCII art QR code string - """ - solana_uri = f"solana:{address}" - cache_key = f"v1:{solana_uri}" - - # Try cache - if SOLANA_QR_ASCII_FILE.exists(): - try: - cached = SOLANA_QR_ASCII_FILE.read_text() - lines = cached.split("\n", 1) - if len(lines) == 2 and lines[0] == cache_key: - return lines[1] - except Exception: - pass - - # Generate new QR - try: - import qrcode - from io import StringIO - - qr = qrcode.QRCode( - version=1, - error_correction=qrcode.constants.ERROR_CORRECT_L, - box_size=1, - border=1, - ) - qr.add_data(solana_uri) - qr.make(fit=True) - - f = StringIO() - qr.print_ascii(out=f, invert=True) - qr_ascii = f.getvalue() - - # Cache - try: - WALLET_DIR.mkdir(exist_ok=True) - SOLANA_QR_ASCII_FILE.write_text(f"{cache_key}\n{qr_ascii}") - except Exception: - pass - - return qr_ascii - - except ImportError: - return f"[QR code requires 'qrcode' package: pip install qrcode[pil]]\nAddress: {address}" - - -def save_solana_wallet_qr(address: str, path: Optional[str] = None) -> str: - """ - Save Solana QR code as PNG image. - - Args: - address: Solana wallet address (base58) - path: Optional custom path (default: ~/.blockrun/solana_qr.png) - - Returns: - Path to saved QR image, or empty string on failure - """ - try: - import qrcode - - solana_uri = f"solana:{address}" - - qr = qrcode.QRCode( - version=4, - error_correction=qrcode.constants.ERROR_CORRECT_L, - box_size=10, - border=2, - ) - qr.add_data(solana_uri) - qr.make(fit=True) - - img = qr.make_image(fill_color="black", back_color="white").convert("RGB") - - save_path = Path(path) if path else SOLANA_QR_FILE - save_path.parent.mkdir(exist_ok=True) - img.save(str(save_path)) - - return str(save_path) - - except ImportError: - return "" - - -def open_solana_wallet_qr(address: str) -> str: - """ - Generate Solana QR code and open it in the default image viewer. - - Args: - address: Solana wallet address (base58) - - Returns: - Path to saved QR image - """ - import subprocess - import platform - - qr_path = save_solana_wallet_qr(address) - if qr_path: - try: - if platform.system() == "Darwin": - subprocess.run(["open", qr_path], check=True) - elif platform.system() == "Windows": - subprocess.run(["start", qr_path], shell=True, check=True) - else: - subprocess.run(["xdg-open", qr_path], check=True) - except Exception: - pass - return qr_path +""" +BlockRun Solana Wallet Management. + +Stores keys as bs58-encoded strings at ~/.blockrun/.solana-session. +Requires: solders>=0.21.0, base58>=2.1.0 +""" + +from __future__ import annotations + +import json +import os +import time +from pathlib import Path +from typing import TYPE_CHECKING, Dict, List, Optional + +if TYPE_CHECKING: + from .solana_client import SolanaLLMClient + +WALLET_DIR = Path.home() / ".blockrun" +SOLANA_WALLET_FILE = WALLET_DIR / ".solana-session" + + +def _require_solders() -> None: + try: + import solders # noqa: F401 + except ImportError: + raise ImportError( + "Solana support requires 'solders' and 'base58' packages. " + "Install with: pip install blockrun-llm[solana]" + ) + + +def create_solana_wallet() -> Dict[str, str]: + """ + Create a new Solana wallet. + + Returns: + Dict with 'address' (base58 pubkey) and 'private_key' (bs58 secret key) + """ + _require_solders() + from solders.keypair import Keypair # type: ignore + + kp = Keypair() + return { + "address": str(kp.pubkey()), + "private_key": str(kp), # bs58-encoded 64-byte keypair + } + + +def solana_key_to_bytes(private_key: str) -> bytes: + """ + Convert a bs58 private key string to bytes (64 bytes). + + Accepts both 64-byte full keypairs and 32-byte seeds (from agentcash + and other providers). 32-byte seeds are automatically expanded. + + Args: + private_key: bs58-encoded Solana secret key (32 or 64 bytes) + + Returns: + 64-byte secret key as bytes + + Raises: + ValueError: If key is invalid + """ + try: + from solders.keypair import Keypair # type: ignore + + try: + kp = Keypair.from_base58_string(private_key) + decoded = bytes(kp) + if len(decoded) == 64: + return decoded + except Exception: + pass + + # Fallback: try as 32-byte seed + import base58 as b58 + + decoded = b58.b58decode(private_key) + if len(decoded) == 32: + kp = Keypair.from_seed(decoded) + return bytes(kp) + elif len(decoded) == 64: + kp = Keypair.from_seed(decoded[:32]) + return bytes(kp) + + raise ValueError(f"Expected 32 or 64 bytes, got {len(decoded)}") + except Exception as e: + # Wrap every failure — including the ``ValueError`` modern ``base58`` + # raises on invalid characters — in the documented message. A bare + # ``except ValueError: raise`` here used to leak base58's raw + # "Invalid character" error past the wrapper, breaking callers (and + # the test) that match on "Invalid Solana private key". + raise ValueError(f"Invalid Solana private key: {e}") from e + + +def get_solana_public_key(private_key: str) -> str: + """ + Get the Solana public key (address) from a bs58 private key. + + Accepts both 64-byte full keypairs and 32-byte seeds. + + Args: + private_key: bs58-encoded Solana secret key (32 or 64 bytes) + + Returns: + Base58 public key string + """ + _require_solders() + from solders.keypair import Keypair # type: ignore + + try: + secret = solana_key_to_bytes(private_key) + kp = Keypair.from_seed(secret[:32]) + return str(kp.pubkey()) + except ValueError: + # 32-byte seed + import base58 as b58 + + decoded = b58.b58decode(private_key) + if len(decoded) == 32: + kp = Keypair.from_seed(decoded) + return str(kp.pubkey()) + raise + + +def save_solana_wallet(private_key: str) -> Path: + WALLET_DIR.mkdir(exist_ok=True) + SOLANA_WALLET_FILE.write_text(private_key) + SOLANA_WALLET_FILE.chmod(0o600) + return SOLANA_WALLET_FILE + + +def _expand_solana_seed(private_key: str) -> str: + """If private_key is a 32-byte seed, expand to 64-byte keypair bs58 string.""" + import base58 as b58 + from solders.keypair import Keypair # type: ignore + + decoded = b58.b58decode(private_key) + if len(decoded) == 32: + kp = Keypair.from_seed(decoded) + return b58.b58encode(bytes(kp)).decode() + return private_key + + +def scan_solana_wallets() -> List[Dict[str, str]]: + """ + Discover ~/./solana-wallet.json files from other providers. + + Each file should contain JSON with "privateKey" and "address" fields. + Results are sorted by modification time (most recent first). Discovery is + opt-in and must never replace the canonical BlockRun wallet automatically. + 32-byte seeds are automatically converted to 64-byte keypairs. + + Returns: + List of dicts with 'private_key', 'address' and 'source', most recent + first. 'address' is the file's own claim — use + list_discovered_solana_wallets() for an address derived from the key. + """ + home = Path.home() + results: List[tuple] = [] # (mtime, private_key, address, source) + + try: + for entry in home.iterdir(): + if not entry.name.startswith(".") or not entry.is_dir(): + continue + wallet_file = entry / "solana-wallet.json" + if not wallet_file.is_file(): + continue + try: + data = json.loads(wallet_file.read_text()) + pk = data.get("privateKey", "") + addr = data.get("address", "") + if pk and addr: + # Expand 32-byte seeds to full keypairs + try: + pk = _expand_solana_seed(pk) + except Exception: + pass + mtime = wallet_file.stat().st_mtime + results.append((mtime, pk, addr, str(wallet_file))) + except (json.JSONDecodeError, OSError): + continue + except OSError: + pass + + # Sort by modification time, most recent first + results.sort(key=lambda x: x[0], reverse=True) + return [{"private_key": pk, "address": addr, "source": src} for _, pk, addr, src in results] + + +def list_discovered_solana_wallets() -> List[Dict[str, str]]: + """ + List Solana wallets from other applications, safe to show to a user. + + Solana counterpart of ``wallet.list_discovered_wallets``: no secret key is + returned and the address is derived from the key rather than trusted from + the file. Nothing here is active — adopt one with import_solana_wallet(). + + Returns: + List of dicts with 'address' and 'source', most recent first + """ + listed = [] + for entry in scan_solana_wallets(): + try: + address = get_solana_public_key(entry["private_key"]) + except Exception: + continue + listed.append({"address": address, "source": entry.get("source", "")}) + return listed + + +def import_solana_wallet(address: str) -> str: + """ + Adopt a discovered Solana wallet, making it the active BlockRun wallet. + + Solana counterpart of ``wallet.import_wallet``. Matching is done against the + address derived from each discovered key, and the current + ~/.blockrun/.solana-session is backed up before being overwritten. + + Args: + address: Address to adopt, as shown by list_discovered_solana_wallets() + + Returns: + The adopted address + + Raises: + ValueError: If no discovered wallet derives to that address + """ + wanted = address.strip() + + for entry in scan_solana_wallets(): + try: + derived = get_solana_public_key(entry["private_key"]) + except Exception: + continue + + # Base58 is case-sensitive — compare exactly, unlike EVM hex. + if derived != wanted: + continue + + if SOLANA_WALLET_FILE.exists(): + current = SOLANA_WALLET_FILE.read_text().strip() + if current and current != entry["private_key"]: + backup = SOLANA_WALLET_FILE.with_name(f".solana-session.backup-{int(time.time())}") + backup.write_text(current) + backup.chmod(0o600) + + save_solana_wallet(entry["private_key"]) + return derived + + available = [w["address"] for w in list_discovered_solana_wallets()] + raise ValueError( + f"No discovered wallet controls {address}. " + f"Available: {', '.join(available) if available else 'none'}" + ) + + +def load_solana_wallet() -> Optional[str]: + """ + Load Solana wallet private key. + + Priority: + 1. ~/.blockrun/.solana-session + """ + # The canonical BlockRun wallet always wins over a discovered provider key. + if SOLANA_WALLET_FILE.exists(): + try: + key = SOLANA_WALLET_FILE.read_text().strip() + except OSError: + return None # unreadable (bad perms/ownership) → treat as "no wallet" + if key: + return key + return None + + +def get_or_create_solana_wallet() -> Dict[str, object]: + """ + Get existing Solana wallet or create new one. + + Priority: + 1. SOLANA_WALLET_KEY env var + 2. ~/.blockrun/.solana-session + 3. Create new + + Returns: + Dict with 'address', 'private_key', 'is_new' + """ + # 1. Environment variable + env_key = os.environ.get("SOLANA_WALLET_KEY") + if env_key: + return {"private_key": env_key, "address": get_solana_public_key(env_key), "is_new": False} + + # 2. Canonical BlockRun session file. scan_solana_wallets() is exposed + # only for an explicit migration flow. + if SOLANA_WALLET_FILE.exists(): + file_key = SOLANA_WALLET_FILE.read_text().strip() + if file_key: + return { + "private_key": file_key, + "address": get_solana_public_key(file_key), + "is_new": False, + } + + # 3. Create new + wallet = create_solana_wallet() + save_solana_wallet(wallet["private_key"]) + return {**wallet, "is_new": True} + + +def format_solana_wallet_migration_notice(new_address: str) -> Optional[str]: + """ + Warn when a new Solana wallet was created while provider wallets exist. + + Solana counterpart of ``wallet.format_wallet_migration_notice``. Addresses + are derived from the discovered secret key rather than trusted from the + file's "address" field. + + Args: + new_address: Address of the wallet that was just created + + Returns: + Formatted notice, or None if nothing was discovered + """ + try: + discovered = scan_solana_wallets() + except Exception: + return None + + addresses = [] + for entry in discovered: + try: + addresses.append(get_solana_public_key(entry["private_key"])) + except Exception: + continue + + if not addresses: + return None + + found = "\n".join(f" {addr}" for addr in addresses) + return f""" +NOTICE: BlockRun created a new Solana wallet, but also found existing +wallet(s) belonging to other applications on this system: + +{found} + +BlockRun now uses only its own wallet: + + {new_address} + +Discovered wallets are never adopted automatically — one may belong to a +different application, or have been planted to make you fund an address you +do not control. + +If an address above is yours and holds your USDC, adopt it deliberately: + + from blockrun_llm import import_solana_wallet + import_solana_wallet("") + +Your current wallet is backed up first. You can also set +SOLANA_WALLET_KEY= for a single run without changing anything. +""" + + +def setup_agent_solana_wallet(silent: bool = False) -> "SolanaLLMClient": + """ + Set up Solana wallet for agent use and return a SolanaLLMClient. + + This is the entry point for Claude Code skills and other agent runtimes. + It auto-creates a Solana wallet if needed and prints address if new. + + Args: + silent: If True, don't print welcome message (default: False) + + Returns: + Configured SolanaLLMClient ready for use + + Example: + from blockrun_llm import setup_agent_solana_wallet + + client = setup_agent_solana_wallet() + response = client.chat("openai/gpt-5.2", "Hello!") + """ + import sys + + result = get_or_create_solana_wallet() + + if result["is_new"]: + # Printed even when silent: `silent` suppresses the welcome message, + # and losing sight of a funded wallet is not something to stay quiet + # about. + notice = format_solana_wallet_migration_notice(str(result["address"])) + if notice: + print(notice, file=sys.stderr) + + if not silent: + print(f"New Solana wallet created: {result['address']}", file=sys.stderr) + + from .solana_client import SolanaLLMClient + + return SolanaLLMClient(private_key=result["private_key"]) + + +def get_solana_usdc_balance(address: str, rpc_url: Optional[str] = None) -> float: + """ + Get USDC-SPL balance for a Solana address. + + Args: + address: Solana wallet address (base58) + rpc_url: Solana RPC endpoint (default: mainnet-beta) + + Returns: + USDC balance as float (6 decimals) + """ + import httpx + + rpc = rpc_url or "https://api.mainnet-beta.solana.com" + # USDC mint on Solana mainnet + usdc_mint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" + + try: + resp = httpx.post( + rpc, + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "getTokenAccountsByOwner", + "params": [ + address, + {"mint": usdc_mint}, + {"encoding": "jsonParsed"}, + ], + }, + timeout=10, + ) + resp.raise_for_status() + data = resp.json() + + accounts = data.get("result", {}).get("value", []) + if not accounts: + return 0.0 + + # Sum all USDC token accounts (usually just one) + total = 0.0 + for acct in accounts: + info = acct.get("account", {}).get("data", {}).get("parsed", {}).get("info", {}) + token_amount = info.get("tokenAmount", {}) + total += float(token_amount.get("uiAmount", 0)) + return total + + except Exception: + return 0.0 + + +# QR code file paths for Solana +SOLANA_QR_FILE = WALLET_DIR / "solana_qr.png" +SOLANA_QR_ASCII_FILE = WALLET_DIR / "solana_qr.txt" + + +def generate_solana_qr_ascii(address: str) -> str: + """ + Generate ASCII QR code for Solana wallet funding. + Uses solana: URI scheme. Caches to ~/.blockrun/solana_qr.txt. + + Args: + address: Solana wallet address (base58) + + Returns: + ASCII art QR code string + """ + solana_uri = f"solana:{address}" + cache_key = f"v1:{solana_uri}" + + # Try cache + if SOLANA_QR_ASCII_FILE.exists(): + try: + cached = SOLANA_QR_ASCII_FILE.read_text() + lines = cached.split("\n", 1) + if len(lines) == 2 and lines[0] == cache_key: + return lines[1] + except Exception: + pass + + # Generate new QR + try: + import qrcode + from io import StringIO + + qr = qrcode.QRCode( + version=1, + error_correction=qrcode.constants.ERROR_CORRECT_L, + box_size=1, + border=1, + ) + qr.add_data(solana_uri) + qr.make(fit=True) + + f = StringIO() + qr.print_ascii(out=f, invert=True) + qr_ascii = f.getvalue() + + # Cache + try: + WALLET_DIR.mkdir(exist_ok=True) + SOLANA_QR_ASCII_FILE.write_text(f"{cache_key}\n{qr_ascii}") + except Exception: + pass + + return qr_ascii + + except ImportError: + return f"[QR code requires 'qrcode' package: pip install qrcode[pil]]\nAddress: {address}" + + +def save_solana_wallet_qr(address: str, path: Optional[str] = None) -> str: + """ + Save Solana QR code as PNG image. + + Args: + address: Solana wallet address (base58) + path: Optional custom path (default: ~/.blockrun/solana_qr.png) + + Returns: + Path to saved QR image, or empty string on failure + """ + try: + import qrcode + + solana_uri = f"solana:{address}" + + qr = qrcode.QRCode( + version=4, + error_correction=qrcode.constants.ERROR_CORRECT_L, + box_size=10, + border=2, + ) + qr.add_data(solana_uri) + qr.make(fit=True) + + img = qr.make_image(fill_color="black", back_color="white").convert("RGB") + + save_path = Path(path) if path else SOLANA_QR_FILE + save_path.parent.mkdir(exist_ok=True) + img.save(str(save_path)) + + return str(save_path) + + except ImportError: + return "" + + +def open_solana_wallet_qr(address: str) -> str: + """ + Generate Solana QR code and open it in the default image viewer. + + Args: + address: Solana wallet address (base58) + + Returns: + Path to saved QR image + """ + import subprocess + import platform + + qr_path = save_solana_wallet_qr(address) + if qr_path: + try: + if platform.system() == "Darwin": + subprocess.run(["open", qr_path], check=True) + elif platform.system() == "Windows": + subprocess.run(["start", qr_path], shell=True, check=True) + else: + subprocess.run(["xdg-open", qr_path], check=True) + except Exception: + pass + return qr_path diff --git a/blockrun_llm/validation.py b/blockrun_llm/validation.py index 9c798eb..a9736f4 100644 --- a/blockrun_llm/validation.py +++ b/blockrun_llm/validation.py @@ -207,17 +207,20 @@ def validate_image_quality(quality: Optional[str]) -> None: ) -# Client-side typo guard, NOT a model limit. The gateway already enforces the -# real per-model ceiling and rejects with that model's own number, so anything -# the SDK hardcodes here can only be wrong in one direction: too low. +# Client-side typo guard, NOT a model limit. # -# This was 100000, and it silently capped every SDK caller below what models -# actually support. Verified against the live gateway 2026-07-21 with the guard -# bypassed: zai/glm-5.2 accepts 262144, and the entire 128000 class accepts -# 128000 (claude-opus-4.8, claude-sonnet-5, claude-fable-5, gpt-5.6-sol/terra/ -# luna, gpt-5.5, gpt-5.4, gpt-5.3-codex, glm-5/5.1/5-turbo — 19 models, 19 -# accepted, zero rejections). Callers asking for those ceilings got a -# ValueError that never reached the network and named a limit no provider set. +# This was 100000, which sat below what models actually serve: zai/glm-5.2 +# serves 262144 and the common ceiling is 128000, so the SDK — not the model — +# was the binding constraint, and callers got a ValueError naming a limit no +# provider had set. +# +# The gateway does NOT reject an over-ceiling max_tokens. It silently clamps to +# the model's ceiling and quotes payment for the clamped value (probed against +# the live 402 leg 2026-07-21: opus-4.8 sent 262144 and 1000000 both quote the +# 128000 price; gpt-5.2 sent 1e12 returns a quote, not a 400). So there is no +# server-side rejection to fall back on — whatever passes here gets priced, and +# anything above the model's ceiling is money spent on tokens you won't get. +# ``LLMClient`` warns when it sees the gateway clamp; see ``_warn_if_clamped``. # # Keep a bound so an obvious mistake (1e9, a byte count, a timestamp) fails # fast locally instead of becoming a payment quote. Set it far above any real @@ -250,8 +253,10 @@ def validate_max_tokens(max_tokens: Optional[int]) -> None: if max_tokens > MAX_TOKENS_SANITY_LIMIT: raise ValueError( f"max_tokens implausibly large (client-side sanity limit: " - f"{MAX_TOKENS_SANITY_LIMIT}). This is not a model limit — the " - f"gateway enforces the real per-model ceiling and reports it." + f"{MAX_TOKENS_SANITY_LIMIT}). This is not a model limit — no " + f"provider set it. Anything under it is sent to the gateway, " + f"which clamps to the model's own ceiling and charges for the " + f"clamped value rather than rejecting." ) diff --git a/blockrun_llm/x402.py b/blockrun_llm/x402.py index 4576519..97656a4 100644 --- a/blockrun_llm/x402.py +++ b/blockrun_llm/x402.py @@ -1,272 +1,272 @@ -""" -x402 Payment Protocol v2 Implementation for BlockRun. - -This module handles creating signed payment payloads for the x402 v2 protocol. -The private key is used ONLY for local signing and NEVER leaves the client. -""" - -import json -import time -import base64 -import secrets -from typing import Dict, Any, Optional -from eth_account import Account -from eth_account.messages import encode_typed_data - - -# Chain and token constants for mainnet -BASE_CHAIN_ID = 8453 -USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" - -# Chain and token constants for testnet (Base Sepolia) -BASE_SEPOLIA_CHAIN_ID = 84532 -USDC_BASE_SEPOLIA = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" - - -# BlockRun's x402 builder code — the ERC-8021 Schema 2 service code (`s`) that -# tags every payment this SDK signs as BlockRun-originated for on-chain -# attribution. See https://docs.cdp.coinbase.com/x402/core-concepts/builder-codes -BLOCKRUN_SERVICE_CODE = "blockrun" - - -def with_builder_code_service_code( - extensions: Optional[Dict[str, Any]], -) -> Dict[str, Any]: - """Merge BlockRun's service code (``s``) into the payload's ``builder-code`` - extension, preserving any app code (``a``) the server echoed back in its 402. - - The CDP facilitator reads ``builder-code.info.s`` and encodes it into the - settlement calldata suffix — no CBOR/encoding happens client-side. - """ - merged: Dict[str, Any] = dict(extensions or {}) - existing = dict(merged.get("builder-code") or {}) - info = dict(existing.get("info") or {}) - info["s"] = [BLOCKRUN_SERVICE_CODE] - existing["info"] = info - merged["builder-code"] = existing - return merged - - -def get_chain_config(network: str) -> tuple[int, str]: - """ - Get chain ID and USDC contract address for a given network. - - Args: - network: Network identifier in EIP-155 format (e.g., "eip155:8453" or "eip155:84532") - - Returns: - Tuple of (chain_id, usdc_address) - """ - if network == "eip155:84532" or network == "base-sepolia": - return BASE_SEPOLIA_CHAIN_ID, USDC_BASE_SEPOLIA - # Default to mainnet - return BASE_CHAIN_ID, USDC_BASE - - -def get_usdc_domain_name(network: str) -> str: - """ - Get the EIP-712 domain name for USDC on a given network. - - Mainnet USDC uses "USD Coin", testnet USDC uses "USDC". - - Args: - network: Network identifier in EIP-155 format - - Returns: - The EIP-712 domain name for signing - """ - if network == "eip155:84532" or network == "base-sepolia": - return "USDC" - return "USD Coin" - - -def create_nonce() -> str: - """Generate a random bytes32 nonce.""" - return "0x" + secrets.token_hex(32) - - -def create_payment_payload( - account: Account, - recipient: str, - amount: str, # In micro USDC (6 decimals) - network: str = "eip155:8453", - resource_url: str = "https://blockrun.ai/api/v1/chat/completions", - resource_description: str = "BlockRun AI API call", - max_timeout_seconds: int = 300, - extra: Optional[Dict[str, str]] = None, - extensions: Optional[Dict[str, Any]] = None, - asset: Optional[str] = None, -) -> str: - """ - Create a signed x402 v2 payment payload. - - This uses EIP-712 typed data signing to create a payment authorization - that the CDP facilitator can verify and settle. - - Args: - account: eth-account Account instance - recipient: Payment recipient address (checksummed) - amount: Amount in micro USDC (6 decimals, e.g., "1000" = $0.001) - network: Network identifier (e.g., "eip155:8453" for Base mainnet, "eip155:84532" for Base Sepolia) - resource_url: URL of the resource being accessed - resource_description: Description of the resource - max_timeout_seconds: Max timeout for the payment (default: 300) - extra: Extra info for USDC domain (name, version) - asset: USDC contract address (optional, derived from network if not provided) - - Returns: - Base64-encoded signed payment payload - """ - # Current timestamp - now = int(time.time()) - valid_after = now - 600 # 10 minutes before (allows for clock skew) - valid_before = now + max_timeout_seconds - - # Generate random nonce - nonce = create_nonce() - - # Get chain config based on network - chain_id, default_usdc = get_chain_config(network) - - # Use provided asset address or default for the network - usdc_address = asset or default_usdc - - # EIP-712 domain for USDC (mainnet or testnet based on network) - default_domain_name = get_usdc_domain_name(network) - domain = { - "name": extra.get("name", default_domain_name) if extra else default_domain_name, - "version": extra.get("version", "2") if extra else "2", - "chainId": chain_id, - "verifyingContract": usdc_address, - } - - # EIP-712 types for TransferWithAuthorization - types = { - "TransferWithAuthorization": [ - {"name": "from", "type": "address"}, - {"name": "to", "type": "address"}, - {"name": "value", "type": "uint256"}, - {"name": "validAfter", "type": "uint256"}, - {"name": "validBefore", "type": "uint256"}, - {"name": "nonce", "type": "bytes32"}, - ], - } - - # Message to sign - message = { - "from": account.address, - "to": recipient, - "value": int(amount), - "validAfter": valid_after, - "validBefore": valid_before, - "nonce": bytes.fromhex(nonce[2:]), # Remove 0x prefix - } - - # Sign using EIP-712 - signable = encode_typed_data(domain, types, message) - signed = account.sign_message(signable) - - # Create x402 v2 payment payload - payment_data = { - "x402Version": 2, - "resource": { - "url": resource_url, - "description": resource_description, - "mimeType": "application/json", - }, - "accepted": { - "scheme": "exact", - "network": network, - "amount": amount, - "asset": usdc_address, - "payTo": recipient, - "maxTimeoutSeconds": max_timeout_seconds, - "extra": extra or {"name": default_domain_name, "version": "2"}, - }, - "payload": { - "signature": ( - "0x" + signed.signature.hex() - if not signed.signature.hex().startswith("0x") - else signed.signature.hex() - ), - "authorization": { - "from": account.address, - "to": recipient, - "value": amount, - "validAfter": str(valid_after), - "validBefore": str(valid_before), - "nonce": nonce, - }, - }, - "extensions": with_builder_code_service_code(extensions), - } - - # Encode as base64 - return base64.b64encode(json.dumps(payment_data).encode()).decode() - - -def parse_payment_required(header_value: str) -> Dict[str, Any]: - """ - Parse the X-Payment-Required header from a 402 response. - - Args: - header_value: Base64-encoded payment requirements - - Returns: - Decoded payment requirements dict - """ - try: - decoded = base64.b64decode(header_value) - return json.loads(decoded) - except Exception: - # Don't expose internal error details - raise ValueError("Failed to parse payment required header: invalid format") - - -def extract_payment_details(payment_required: Dict[str, Any]) -> Dict[str, Any]: - """ - Extract payment details from parsed payment required response. - - Supports both v1 and v2 formats. - - Args: - payment_required: Parsed payment required dict - - Returns: - Dict with amount, recipient, network, asset, and extra info - """ - accepts = payment_required.get("accepts", []) - if not accepts: - raise ValueError("No payment options in payment required response") - - # Take the first option - option = accepts[0] - - # Support both v1 (maxAmountRequired) and v2 (amount) formats - amount = option.get("amount") or option.get("maxAmountRequired") - if not amount: - raise ValueError("No amount found in payment requirements") - - return { - "amount": amount, - "recipient": option.get("payTo"), - "network": option.get("network"), - "asset": option.get("asset"), - "scheme": option.get("scheme"), - "maxTimeoutSeconds": option.get("maxTimeoutSeconds", 300), - "extra": option.get("extra"), - "resource": payment_required.get("resource"), - } - - -# ============================================================ -# Solana x402 Payment — delegated to official x402 SDK -# ============================================================ -# The Solana payment implementation has been replaced by the -# official x402 Python SDK (pip install x402[svm]). -# See solana_client.py for usage. - - -def is_solana_network(network: str) -> bool: - """Check if a network string represents Solana.""" - return network.startswith("solana:") +""" +x402 Payment Protocol v2 Implementation for BlockRun. + +This module handles creating signed payment payloads for the x402 v2 protocol. +The private key is used ONLY for local signing and NEVER leaves the client. +""" + +import json +import time +import base64 +import secrets +from typing import Dict, Any, Optional +from eth_account import Account +from eth_account.messages import encode_typed_data + + +# Chain and token constants for mainnet +BASE_CHAIN_ID = 8453 +USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" + +# Chain and token constants for testnet (Base Sepolia) +BASE_SEPOLIA_CHAIN_ID = 84532 +USDC_BASE_SEPOLIA = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" + + +# BlockRun's x402 builder code — the ERC-8021 Schema 2 service code (`s`) that +# tags every payment this SDK signs as BlockRun-originated for on-chain +# attribution. See https://docs.cdp.coinbase.com/x402/core-concepts/builder-codes +BLOCKRUN_SERVICE_CODE = "blockrun" + + +def with_builder_code_service_code( + extensions: Optional[Dict[str, Any]], +) -> Dict[str, Any]: + """Merge BlockRun's service code (``s``) into the payload's ``builder-code`` + extension, preserving any app code (``a``) the server echoed back in its 402. + + The CDP facilitator reads ``builder-code.info.s`` and encodes it into the + settlement calldata suffix — no CBOR/encoding happens client-side. + """ + merged: Dict[str, Any] = dict(extensions or {}) + existing = dict(merged.get("builder-code") or {}) + info = dict(existing.get("info") or {}) + info["s"] = [BLOCKRUN_SERVICE_CODE] + existing["info"] = info + merged["builder-code"] = existing + return merged + + +def get_chain_config(network: str) -> tuple[int, str]: + """ + Get chain ID and USDC contract address for a given network. + + Args: + network: Network identifier in EIP-155 format (e.g., "eip155:8453" or "eip155:84532") + + Returns: + Tuple of (chain_id, usdc_address) + """ + if network == "eip155:84532" or network == "base-sepolia": + return BASE_SEPOLIA_CHAIN_ID, USDC_BASE_SEPOLIA + # Default to mainnet + return BASE_CHAIN_ID, USDC_BASE + + +def get_usdc_domain_name(network: str) -> str: + """ + Get the EIP-712 domain name for USDC on a given network. + + Mainnet USDC uses "USD Coin", testnet USDC uses "USDC". + + Args: + network: Network identifier in EIP-155 format + + Returns: + The EIP-712 domain name for signing + """ + if network == "eip155:84532" or network == "base-sepolia": + return "USDC" + return "USD Coin" + + +def create_nonce() -> str: + """Generate a random bytes32 nonce.""" + return "0x" + secrets.token_hex(32) + + +def create_payment_payload( + account: Account, + recipient: str, + amount: str, # In micro USDC (6 decimals) + network: str = "eip155:8453", + resource_url: str = "https://blockrun.ai/api/v1/chat/completions", + resource_description: str = "BlockRun AI API call", + max_timeout_seconds: int = 300, + extra: Optional[Dict[str, str]] = None, + extensions: Optional[Dict[str, Any]] = None, + asset: Optional[str] = None, +) -> str: + """ + Create a signed x402 v2 payment payload. + + This uses EIP-712 typed data signing to create a payment authorization + that the CDP facilitator can verify and settle. + + Args: + account: eth-account Account instance + recipient: Payment recipient address (checksummed) + amount: Amount in micro USDC (6 decimals, e.g., "1000" = $0.001) + network: Network identifier (e.g., "eip155:8453" for Base mainnet, "eip155:84532" for Base Sepolia) + resource_url: URL of the resource being accessed + resource_description: Description of the resource + max_timeout_seconds: Max timeout for the payment (default: 300) + extra: Extra info for USDC domain (name, version) + asset: USDC contract address (optional, derived from network if not provided) + + Returns: + Base64-encoded signed payment payload + """ + # Current timestamp + now = int(time.time()) + valid_after = now - 600 # 10 minutes before (allows for clock skew) + valid_before = now + max_timeout_seconds + + # Generate random nonce + nonce = create_nonce() + + # Get chain config based on network + chain_id, default_usdc = get_chain_config(network) + + # Use provided asset address or default for the network + usdc_address = asset or default_usdc + + # EIP-712 domain for USDC (mainnet or testnet based on network) + default_domain_name = get_usdc_domain_name(network) + domain = { + "name": extra.get("name", default_domain_name) if extra else default_domain_name, + "version": extra.get("version", "2") if extra else "2", + "chainId": chain_id, + "verifyingContract": usdc_address, + } + + # EIP-712 types for TransferWithAuthorization + types = { + "TransferWithAuthorization": [ + {"name": "from", "type": "address"}, + {"name": "to", "type": "address"}, + {"name": "value", "type": "uint256"}, + {"name": "validAfter", "type": "uint256"}, + {"name": "validBefore", "type": "uint256"}, + {"name": "nonce", "type": "bytes32"}, + ], + } + + # Message to sign + message = { + "from": account.address, + "to": recipient, + "value": int(amount), + "validAfter": valid_after, + "validBefore": valid_before, + "nonce": bytes.fromhex(nonce[2:]), # Remove 0x prefix + } + + # Sign using EIP-712 + signable = encode_typed_data(domain, types, message) + signed = account.sign_message(signable) + + # Create x402 v2 payment payload + payment_data = { + "x402Version": 2, + "resource": { + "url": resource_url, + "description": resource_description, + "mimeType": "application/json", + }, + "accepted": { + "scheme": "exact", + "network": network, + "amount": amount, + "asset": usdc_address, + "payTo": recipient, + "maxTimeoutSeconds": max_timeout_seconds, + "extra": extra or {"name": default_domain_name, "version": "2"}, + }, + "payload": { + "signature": ( + "0x" + signed.signature.hex() + if not signed.signature.hex().startswith("0x") + else signed.signature.hex() + ), + "authorization": { + "from": account.address, + "to": recipient, + "value": amount, + "validAfter": str(valid_after), + "validBefore": str(valid_before), + "nonce": nonce, + }, + }, + "extensions": with_builder_code_service_code(extensions), + } + + # Encode as base64 + return base64.b64encode(json.dumps(payment_data).encode()).decode() + + +def parse_payment_required(header_value: str) -> Dict[str, Any]: + """ + Parse the X-Payment-Required header from a 402 response. + + Args: + header_value: Base64-encoded payment requirements + + Returns: + Decoded payment requirements dict + """ + try: + decoded = base64.b64decode(header_value) + return json.loads(decoded) + except Exception: + # Don't expose internal error details + raise ValueError("Failed to parse payment required header: invalid format") + + +def extract_payment_details(payment_required: Dict[str, Any]) -> Dict[str, Any]: + """ + Extract payment details from parsed payment required response. + + Supports both v1 and v2 formats. + + Args: + payment_required: Parsed payment required dict + + Returns: + Dict with amount, recipient, network, asset, and extra info + """ + accepts = payment_required.get("accepts", []) + if not accepts: + raise ValueError("No payment options in payment required response") + + # Take the first option + option = accepts[0] + + # Support both v1 (maxAmountRequired) and v2 (amount) formats + amount = option.get("amount") or option.get("maxAmountRequired") + if not amount: + raise ValueError("No amount found in payment requirements") + + return { + "amount": amount, + "recipient": option.get("payTo"), + "network": option.get("network"), + "asset": option.get("asset"), + "scheme": option.get("scheme"), + "maxTimeoutSeconds": option.get("maxTimeoutSeconds", 300), + "extra": option.get("extra"), + "resource": payment_required.get("resource"), + } + + +# ============================================================ +# Solana x402 Payment — delegated to official x402 SDK +# ============================================================ +# The Solana payment implementation has been replaced by the +# official x402 Python SDK (pip install x402[svm]). +# See solana_client.py for usage. + + +def is_solana_network(network: str) -> bool: + """Check if a network string represents Solana.""" + return network.startswith("solana:") diff --git a/pyproject.toml b/pyproject.toml index 9d5788c..83b9bd8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "blockrun-llm" -version = "1.8.1" +version = "1.8.2" description = "BlockRun SDK - Pay-per-request AI (LLM, Image, Video, Music, Voice) via x402 on Base and Solana" readme = "README.md" license = "MIT" diff --git a/pytest.ini b/pytest.ini index 392c6ba..81f76ce 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,16 +1,16 @@ -[pytest] -testpaths = tests -python_files = test_*.py -python_classes = Test* -python_functions = test_* -addopts = - -v - --strict-markers - --tb=short -markers = - integration: Integration tests requiring API access and funded wallet - unit: Unit tests (run by default) - asyncio: Async tests using pytest-asyncio - -# Async test configuration -asyncio_mode = auto +[pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = + -v + --strict-markers + --tb=short +markers = + integration: Integration tests requiring API access and funded wallet + unit: Unit tests (run by default) + asyncio: Async tests using pytest-asyncio + +# Async test configuration +asyncio_mode = auto diff --git a/tests/__init__.py b/tests/__init__.py index ec7d357..005b3b8 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1 @@ -"""Tests for BlockRun LLM SDK.""" +"""Tests for BlockRun LLM SDK.""" diff --git a/tests/helpers.py b/tests/helpers.py index 0f15580..c394c78 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,151 +1,151 @@ -""" -Test utilities and mock builders for BlockRun LLM SDK tests. -""" - -import json -import base64 -from typing import Dict, Any, Optional -from eth_account import Account - -# Test private key (DO NOT use in production) -# This is a well-known test key from Hardhat/Foundry -TEST_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" - -# Test account derived from TEST_PRIVATE_KEY -# Address: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 -TEST_ACCOUNT = Account.from_key(TEST_PRIVATE_KEY) - -# Test recipient address for payment mocks -TEST_RECIPIENT = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" - - -def build_payment_required_response( - amount: str = "1000000", - recipient: str = TEST_RECIPIENT, - network: str = "eip155:8453", - resource: Optional[Dict[str, str]] = None, -) -> str: - """Build a mock 402 Payment Required response.""" - payment_required = { - "x402Version": 2, - "accepts": [ - { - "scheme": "exact", - "network": network, - "amount": amount, - "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", - "payTo": recipient, - "maxTimeoutSeconds": 300, - "extra": {"name": "USD Coin", "version": "2"}, - } - ], - "resource": resource - or { - "url": "https://api.blockrun.ai/v1/chat/completions", - "description": "BlockRun AI API call", - }, - } - - return base64.b64encode(json.dumps(payment_required).encode()).decode() - - -def build_chat_response( - content: str = "This is a test response.", - model: str = "gpt-5.2", - prompt_tokens: int = 10, - completion_tokens: int = 20, -) -> Dict[str, Any]: - """Build a mock successful chat response.""" - return { - "id": "chatcmpl-test123", - "object": "chat.completion", - "created": 1234567890, - "model": model, - "choices": [ - { - "index": 0, - "message": {"role": "assistant", "content": content}, - "finish_reason": "stop", - } - ], - "usage": { - "prompt_tokens": prompt_tokens, - "completion_tokens": completion_tokens, - "total_tokens": prompt_tokens + completion_tokens, - }, - } - - -def build_error_response( - error: str = "Test error message", - code: str = "test_error", - include_sensitive: bool = True, -) -> Dict[str, Any]: - """Build a mock error response.""" - response = {"error": error, "code": code} - - if include_sensitive: - # These should be filtered out by sanitization - response.update( - { - "internal_stack": "/var/app/handler.py:123", - "api_key": "secret_key_should_be_filtered", - "database_url": "postgres://user:pass@host/db", - } - ) - - return response - - -def build_models_response() -> Dict[str, Any]: - """Build a mock models list response.""" - return { - "data": [ - { - "id": "openai/gpt-5.2", - "provider": "openai", - "name": "GPT-5.2", - "inputPrice": 2.5, - "outputPrice": 10.0, - }, - { - "id": "anthropic/claude-sonnet-4.6", - "provider": "anthropic", - "name": "Claude Sonnet 4.6", - "inputPrice": 3.0, - "outputPrice": 15.0, - }, - { - "id": "google/gemini-2.5-flash", - "provider": "google", - "name": "Gemini 2.5 Flash", - "inputPrice": 0.15, - "outputPrice": 0.6, - }, - ] - } - - -class MockResponse: - """Mock HTTP response for testing.""" - - def __init__( - self, - status_code: int, - json_data: Optional[Dict[str, Any]] = None, - text_data: Optional[str] = None, - headers: Optional[Dict[str, str]] = None, - ): - self.status_code = status_code - self._json_data = json_data - self._text_data = text_data or (json.dumps(json_data) if json_data else "") - self.headers = headers or {} - - def json(self) -> Dict[str, Any]: - if self._json_data is None: - raise ValueError("No JSON data available") - return self._json_data - - @property - def text(self) -> str: - return self._text_data +""" +Test utilities and mock builders for BlockRun LLM SDK tests. +""" + +import json +import base64 +from typing import Dict, Any, Optional +from eth_account import Account + +# Test private key (DO NOT use in production) +# This is a well-known test key from Hardhat/Foundry +TEST_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + +# Test account derived from TEST_PRIVATE_KEY +# Address: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 +TEST_ACCOUNT = Account.from_key(TEST_PRIVATE_KEY) + +# Test recipient address for payment mocks +TEST_RECIPIENT = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" + + +def build_payment_required_response( + amount: str = "1000000", + recipient: str = TEST_RECIPIENT, + network: str = "eip155:8453", + resource: Optional[Dict[str, str]] = None, +) -> str: + """Build a mock 402 Payment Required response.""" + payment_required = { + "x402Version": 2, + "accepts": [ + { + "scheme": "exact", + "network": network, + "amount": amount, + "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "payTo": recipient, + "maxTimeoutSeconds": 300, + "extra": {"name": "USD Coin", "version": "2"}, + } + ], + "resource": resource + or { + "url": "https://api.blockrun.ai/v1/chat/completions", + "description": "BlockRun AI API call", + }, + } + + return base64.b64encode(json.dumps(payment_required).encode()).decode() + + +def build_chat_response( + content: str = "This is a test response.", + model: str = "gpt-5.2", + prompt_tokens: int = 10, + completion_tokens: int = 20, +) -> Dict[str, Any]: + """Build a mock successful chat response.""" + return { + "id": "chatcmpl-test123", + "object": "chat.completion", + "created": 1234567890, + "model": model, + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": content}, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "total_tokens": prompt_tokens + completion_tokens, + }, + } + + +def build_error_response( + error: str = "Test error message", + code: str = "test_error", + include_sensitive: bool = True, +) -> Dict[str, Any]: + """Build a mock error response.""" + response = {"error": error, "code": code} + + if include_sensitive: + # These should be filtered out by sanitization + response.update( + { + "internal_stack": "/var/app/handler.py:123", + "api_key": "secret_key_should_be_filtered", + "database_url": "postgres://user:pass@host/db", + } + ) + + return response + + +def build_models_response() -> Dict[str, Any]: + """Build a mock models list response.""" + return { + "data": [ + { + "id": "openai/gpt-5.2", + "provider": "openai", + "name": "GPT-5.2", + "inputPrice": 2.5, + "outputPrice": 10.0, + }, + { + "id": "anthropic/claude-sonnet-4.6", + "provider": "anthropic", + "name": "Claude Sonnet 4.6", + "inputPrice": 3.0, + "outputPrice": 15.0, + }, + { + "id": "google/gemini-2.5-flash", + "provider": "google", + "name": "Gemini 2.5 Flash", + "inputPrice": 0.15, + "outputPrice": 0.6, + }, + ] + } + + +class MockResponse: + """Mock HTTP response for testing.""" + + def __init__( + self, + status_code: int, + json_data: Optional[Dict[str, Any]] = None, + text_data: Optional[str] = None, + headers: Optional[Dict[str, str]] = None, + ): + self.status_code = status_code + self._json_data = json_data + self._text_data = text_data or (json.dumps(json_data) if json_data else "") + self.headers = headers or {} + + def json(self) -> Dict[str, Any]: + if self._json_data is None: + raise ValueError("No JSON data available") + return self._json_data + + @property + def text(self) -> str: + return self._text_data diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py index 98e40eb..9b84820 100644 --- a/tests/integration/__init__.py +++ b/tests/integration/__init__.py @@ -1 +1 @@ -"""Integration tests for BlockRun LLM SDK.""" +"""Integration tests for BlockRun LLM SDK.""" diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 082ccb4..bf40f6f 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,26 +1,26 @@ -"""Pytest configuration for integration tests.""" - -import os -import pytest - - -def pytest_configure(config): - """Configure pytest with custom markers.""" - config.addinivalue_line( - "markers", "integration: Integration tests requiring funded wallet and API access" - ) - - -@pytest.fixture(scope="session") -def wallet_private_key(): - """Get wallet private key from environment variable. - - Returns None if not set, which will cause integration tests to be skipped. - """ - return os.environ.get("BASE_CHAIN_WALLET_KEY") - - -@pytest.fixture(scope="session") -def production_api_url(): - """Get production API URL.""" - return "https://blockrun.ai/api" +"""Pytest configuration for integration tests.""" + +import os +import pytest + + +def pytest_configure(config): + """Configure pytest with custom markers.""" + config.addinivalue_line( + "markers", "integration: Integration tests requiring funded wallet and API access" + ) + + +@pytest.fixture(scope="session") +def wallet_private_key(): + """Get wallet private key from environment variable. + + Returns None if not set, which will cause integration tests to be skipped. + """ + return os.environ.get("BASE_CHAIN_WALLET_KEY") + + +@pytest.fixture(scope="session") +def production_api_url(): + """Get production API URL.""" + return "https://blockrun.ai/api" diff --git a/tests/integration/test_production_api.py b/tests/integration/test_production_api.py index 07e5d9d..88feba2 100644 --- a/tests/integration/test_production_api.py +++ b/tests/integration/test_production_api.py @@ -1,315 +1,315 @@ -"""Integration tests for BlockRun LLM SDK against production API. - -Requirements: -- BASE_CHAIN_WALLET_KEY environment variable with funded Base wallet -- Minimum $1 USDC on Base chain -- Estimated cost per test run: ~$0.05 - -Run with: pytest tests/integration -Skip if no wallet: Tests will be skipped if BASE_CHAIN_WALLET_KEY not set -""" - -import asyncio -import os -import time - -import pytest -from blockrun_llm import LLMClient, AsyncLLMClient - -WALLET_KEY = os.environ.get("BASE_CHAIN_WALLET_KEY") -PRODUCTION_API = "https://blockrun.ai/api" - -# Skip all tests if no wallet key configured -pytestmark = pytest.mark.skipif( - not WALLET_KEY, reason="BASE_CHAIN_WALLET_KEY environment variable not set" -) - - -class TestProductionAPISync: - """Integration tests for synchronous LLMClient against production API.""" - - @pytest.fixture(scope="class") - def client(self): - """Create LLMClient instance for testing.""" - if not WALLET_KEY: - pytest.skip("BASE_CHAIN_WALLET_KEY not set") - - client = LLMClient(private_key=WALLET_KEY, api_url=PRODUCTION_API) - - print("\n🧪 Running sync integration tests against production API") - print(f" Wallet: {client.get_wallet_address()}") - print(f" API: {PRODUCTION_API}") - print(" Estimated cost: ~$0.05\n") - - return client - - def test_list_models(self, client): - """Should list available models from production API.""" - models = client.list_models() - - assert models is not None - assert isinstance(models, list) - assert len(models) > 0 - - # Verify model structure - first_model = models[0] - assert "id" in first_model - assert "provider" in first_model - assert "inputPrice" in first_model - assert "outputPrice" in first_model - - print(f" ✓ Found {len(models)} models") - - # Respect rate limits - time.sleep(2) - - def test_simple_chat_request(self, client): - """Should complete a simple chat request.""" - # Use cheapest model for testing - response = client.chat( - "google/gemini-2.5-flash-lite", - [{"role": "user", "content": "Say 'test passed' and nothing else"}], - ) - - assert response is not None - assert isinstance(response, str) - assert "test passed" in response.lower() - - print(f" ✓ Chat response: {response[:50]}...") - - time.sleep(2) - - def test_chat_completion_with_usage_stats(self, client): - """Should return chat completion with usage stats.""" - completion = client.chat_completion( - "google/gemini-2.5-flash-lite", - [{"role": "user", "content": "Count to 5"}], - max_tokens=50, - ) - - assert completion is not None - assert "choices" in completion - assert len(completion["choices"]) > 0 - assert "message" in completion["choices"][0] - assert "content" in completion["choices"][0]["message"] - assert completion["choices"][0]["message"]["content"] - - # Verify usage stats - assert "usage" in completion - assert completion["usage"]["prompt_tokens"] > 0 - assert completion["usage"]["completion_tokens"] > 0 - assert completion["usage"]["total_tokens"] > 0 - - print(f" ✓ Completion with usage: {completion['usage']}") - - time.sleep(2) - - def test_payment_flow_end_to_end(self, client): - """Should handle 402 payment flow end-to-end. - - This test verifies the full x402 payment protocol: - 1. Request to API - 2. Receive 402 with payment required - 3. Create payment payload with EIP-712 signature - 4. Retry with payment receipt - 5. Receive successful response - """ - response = client.chat( - "google/gemini-2.5-flash-lite", [{"role": "user", "content": "What is 2+2?"}] - ) - - # If we got a response, the payment flow succeeded - assert response is not None - assert isinstance(response, str) - assert response - - print(" ✓ Payment flow successful, response received") - - time.sleep(2) - - -class TestProductionAPIAsync: - """Integration tests for asynchronous AsyncLLMClient against production API.""" - - @pytest.fixture(scope="class") - async def async_client(self): - """Create AsyncLLMClient instance for testing.""" - if not WALLET_KEY: - pytest.skip("BASE_CHAIN_WALLET_KEY not set") - - client = AsyncLLMClient(private_key=WALLET_KEY, api_url=PRODUCTION_API) - - print("\n🧪 Running async integration tests against production API") - print(f" Wallet: {client.get_wallet_address()}") - print(f" API: {PRODUCTION_API}") - print(" Estimated cost: ~$0.05\n") - - return client - - @pytest.mark.asyncio - async def test_async_list_models(self, async_client): - """Should list available models asynchronously.""" - models = await async_client.list_models() - - assert models is not None - assert isinstance(models, list) - assert len(models) > 0 - - print(f" ✓ Async: Found {len(models)} models") - - await asyncio.sleep(2) - - @pytest.mark.asyncio - async def test_async_simple_chat(self, async_client): - """Should complete a simple chat request asynchronously.""" - response = await async_client.chat( - "google/gemini-2.5-flash-lite", - [{"role": "user", "content": "Say 'async test passed' and nothing else"}], - ) - - assert response is not None - assert isinstance(response, str) - assert "test passed" in response.lower() - - print(f" ✓ Async chat response: {response[:50]}...") - - await asyncio.sleep(2) - - @pytest.mark.asyncio - async def test_async_chat_completion(self, async_client): - """Should return chat completion with usage stats asynchronously.""" - completion = await async_client.chat_completion( - "google/gemini-2.5-flash-lite", - [{"role": "user", "content": "Count to 5"}], - max_tokens=50, - ) - - assert completion is not None - assert "choices" in completion - assert len(completion["choices"]) > 0 - assert "usage" in completion - assert completion["usage"]["total_tokens"] > 0 - - print(f" ✓ Async completion with usage: {completion['usage']}") - - await asyncio.sleep(2) - - -class TestProductionAPIErrorHandling: - """Integration tests for error handling against production API.""" - - @pytest.fixture(scope="class") - def client(self): - """Create LLMClient instance for testing.""" - if not WALLET_KEY: - pytest.skip("BASE_CHAIN_WALLET_KEY not set") - - return LLMClient(private_key=WALLET_KEY, api_url=PRODUCTION_API) - - def test_invalid_model_error(self, client): - """Should handle invalid model error gracefully.""" - from blockrun_llm import APIError - - with pytest.raises(APIError): - client.chat( - "invalid-model-that-does-not-exist", - [{"role": "user", "content": "test"}], - ) - - print(" ✓ Invalid model error handled correctly") - - time.sleep(2) - - def test_error_response_sanitization(self, client): - """Should sanitize error responses.""" - from blockrun_llm import APIError - - try: - client.chat("invalid-model", [{"role": "user", "content": "test"}]) - pytest.fail("Should have raised APIError") - except APIError as e: - # Error should be sanitized (no internal stack traces, API keys, etc.) - assert e.message is not None - assert "/var/" not in str(e.message) - assert ( - "internal" not in str(e.message).lower() or "internal" in str(e.message).lower() - ) # Allow "internal" in error message but not internal paths - assert "stack" not in str(e.message).lower() - - print(" ✓ Error response properly sanitized") - - time.sleep(2) - - -# ============================================================================= -# Solana + Exa Integration Tests -# ============================================================================= - -SOLANA_WALLET_KEY = os.environ.get("SOLANA_WALLET_KEY") -SOLANA_API = "https://sol.blockrun.ai/api" - - -class TestSolanaExa: - """Integration tests for Exa web search via SolanaLLMClient.""" - - @pytest.fixture(scope="class") - def client(self): - if not SOLANA_WALLET_KEY: - pytest.skip("SOLANA_WALLET_KEY not set") - from blockrun_llm import SolanaLLMClient - - c = SolanaLLMClient(private_key=SOLANA_WALLET_KEY, api_url=SOLANA_API) - print("\n🧪 Running Solana/Exa integration tests against sol.blockrun.ai") - print(f" Wallet: {c.get_wallet_address()}") - print(" Estimated cost: ~$0.04\n") - return c - - def test_exa_search(self, client): - """exa_search returns results with title/url fields.""" - result = client.exa_search("latest AI safety research", numResults=3) - assert "results" in result, f"Expected 'results' key, got: {list(result.keys())}" - assert len(result["results"]) > 0 - first = result["results"][0] - assert "url" in first or "title" in first - cost = client.get_spending()["total_usd"] - assert 0.009 <= cost <= 0.011, f"Expected ~$0.01 cost, got {cost}" - print(f" ✓ exa_search: {len(result['results'])} results, cost=${cost:.4f}") - time.sleep(1) - - def test_exa_find_similar(self, client): - """exa_find_similar returns semantically similar pages.""" - result = client.exa_find_similar("https://openai.com/research/gpt-4", numResults=3) - assert "results" in result - assert len(result["results"]) > 0 - print(f" ✓ exa_find_similar: {len(result['results'])} results") - time.sleep(1) - - def test_exa_contents(self, client): - """exa_contents extracts text from a URL, priced per URL.""" - result = client.exa_contents(["https://www.anthropic.com/research"]) - assert result is not None - assert isinstance(result, dict) - print(" ✓ exa_contents: response received") - time.sleep(1) - - def test_exa_answer(self, client): - """exa_answer returns an AI-generated answer from live web.""" - result = client.exa_answer("What is Anthropic Claude?") - assert result is not None - assert isinstance(result, dict) - print(" ✓ exa_answer: response received") - time.sleep(1) - - def test_exa_generic_proxy(self, client): - """exa() generic proxy works for any endpoint.""" - result = client.exa("search", {"query": "blockrun.ai", "numResults": 2}) - assert "results" in result - print(f" ✓ exa() generic: {len(result['results'])} results") - time.sleep(1) - - def test_exa_spending_tracked(self, client): - """Session spending is tracked across Exa calls.""" - spending = client.get_spending() - assert spending["total_usd"] > 0 - assert spending["calls"] >= 3 - print(f" ✓ Spending tracked: ${spending['total_usd']:.4f} over {spending['calls']} calls") +"""Integration tests for BlockRun LLM SDK against production API. + +Requirements: +- BASE_CHAIN_WALLET_KEY environment variable with funded Base wallet +- Minimum $1 USDC on Base chain +- Estimated cost per test run: ~$0.05 + +Run with: pytest tests/integration +Skip if no wallet: Tests will be skipped if BASE_CHAIN_WALLET_KEY not set +""" + +import asyncio +import os +import time + +import pytest +from blockrun_llm import LLMClient, AsyncLLMClient + +WALLET_KEY = os.environ.get("BASE_CHAIN_WALLET_KEY") +PRODUCTION_API = "https://blockrun.ai/api" + +# Skip all tests if no wallet key configured +pytestmark = pytest.mark.skipif( + not WALLET_KEY, reason="BASE_CHAIN_WALLET_KEY environment variable not set" +) + + +class TestProductionAPISync: + """Integration tests for synchronous LLMClient against production API.""" + + @pytest.fixture(scope="class") + def client(self): + """Create LLMClient instance for testing.""" + if not WALLET_KEY: + pytest.skip("BASE_CHAIN_WALLET_KEY not set") + + client = LLMClient(private_key=WALLET_KEY, api_url=PRODUCTION_API) + + print("\n🧪 Running sync integration tests against production API") + print(f" Wallet: {client.get_wallet_address()}") + print(f" API: {PRODUCTION_API}") + print(" Estimated cost: ~$0.05\n") + + return client + + def test_list_models(self, client): + """Should list available models from production API.""" + models = client.list_models() + + assert models is not None + assert isinstance(models, list) + assert len(models) > 0 + + # Verify model structure + first_model = models[0] + assert "id" in first_model + assert "provider" in first_model + assert "inputPrice" in first_model + assert "outputPrice" in first_model + + print(f" ✓ Found {len(models)} models") + + # Respect rate limits + time.sleep(2) + + def test_simple_chat_request(self, client): + """Should complete a simple chat request.""" + # Use cheapest model for testing + response = client.chat( + "google/gemini-2.5-flash-lite", + [{"role": "user", "content": "Say 'test passed' and nothing else"}], + ) + + assert response is not None + assert isinstance(response, str) + assert "test passed" in response.lower() + + print(f" ✓ Chat response: {response[:50]}...") + + time.sleep(2) + + def test_chat_completion_with_usage_stats(self, client): + """Should return chat completion with usage stats.""" + completion = client.chat_completion( + "google/gemini-2.5-flash-lite", + [{"role": "user", "content": "Count to 5"}], + max_tokens=50, + ) + + assert completion is not None + assert "choices" in completion + assert len(completion["choices"]) > 0 + assert "message" in completion["choices"][0] + assert "content" in completion["choices"][0]["message"] + assert completion["choices"][0]["message"]["content"] + + # Verify usage stats + assert "usage" in completion + assert completion["usage"]["prompt_tokens"] > 0 + assert completion["usage"]["completion_tokens"] > 0 + assert completion["usage"]["total_tokens"] > 0 + + print(f" ✓ Completion with usage: {completion['usage']}") + + time.sleep(2) + + def test_payment_flow_end_to_end(self, client): + """Should handle 402 payment flow end-to-end. + + This test verifies the full x402 payment protocol: + 1. Request to API + 2. Receive 402 with payment required + 3. Create payment payload with EIP-712 signature + 4. Retry with payment receipt + 5. Receive successful response + """ + response = client.chat( + "google/gemini-2.5-flash-lite", [{"role": "user", "content": "What is 2+2?"}] + ) + + # If we got a response, the payment flow succeeded + assert response is not None + assert isinstance(response, str) + assert response + + print(" ✓ Payment flow successful, response received") + + time.sleep(2) + + +class TestProductionAPIAsync: + """Integration tests for asynchronous AsyncLLMClient against production API.""" + + @pytest.fixture(scope="class") + async def async_client(self): + """Create AsyncLLMClient instance for testing.""" + if not WALLET_KEY: + pytest.skip("BASE_CHAIN_WALLET_KEY not set") + + client = AsyncLLMClient(private_key=WALLET_KEY, api_url=PRODUCTION_API) + + print("\n🧪 Running async integration tests against production API") + print(f" Wallet: {client.get_wallet_address()}") + print(f" API: {PRODUCTION_API}") + print(" Estimated cost: ~$0.05\n") + + return client + + @pytest.mark.asyncio + async def test_async_list_models(self, async_client): + """Should list available models asynchronously.""" + models = await async_client.list_models() + + assert models is not None + assert isinstance(models, list) + assert len(models) > 0 + + print(f" ✓ Async: Found {len(models)} models") + + await asyncio.sleep(2) + + @pytest.mark.asyncio + async def test_async_simple_chat(self, async_client): + """Should complete a simple chat request asynchronously.""" + response = await async_client.chat( + "google/gemini-2.5-flash-lite", + [{"role": "user", "content": "Say 'async test passed' and nothing else"}], + ) + + assert response is not None + assert isinstance(response, str) + assert "test passed" in response.lower() + + print(f" ✓ Async chat response: {response[:50]}...") + + await asyncio.sleep(2) + + @pytest.mark.asyncio + async def test_async_chat_completion(self, async_client): + """Should return chat completion with usage stats asynchronously.""" + completion = await async_client.chat_completion( + "google/gemini-2.5-flash-lite", + [{"role": "user", "content": "Count to 5"}], + max_tokens=50, + ) + + assert completion is not None + assert "choices" in completion + assert len(completion["choices"]) > 0 + assert "usage" in completion + assert completion["usage"]["total_tokens"] > 0 + + print(f" ✓ Async completion with usage: {completion['usage']}") + + await asyncio.sleep(2) + + +class TestProductionAPIErrorHandling: + """Integration tests for error handling against production API.""" + + @pytest.fixture(scope="class") + def client(self): + """Create LLMClient instance for testing.""" + if not WALLET_KEY: + pytest.skip("BASE_CHAIN_WALLET_KEY not set") + + return LLMClient(private_key=WALLET_KEY, api_url=PRODUCTION_API) + + def test_invalid_model_error(self, client): + """Should handle invalid model error gracefully.""" + from blockrun_llm import APIError + + with pytest.raises(APIError): + client.chat( + "invalid-model-that-does-not-exist", + [{"role": "user", "content": "test"}], + ) + + print(" ✓ Invalid model error handled correctly") + + time.sleep(2) + + def test_error_response_sanitization(self, client): + """Should sanitize error responses.""" + from blockrun_llm import APIError + + try: + client.chat("invalid-model", [{"role": "user", "content": "test"}]) + pytest.fail("Should have raised APIError") + except APIError as e: + # Error should be sanitized (no internal stack traces, API keys, etc.) + assert e.message is not None + assert "/var/" not in str(e.message) + assert ( + "internal" not in str(e.message).lower() or "internal" in str(e.message).lower() + ) # Allow "internal" in error message but not internal paths + assert "stack" not in str(e.message).lower() + + print(" ✓ Error response properly sanitized") + + time.sleep(2) + + +# ============================================================================= +# Solana + Exa Integration Tests +# ============================================================================= + +SOLANA_WALLET_KEY = os.environ.get("SOLANA_WALLET_KEY") +SOLANA_API = "https://sol.blockrun.ai/api" + + +class TestSolanaExa: + """Integration tests for Exa web search via SolanaLLMClient.""" + + @pytest.fixture(scope="class") + def client(self): + if not SOLANA_WALLET_KEY: + pytest.skip("SOLANA_WALLET_KEY not set") + from blockrun_llm import SolanaLLMClient + + c = SolanaLLMClient(private_key=SOLANA_WALLET_KEY, api_url=SOLANA_API) + print("\n🧪 Running Solana/Exa integration tests against sol.blockrun.ai") + print(f" Wallet: {c.get_wallet_address()}") + print(" Estimated cost: ~$0.04\n") + return c + + def test_exa_search(self, client): + """exa_search returns results with title/url fields.""" + result = client.exa_search("latest AI safety research", numResults=3) + assert "results" in result, f"Expected 'results' key, got: {list(result.keys())}" + assert len(result["results"]) > 0 + first = result["results"][0] + assert "url" in first or "title" in first + cost = client.get_spending()["total_usd"] + assert 0.009 <= cost <= 0.011, f"Expected ~$0.01 cost, got {cost}" + print(f" ✓ exa_search: {len(result['results'])} results, cost=${cost:.4f}") + time.sleep(1) + + def test_exa_find_similar(self, client): + """exa_find_similar returns semantically similar pages.""" + result = client.exa_find_similar("https://openai.com/research/gpt-4", numResults=3) + assert "results" in result + assert len(result["results"]) > 0 + print(f" ✓ exa_find_similar: {len(result['results'])} results") + time.sleep(1) + + def test_exa_contents(self, client): + """exa_contents extracts text from a URL, priced per URL.""" + result = client.exa_contents(["https://www.anthropic.com/research"]) + assert result is not None + assert isinstance(result, dict) + print(" ✓ exa_contents: response received") + time.sleep(1) + + def test_exa_answer(self, client): + """exa_answer returns an AI-generated answer from live web.""" + result = client.exa_answer("What is Anthropic Claude?") + assert result is not None + assert isinstance(result, dict) + print(" ✓ exa_answer: response received") + time.sleep(1) + + def test_exa_generic_proxy(self, client): + """exa() generic proxy works for any endpoint.""" + result = client.exa("search", {"query": "blockrun.ai", "numResults": 2}) + assert "results" in result + print(f" ✓ exa() generic: {len(result['results'])} results") + time.sleep(1) + + def test_exa_spending_tracked(self, client): + """Session spending is tracked across Exa calls.""" + spending = client.get_spending() + assert spending["total_usd"] > 0 + assert spending["calls"] >= 3 + print(f" ✓ Spending tracked: ${spending['total_usd']:.4f} over {spending['calls']} calls") diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py index ceb7f13..0360524 100644 --- a/tests/unit/__init__.py +++ b/tests/unit/__init__.py @@ -1 +1 @@ -"""Unit tests for BlockRun LLM SDK.""" +"""Unit tests for BlockRun LLM SDK.""" diff --git a/tests/unit/test_settled_payment.py b/tests/unit/test_settled_payment.py new file mode 100644 index 0000000..871d30e --- /dev/null +++ b/tests/unit/test_settled_payment.py @@ -0,0 +1,373 @@ +"""Tests for the settled-payment boundary and the gateway clamp warning. + +Both mechanisms exist to protect money, and both were shipped without coverage. +The rule they encode: signing is settlement, so exactly one PAYMENT-SIGNATURE +leaves the process per user-initiated call, no matter how the paid leg fails. +""" + +import httpx +import pytest + +from blockrun_llm import LLMClient +from blockrun_llm.client import ( + _SETTLED_ATTR, + _mark_settled, + _should_fallback, + _warn_if_clamped, +) +from blockrun_llm.types import APIError, PaymentError + +from ..helpers import ( + TEST_PRIVATE_KEY, + build_chat_response, + build_payment_required_response, +) + + +def _client(handler) -> LLMClient: + client = LLMClient(private_key=TEST_PRIVATE_KEY) + client._client = httpx.Client(transport=httpx.MockTransport(handler)) + return client + + +class TestSettledTagClassification: + def test_untagged_timeout_still_falls_back(self): + """An unpaid timeout is a genuine transient failure; keep retrying.""" + assert _should_fallback(httpx.ReadTimeout("boom")) is True + + def test_untagged_503_still_falls_back(self): + assert _should_fallback(APIError("upstream", 503, None)) is True + + def test_settled_timeout_does_not_fall_back(self): + assert _should_fallback(_mark_settled(httpx.ReadTimeout("boom"))) is False + + def test_settled_network_error_does_not_fall_back(self): + assert _should_fallback(_mark_settled(httpx.ConnectError("boom"))) is False + + def test_settled_5xx_does_not_fall_back(self): + """The dominant post-settlement failure. Tagging only timeouts left + this open, so the six-settlement path survived the first fix.""" + assert _should_fallback(_mark_settled(APIError("upstream", 503, None))) is False + + def test_mark_settled_preserves_identity_and_type(self): + exc = httpx.ReadTimeout("boom") + assert _mark_settled(exc) is exc + with pytest.raises(httpx.TimeoutException): + raise exc + + +class TestTagScope: + """What must NOT be tagged, driven through the real client. The handlers + were once `except Exception`, which labeled rejected payments and SDK bugs + as settled payments. These fail if the handlers widen again.""" + + def test_payment_rejection_is_not_tagged_as_settled(self): + """A paid-leg 402 means the facilitator refused; the funds did not + move. Calling that 'settled' is exactly backwards.""" + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 402, + json={"error": "Payment Required"}, + headers={"payment-required": build_payment_required_response()}, + ) + + client = _client(handler) + with pytest.raises(PaymentError) as exc: + client.chat_completion("a/b", [{"role": "user", "content": "hi"}]) + assert getattr(exc.value, _SETTLED_ATTR, False) is False + + def test_programming_error_in_paid_leg_is_not_tagged(self, monkeypatch): + """An AttributeError raised after the paid response is an SDK bug. It + must propagate as itself, not as a settled-payment failure.""" + + def handler(request: httpx.Request) -> httpx.Response: + if "PAYMENT-SIGNATURE" in request.headers: + return httpx.Response(200, json=build_chat_response()) + return httpx.Response( + 402, + json={"error": "Payment Required"}, + headers={"payment-required": build_payment_required_response()}, + ) + + client = _client(handler) + + def boom(_response): + raise AttributeError("SDK bug in settlement capture") + + monkeypatch.setattr(client, "_capture_settlement", boom) + + with pytest.raises(AttributeError) as exc: + client.chat_completion("a/b", [{"role": "user", "content": "hi"}]) + assert getattr(exc.value, _SETTLED_ATTR, False) is False + + def test_tagged_types_are_a_superset_of_fallback_eligible(self): + """The handlers catch `(httpx.HTTPError, APIError)`. That must cover + everything _should_fallback says yes to, or a settled failure escapes + untagged and the chain pays again.""" + assert issubclass(httpx.TimeoutException, httpx.HTTPError) + assert issubclass(httpx.NetworkError, httpx.HTTPError) + assert not issubclass(PaymentError, APIError) + + def test_tagging_preserves_traceback_and_context(self): + """Handlers re-raise bare rather than `from None`, so an opaque wrapper + error keeps the cause that explains it.""" + try: + try: + raise ValueError("underlying base64 failure") + except ValueError: + raise APIError("invalid format", 500, None) + except APIError as outer: + _mark_settled(outer) + assert isinstance(outer.__context__, ValueError) + assert outer.__suppress_context__ is False + + +class TestNoSecondSettlement: + """One user-initiated call must never settle more than once.""" + + def _paid_leg_fails(self, failure): + # Count distinct signatures, not signed requests. The paid leg retries + # 502/503 once with the SAME PAYMENT-SIGNATURE, which is one settlement + # replayed, not a second charge. A new signature is a new settlement. + signed = set() + + def handler(request: httpx.Request) -> httpx.Response: + if "PAYMENT-SIGNATURE" in request.headers: + signed.add(request.headers["PAYMENT-SIGNATURE"]) + return failure() + return httpx.Response( + 402, + json={"error": "Payment Required"}, + headers={"payment-required": build_payment_required_response()}, + ) + + return signed, handler + + def test_timeout_after_payment_does_not_pay_the_next_model(self): + def fail(): + raise httpx.ReadTimeout("upstream hung after settlement") + + signed, handler = self._paid_leg_fails(fail) + client = _client(handler) + + with pytest.raises(httpx.TimeoutException): + client.chat_completion( + "primary/slow", + [{"role": "user", "content": "hi"}], + fallback_models=["fallback/good", "fallback/other"], + ) + assert len(signed) == 1, f"settled {len(signed)} times for one call" + + def test_paid_5xx_does_not_pay_the_next_model(self, monkeypatch): + """Regression: a 402-then-503 chain across 3 models signed six payments + and returned nothing.""" + monkeypatch.setattr("time.sleep", lambda _s: None) + signed, handler = self._paid_leg_fails( + lambda: httpx.Response(503, json={"error": "upstream down"}) + ) + client = _client(handler) + + with pytest.raises(APIError): + client.chat_completion( + "a/b", + [{"role": "user", "content": "hi"}], + fallback_models=["c/d", "e/f"], + ) + assert len(signed) == 1, f"settled {len(signed)} times for one call" + + def test_unpaid_failure_still_walks_the_chain(self): + """The guard must not disable legitimate free retries: if nothing was + signed, falling back costs the caller nothing.""" + seen = [] + + def handler(request: httpx.Request) -> httpx.Response: + import json as _json + + model = _json.loads(request.read())["model"] + seen.append(model) + if model == "primary/bad": + return httpx.Response(503, json={"error": "down"}) + return httpx.Response(200, json=build_chat_response()) + + client = _client(handler) + client.chat_completion( + "primary/bad", + [{"role": "user", "content": "hi"}], + fallback_models=["fallback/good"], + ) + # primary appears twice: the unpaid leg retries 502/503 once before the + # chain advances. What matters is that it advanced at all. + assert "fallback/good" in seen + + +class TestPaidStreamCleanup: + """Extracting the paid phase into its own generator changed who is + responsible for closing it.""" + + def test_abandoned_async_paid_stream_closes_its_inner_generator(self): + """Drives the real AsyncLLMClient. `async for` does not aclose the inner + generator when the outer is closed, so the paid + `async with self._client.stream(...)` would stay suspended and hold the + connection until GC finalization. Fails if the explicit + `finally: await paid.aclose()` is removed. + """ + import asyncio + + from blockrun_llm import AsyncLLMClient + + closed = [] + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 402, + json={"error": "Payment Required"}, + headers={"payment-required": build_payment_required_response()}, + ) + + async def run(): + client = AsyncLLMClient(private_key=TEST_PRIVATE_KEY) + client._client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + + async def fake_paid_phase(*_a, **_kw): + try: + yield {"chunk": 1} + yield {"chunk": 2} + finally: + closed.append(True) + + client._astream_paid_phase = fake_paid_phase + + stream = client.chat_completion_stream("a/b", [{"role": "user", "content": "hi"}]) + assert await stream.__anext__() == {"chunk": 1} + # Abandon mid-stream, exactly like `break` in a caller's loop. + await stream.aclose() + # Assert HERE, not after asyncio.run(). Without the explicit + # aclose, CPython's asyncgen finalizer still closes the inner + # generator eventually during loop teardown — which is precisely + # the "connection held until GC" behavior being fixed. Only a + # synchronous check at the close point tells the two apart. + return list(closed) + + assert asyncio.run(run()) == [True], "inner paid generator was not closed at aclose()" + + def test_sync_delegation_closes_via_yield_from(self): + """The sync path gets this for free, which is why only the async path + needed the explicit close. Recorded so nobody 'fixes' it symmetrically.""" + closed = [] + + def inner(): + try: + yield 1 + yield 2 + finally: + closed.append(True) + + def outer(): + yield from inner() + + gen = outer() + assert next(gen) == 1 + gen.close() + assert closed == [True] + + +class TestWarnIfClamped: + def test_warns_when_quoted_below_requested(self, capsys): + _warn_if_clamped( + {"model": "claude-opus-4.8", "max_tokens": 262144}, + "claude-opus-4.8 chat completion, 128000 max output tokens", + ) + err = capsys.readouterr().err + assert "clamped" in err and "262144" in err and "128000" in err + + def test_parses_comma_grouped_ceiling(self, capsys): + _warn_if_clamped({"max_tokens": 200000}, "gpt-5.5, 128,000 max output tokens") + assert "128000" in capsys.readouterr().err + + def test_silent_when_quoted_meets_the_request(self, capsys): + _warn_if_clamped({"max_tokens": 128000}, "128000 max output tokens") + _warn_if_clamped({"max_tokens": 1000}, "128000 max output tokens") + assert capsys.readouterr().err == "" + + def test_silent_when_no_ceiling_in_description(self, capsys): + _warn_if_clamped({"max_tokens": 999999}, "BlockRun AI API call") + _warn_if_clamped({"max_tokens": 999999}, None) + _warn_if_clamped({"max_tokens": 999999}, "") + assert capsys.readouterr().err == "" + + def test_bool_is_not_a_token_count(self, capsys): + _warn_if_clamped({"max_tokens": True}, "1 max output tokens") + assert capsys.readouterr().err == "" + + def test_non_string_description_does_not_raise(self, capsys): + """The field is server-controlled and JSON allows anything. A warning + must never be the reason a paid request fails.""" + _warn_if_clamped({"max_tokens": 100}, {"nested": "128 max output tokens"}) + _warn_if_clamped({"max_tokens": 100}, 12345) + assert capsys.readouterr().err == "" + + def test_ambiguous_description_stays_silent(self, capsys): + """A per-unit rate is not a ceiling. Two candidates means the format is + not what we think it is, so say nothing.""" + _warn_if_clamped( + {"max_tokens": 4096, "model": "m"}, + "$0.002 per 1000 max output tokens, 128000 max output tokens", + ) + assert capsys.readouterr().err == "" + + def test_long_description_does_not_hang(self, capsys): + """Outer defense: the scan limit caps what reaches the regex at all.""" + import time + + start = time.perf_counter() + _warn_if_clamped({"max_tokens": 100}, "9" * 100_000) + assert time.perf_counter() - start < 1.0 + assert capsys.readouterr().err == "" + + def test_pattern_itself_is_not_backtracking(self): + """Inner defense, pinned separately so removing the scan limit cannot + silently reintroduce the ReDoS. The old `(\\d[\\d,]*)` pattern took + ~1.95s on this input; the bounded one takes ~0.0002s. + """ + import time + + from blockrun_llm.client import _QUOTED_MAX_TOKENS_RE + + start = time.perf_counter() + _QUOTED_MAX_TOKENS_RE.search("9" * 16_000) + assert time.perf_counter() - start < 0.05 + + def test_pattern_still_matches_the_real_shapes(self): + from blockrun_llm.client import _QUOTED_MAX_TOKENS_RE + + for text, expected in ( + ("claude-opus-4.8 - 128000 max output tokens", "128000"), + ("gpt-5.5, 128,000 max output tokens", "128,000"), + ("262144 MAX OUTPUT TOKENS", "262144"), + ): + assert _QUOTED_MAX_TOKENS_RE.findall(text) == [expected], text + + def test_warning_fires_on_the_real_402_leg(self, capsys): + def handler(request: httpx.Request) -> httpx.Response: + if "PAYMENT-SIGNATURE" in request.headers: + return httpx.Response(200, json=build_chat_response()) + return httpx.Response( + 402, + json={"error": "Payment Required"}, + headers={ + "payment-required": build_payment_required_response( + resource={ + "url": "https://blockrun.ai/api/v1/chat/completions", + "description": "claude-opus-4.8 - 128000 max output tokens", + } + ) + }, + ) + + _client(handler).chat_completion( + "claude-opus-4.8", + [{"role": "user", "content": "hi"}], + max_tokens=262144, + ) + assert "max_tokens clamped" in capsys.readouterr().err diff --git a/tests/unit/test_solana_settled_payment.py b/tests/unit/test_solana_settled_payment.py new file mode 100644 index 0000000..1d4d000 --- /dev/null +++ b/tests/unit/test_solana_settled_payment.py @@ -0,0 +1,65 @@ +"""Solana half of the settled-payment guard. + +Signing is settlement on either chain. The Base client learned not to let the +fallback chain buy a retry after a payment went out; this pins the same rule for +Solana, where the transfer is SPL USDC. + +Guarded with importorskip: the 3.9 CI job installs the SDK without the solana +extra, and an unguarded Solana test file turns that job red (see #19/#20). +""" + +from __future__ import annotations + +import httpx +import pytest + +pytest.importorskip("x402") +pytest.importorskip("solders") + +from blockrun_llm.client import _mark_settled # noqa: E402 +from blockrun_llm.solana_client import _should_fallback_solana # noqa: E402 +from blockrun_llm.types import APIError, PaymentError # noqa: E402 + + +class TestSolanaSettledTag: + """Mirrors TestSettledTagClassification in test_settled_payment.py.""" + + def test_untagged_timeout_still_falls_back(self): + assert _should_fallback_solana(httpx.ReadTimeout("boom")) is True + + def test_untagged_503_still_falls_back(self): + assert _should_fallback_solana(APIError("upstream", 503, None)) is True + + def test_settled_timeout_does_not_fall_back(self): + assert _should_fallback_solana(_mark_settled(httpx.ReadTimeout("boom"))) is False + + def test_settled_network_error_does_not_fall_back(self): + assert _should_fallback_solana(_mark_settled(httpx.ConnectError("boom"))) is False + + def test_settled_5xx_does_not_fall_back(self): + """The dominant post-settlement failure, and the one the Base fix + originally missed.""" + assert _should_fallback_solana(_mark_settled(APIError("upstream", 503, None))) is False + + def test_payment_error_still_refused(self): + """Pre-existing behavior must survive the new first check.""" + assert _should_fallback_solana(PaymentError("insufficient balance")) is False + + def test_permanent_payment_reason_still_refused(self): + """The issue #6 guard: a transient type carrying a permanent reason.""" + assert _should_fallback_solana(httpx.ReadTimeout("transaction_simulation_failed")) is False + + def test_both_chains_agree_on_the_tag(self): + """The tag has to mean the same thing in both fallback chains, or one + of them keeps paying twice.""" + from blockrun_llm.client import _should_fallback + + for exc in ( + httpx.ReadTimeout("boom"), + httpx.ConnectError("boom"), + APIError("upstream", 503, None), + ): + assert _should_fallback(exc) is True + assert _should_fallback_solana(exc) is True + assert _should_fallback(_mark_settled(exc)) is False + assert _should_fallback_solana(_mark_settled(exc)) is False diff --git a/tests/unit/test_solana_wallet.py b/tests/unit/test_solana_wallet.py index b05b461..ab47e2b 100644 --- a/tests/unit/test_solana_wallet.py +++ b/tests/unit/test_solana_wallet.py @@ -1,50 +1,50 @@ -"""Unit tests for Solana wallet utilities.""" - -import pytest -from blockrun_llm.solana_wallet import ( - create_solana_wallet, - solana_key_to_bytes, - get_solana_public_key, -) - -# A valid test bs58 secret key (64 bytes, valid keypair from deterministic seed) -TEST_BS58_KEY = ( - "433C7KFcM4y1ZEVdZYSH7wheSNAM384UcbgXEyD5FV7Q2HsQ1BwjEDx4GbBZUqPkZTVhFPyLyuZnzK8wCeAkU7wG" -) - - -class TestCreateSolanaWallet: - def test_returns_address_and_key(self): - wallet = create_solana_wallet() - assert "address" in wallet - assert "private_key" in wallet - assert len(wallet["address"]) >= 32 # base58 pubkey - assert len(wallet["private_key"]) >= 86 # bs58 64-byte key - - def test_unique_wallets(self): - w1 = create_solana_wallet() - w2 = create_solana_wallet() - assert w1["address"] != w2["address"] - assert w1["private_key"] != w2["private_key"] - - -class TestSolanaKeyToBytes: - def test_valid_key(self): - b = solana_key_to_bytes(TEST_BS58_KEY) - assert isinstance(b, bytes) - assert len(b) == 64 - - def test_invalid_key_raises(self): - with pytest.raises(ValueError, match="Invalid Solana private key"): - solana_key_to_bytes("not-a-valid-key!!!") - - -class TestGetSolanaPublicKey: - def test_returns_base58_address(self): - addr = get_solana_public_key(TEST_BS58_KEY) - assert isinstance(addr, str) - assert len(addr) >= 32 - # Should be valid base58 (only alphanumeric, no 0/O/I/l) - import re - - assert re.match(r"^[1-9A-HJ-NP-Za-km-z]+$", addr) +"""Unit tests for Solana wallet utilities.""" + +import pytest +from blockrun_llm.solana_wallet import ( + create_solana_wallet, + solana_key_to_bytes, + get_solana_public_key, +) + +# A valid test bs58 secret key (64 bytes, valid keypair from deterministic seed) +TEST_BS58_KEY = ( + "433C7KFcM4y1ZEVdZYSH7wheSNAM384UcbgXEyD5FV7Q2HsQ1BwjEDx4GbBZUqPkZTVhFPyLyuZnzK8wCeAkU7wG" +) + + +class TestCreateSolanaWallet: + def test_returns_address_and_key(self): + wallet = create_solana_wallet() + assert "address" in wallet + assert "private_key" in wallet + assert len(wallet["address"]) >= 32 # base58 pubkey + assert len(wallet["private_key"]) >= 86 # bs58 64-byte key + + def test_unique_wallets(self): + w1 = create_solana_wallet() + w2 = create_solana_wallet() + assert w1["address"] != w2["address"] + assert w1["private_key"] != w2["private_key"] + + +class TestSolanaKeyToBytes: + def test_valid_key(self): + b = solana_key_to_bytes(TEST_BS58_KEY) + assert isinstance(b, bytes) + assert len(b) == 64 + + def test_invalid_key_raises(self): + with pytest.raises(ValueError, match="Invalid Solana private key"): + solana_key_to_bytes("not-a-valid-key!!!") + + +class TestGetSolanaPublicKey: + def test_returns_base58_address(self): + addr = get_solana_public_key(TEST_BS58_KEY) + assert isinstance(addr, str) + assert len(addr) >= 32 + # Should be valid base58 (only alphanumeric, no 0/O/I/l) + import re + + assert re.match(r"^[1-9A-HJ-NP-Za-km-z]+$", addr) diff --git a/tests/unit/test_x402.py b/tests/unit/test_x402.py index 7665c3d..a93ac25 100644 --- a/tests/unit/test_x402.py +++ b/tests/unit/test_x402.py @@ -1,317 +1,317 @@ -"""Unit tests for x402 payment protocol.""" - -import pytest -import base64 -import json -from blockrun_llm.x402 import ( - create_nonce, - create_payment_payload, - parse_payment_required, - extract_payment_details, -) -from ..helpers import TEST_ACCOUNT, TEST_RECIPIENT - - -class TestCreateNonce: - def test_nonce_format(self): - """Should generate nonce with correct format.""" - nonce = create_nonce() - - assert nonce.startswith("0x") - assert len(nonce) == 66 # 0x + 64 hex chars - - def test_nonce_uniqueness(self): - """Should generate unique nonces.""" - nonce1 = create_nonce() - nonce2 = create_nonce() - - assert nonce1 != nonce2 - - def test_nonce_is_hex(self): - """Should contain only hex characters.""" - nonce = create_nonce() - # Remove 0x prefix and check if valid hex - hex_part = nonce[2:] - assert all(c in "0123456789abcdef" for c in hex_part.lower()) - - -class TestCreatePaymentPayload: - def test_create_valid_payload(self): - """Should create valid payment payload.""" - payload = create_payment_payload( - account=TEST_ACCOUNT, - recipient=TEST_RECIPIENT, - amount="1000000", - network="eip155:8453", - ) - - assert isinstance(payload, str) - - # Decode and verify structure - decoded = json.loads(base64.b64decode(payload)) - assert decoded["x402Version"] == 2 - assert "payload" in decoded - assert "signature" in decoded["payload"] - assert decoded["payload"]["signature"].startswith("0x") - - def test_payload_includes_authorization(self): - """Should include authorization details.""" - payload = create_payment_payload( - account=TEST_ACCOUNT, - recipient=TEST_RECIPIENT, - amount="1000000", - ) - - decoded = json.loads(base64.b64decode(payload)) - auth = decoded["payload"]["authorization"] - - assert auth["from"] == TEST_ACCOUNT.address - assert auth["to"] == TEST_RECIPIENT - assert auth["value"] == "1000000" - assert "validAfter" in auth - assert "validBefore" in auth - assert "nonce" in auth - - def test_payload_attaches_builder_code_service_code(self): - """Should tag every payment with the BlockRun service code (s).""" - payload = create_payment_payload( - account=TEST_ACCOUNT, - recipient=TEST_RECIPIENT, - amount="1000000", - ) - - decoded = json.loads(base64.b64decode(payload)) - assert decoded["extensions"]["builder-code"]["info"]["s"] == ["blockrun"] - - def test_payload_preserves_echoed_app_code(self): - """Should keep the server-echoed app code (a) when adding service code (s).""" - payload = create_payment_payload( - account=TEST_ACCOUNT, - recipient=TEST_RECIPIENT, - amount="1000000", - extensions={"builder-code": {"info": {"a": "blockrun"}}}, - ) - - decoded = json.loads(base64.b64decode(payload)) - info = decoded["extensions"]["builder-code"]["info"] - assert info["a"] == "blockrun" - assert info["s"] == ["blockrun"] - - def test_payload_includes_resource_info(self): - """Should include resource information.""" - payload = create_payment_payload( - account=TEST_ACCOUNT, - recipient=TEST_RECIPIENT, - amount="1000000", - resource_url="https://api.blockrun.ai/v1/test", - resource_description="Test Resource", - ) - - decoded = json.loads(base64.b64decode(payload)) - assert decoded["resource"]["url"] == "https://api.blockrun.ai/v1/test" - assert decoded["resource"]["description"] == "Test Resource" - - def test_payload_time_windows(self): - """Should set valid time windows.""" - import time - - before = int(time.time()) - payload = create_payment_payload( - account=TEST_ACCOUNT, recipient=TEST_RECIPIENT, amount="1000000" - ) - after = int(time.time()) - - decoded = json.loads(base64.b64decode(payload)) - auth = decoded["payload"]["authorization"] - - # Valid after should be in the past (allows clock skew) - assert int(auth["validAfter"]) < before - - # Valid before should be in the future - assert int(auth["validBefore"]) > after - - def test_custom_timeout(self): - """Should use custom max timeout.""" - payload = create_payment_payload( - account=TEST_ACCOUNT, - recipient=TEST_RECIPIENT, - amount="1000000", - max_timeout_seconds=600, - ) - - decoded = json.loads(base64.b64decode(payload)) - assert decoded["accepted"]["maxTimeoutSeconds"] == 600 - - -class TestParsePaymentRequired: - def test_parse_valid_header(self): - """Should parse valid payment required header.""" - data = { - "x402Version": 2, - "accepts": [ - { - "scheme": "exact", - "network": "eip155:8453", - "amount": "1000000", - "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", - "payTo": TEST_RECIPIENT, - "maxTimeoutSeconds": 300, - } - ], - } - - encoded = base64.b64encode(json.dumps(data).encode()).decode() - result = parse_payment_required(encoded) - - assert result["x402Version"] == 2 - assert len(result["accepts"]) == 1 - - def test_invalid_base64(self): - """Should raise ValueError on invalid base64.""" - with pytest.raises(ValueError, match="invalid format"): - parse_payment_required("invalid!!!") - - def test_invalid_json(self): - """Should raise ValueError on invalid JSON.""" - with pytest.raises(ValueError, match="invalid format"): - parse_payment_required(base64.b64encode(b"not json").decode()) - - -class TestExtractPaymentDetails: - def test_extract_details(self): - """Should extract payment details.""" - payment_required = { - "x402Version": 2, - "accepts": [ - { - "scheme": "exact", - "network": "eip155:8453", - "amount": "1000000", - "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", - "payTo": TEST_RECIPIENT, - "maxTimeoutSeconds": 300, - } - ], - } - - details = extract_payment_details(payment_required) - - assert details["amount"] == "1000000" - assert details["recipient"] == TEST_RECIPIENT - assert details["network"] == "eip155:8453" - assert details["maxTimeoutSeconds"] == 300 - - def test_empty_accepts(self): - """Should raise ValueError on empty accepts.""" - with pytest.raises(ValueError, match="No payment options"): - extract_payment_details({"x402Version": 2, "accepts": []}) - - def test_default_timeout(self): - """Should use default timeout if not specified.""" - payment_required = { - "x402Version": 2, - "accepts": [ - { - "scheme": "exact", - "network": "eip155:8453", - "amount": "1000000", - "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", - "payTo": TEST_RECIPIENT, - # maxTimeoutSeconds not specified - } - ], - } - - details = extract_payment_details(payment_required) - assert details["maxTimeoutSeconds"] == 300 - - def test_include_resource(self): - """Should include resource if present.""" - payment_required = { - "x402Version": 2, - "accepts": [ - { - "scheme": "exact", - "network": "eip155:8453", - "amount": "1000000", - "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", - "payTo": TEST_RECIPIENT, - } - ], - "resource": { - "url": "https://api.blockrun.ai/test", - "description": "Test", - }, - } - - details = extract_payment_details(payment_required) - assert details["resource"]["url"] == "https://api.blockrun.ai/test" - - -class TestSolanaX402SdkIntegration: - """Tests for Solana x402 SDK integration.""" - - USDC_SOLANA = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" - TOKEN_PROGRAM_ID = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" - TEST_FEE_PAYER = "2wKupLR9q6wXYppw8Gr2NvWxKBUqm4PPJKkQfoxHDBg4" - TEST_SOL_RECIPIENT = "AQqnMFBwGZEoti85aTVRy8XYpKrho7GaMDx9ZB3CEeKA" - - def test_decode_solana_payment_required(self): - """Should decode a Solana 402 PaymentRequired header.""" - from x402.http.utils import decode_payment_required_header - - data = { - "x402Version": 2, - "accepts": [ - { - "scheme": "exact", - "network": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", - "amount": "1000", - "asset": self.USDC_SOLANA, - "payTo": self.TEST_SOL_RECIPIENT, - "maxTimeoutSeconds": 300, - "extra": {"feePayer": self.TEST_FEE_PAYER}, - } - ], - } - encoded = base64.b64encode(json.dumps(data).encode()).decode() - result = decode_payment_required_header(encoded) - - assert result.x402_version == 2 - assert len(result.accepts) == 1 - assert str(result.accepts[0].network).startswith("solana:") - assert result.accepts[0].pay_to == self.TEST_SOL_RECIPIENT - assert result.accepts[0].amount == "1000" - assert result.accepts[0].extra["feePayer"] == self.TEST_FEE_PAYER - - def test_keypair_signer_address(self): - """KeypairSigner should derive correct public key from bs58 secret.""" - from x402.mechanisms.svm import KeypairSigner - from solders.keypair import Keypair - - # Generate a valid keypair and get its base58 representation - kp = Keypair() - expected_address = str(kp.pubkey()) - - signer = KeypairSigner.from_base58(str(kp)) - assert signer.address == expected_address - - def test_ata_derivation_uses_correct_program_id(self): - """ATA derivation must use the correct Associated Token Program ID.""" - from x402.mechanisms.svm import derive_ata - - # Known wallet -> known USDC ATA (verified on-chain) - owner = "CtJTYWPQSL5jw9B2JRHmpQjYCSSgUX3LRvmMBhq55HmQ" - expected_ata = "HZPPxg9ZyoHu4f2pj5uEEXsArLA2rnL9FtDgC8rrAp5Q" - - result = derive_ata(owner, self.USDC_SOLANA, self.TOKEN_PROGRAM_ID) - assert result == expected_ata - - def test_is_solana_network(self): - """Should correctly identify Solana networks.""" - from blockrun_llm.x402 import is_solana_network - - assert is_solana_network("solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp") - assert is_solana_network("solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1") - assert not is_solana_network("eip155:8453") - assert not is_solana_network("base-sepolia") +"""Unit tests for x402 payment protocol.""" + +import pytest +import base64 +import json +from blockrun_llm.x402 import ( + create_nonce, + create_payment_payload, + parse_payment_required, + extract_payment_details, +) +from ..helpers import TEST_ACCOUNT, TEST_RECIPIENT + + +class TestCreateNonce: + def test_nonce_format(self): + """Should generate nonce with correct format.""" + nonce = create_nonce() + + assert nonce.startswith("0x") + assert len(nonce) == 66 # 0x + 64 hex chars + + def test_nonce_uniqueness(self): + """Should generate unique nonces.""" + nonce1 = create_nonce() + nonce2 = create_nonce() + + assert nonce1 != nonce2 + + def test_nonce_is_hex(self): + """Should contain only hex characters.""" + nonce = create_nonce() + # Remove 0x prefix and check if valid hex + hex_part = nonce[2:] + assert all(c in "0123456789abcdef" for c in hex_part.lower()) + + +class TestCreatePaymentPayload: + def test_create_valid_payload(self): + """Should create valid payment payload.""" + payload = create_payment_payload( + account=TEST_ACCOUNT, + recipient=TEST_RECIPIENT, + amount="1000000", + network="eip155:8453", + ) + + assert isinstance(payload, str) + + # Decode and verify structure + decoded = json.loads(base64.b64decode(payload)) + assert decoded["x402Version"] == 2 + assert "payload" in decoded + assert "signature" in decoded["payload"] + assert decoded["payload"]["signature"].startswith("0x") + + def test_payload_includes_authorization(self): + """Should include authorization details.""" + payload = create_payment_payload( + account=TEST_ACCOUNT, + recipient=TEST_RECIPIENT, + amount="1000000", + ) + + decoded = json.loads(base64.b64decode(payload)) + auth = decoded["payload"]["authorization"] + + assert auth["from"] == TEST_ACCOUNT.address + assert auth["to"] == TEST_RECIPIENT + assert auth["value"] == "1000000" + assert "validAfter" in auth + assert "validBefore" in auth + assert "nonce" in auth + + def test_payload_attaches_builder_code_service_code(self): + """Should tag every payment with the BlockRun service code (s).""" + payload = create_payment_payload( + account=TEST_ACCOUNT, + recipient=TEST_RECIPIENT, + amount="1000000", + ) + + decoded = json.loads(base64.b64decode(payload)) + assert decoded["extensions"]["builder-code"]["info"]["s"] == ["blockrun"] + + def test_payload_preserves_echoed_app_code(self): + """Should keep the server-echoed app code (a) when adding service code (s).""" + payload = create_payment_payload( + account=TEST_ACCOUNT, + recipient=TEST_RECIPIENT, + amount="1000000", + extensions={"builder-code": {"info": {"a": "blockrun"}}}, + ) + + decoded = json.loads(base64.b64decode(payload)) + info = decoded["extensions"]["builder-code"]["info"] + assert info["a"] == "blockrun" + assert info["s"] == ["blockrun"] + + def test_payload_includes_resource_info(self): + """Should include resource information.""" + payload = create_payment_payload( + account=TEST_ACCOUNT, + recipient=TEST_RECIPIENT, + amount="1000000", + resource_url="https://api.blockrun.ai/v1/test", + resource_description="Test Resource", + ) + + decoded = json.loads(base64.b64decode(payload)) + assert decoded["resource"]["url"] == "https://api.blockrun.ai/v1/test" + assert decoded["resource"]["description"] == "Test Resource" + + def test_payload_time_windows(self): + """Should set valid time windows.""" + import time + + before = int(time.time()) + payload = create_payment_payload( + account=TEST_ACCOUNT, recipient=TEST_RECIPIENT, amount="1000000" + ) + after = int(time.time()) + + decoded = json.loads(base64.b64decode(payload)) + auth = decoded["payload"]["authorization"] + + # Valid after should be in the past (allows clock skew) + assert int(auth["validAfter"]) < before + + # Valid before should be in the future + assert int(auth["validBefore"]) > after + + def test_custom_timeout(self): + """Should use custom max timeout.""" + payload = create_payment_payload( + account=TEST_ACCOUNT, + recipient=TEST_RECIPIENT, + amount="1000000", + max_timeout_seconds=600, + ) + + decoded = json.loads(base64.b64decode(payload)) + assert decoded["accepted"]["maxTimeoutSeconds"] == 600 + + +class TestParsePaymentRequired: + def test_parse_valid_header(self): + """Should parse valid payment required header.""" + data = { + "x402Version": 2, + "accepts": [ + { + "scheme": "exact", + "network": "eip155:8453", + "amount": "1000000", + "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "payTo": TEST_RECIPIENT, + "maxTimeoutSeconds": 300, + } + ], + } + + encoded = base64.b64encode(json.dumps(data).encode()).decode() + result = parse_payment_required(encoded) + + assert result["x402Version"] == 2 + assert len(result["accepts"]) == 1 + + def test_invalid_base64(self): + """Should raise ValueError on invalid base64.""" + with pytest.raises(ValueError, match="invalid format"): + parse_payment_required("invalid!!!") + + def test_invalid_json(self): + """Should raise ValueError on invalid JSON.""" + with pytest.raises(ValueError, match="invalid format"): + parse_payment_required(base64.b64encode(b"not json").decode()) + + +class TestExtractPaymentDetails: + def test_extract_details(self): + """Should extract payment details.""" + payment_required = { + "x402Version": 2, + "accepts": [ + { + "scheme": "exact", + "network": "eip155:8453", + "amount": "1000000", + "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "payTo": TEST_RECIPIENT, + "maxTimeoutSeconds": 300, + } + ], + } + + details = extract_payment_details(payment_required) + + assert details["amount"] == "1000000" + assert details["recipient"] == TEST_RECIPIENT + assert details["network"] == "eip155:8453" + assert details["maxTimeoutSeconds"] == 300 + + def test_empty_accepts(self): + """Should raise ValueError on empty accepts.""" + with pytest.raises(ValueError, match="No payment options"): + extract_payment_details({"x402Version": 2, "accepts": []}) + + def test_default_timeout(self): + """Should use default timeout if not specified.""" + payment_required = { + "x402Version": 2, + "accepts": [ + { + "scheme": "exact", + "network": "eip155:8453", + "amount": "1000000", + "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "payTo": TEST_RECIPIENT, + # maxTimeoutSeconds not specified + } + ], + } + + details = extract_payment_details(payment_required) + assert details["maxTimeoutSeconds"] == 300 + + def test_include_resource(self): + """Should include resource if present.""" + payment_required = { + "x402Version": 2, + "accepts": [ + { + "scheme": "exact", + "network": "eip155:8453", + "amount": "1000000", + "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "payTo": TEST_RECIPIENT, + } + ], + "resource": { + "url": "https://api.blockrun.ai/test", + "description": "Test", + }, + } + + details = extract_payment_details(payment_required) + assert details["resource"]["url"] == "https://api.blockrun.ai/test" + + +class TestSolanaX402SdkIntegration: + """Tests for Solana x402 SDK integration.""" + + USDC_SOLANA = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" + TOKEN_PROGRAM_ID = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + TEST_FEE_PAYER = "2wKupLR9q6wXYppw8Gr2NvWxKBUqm4PPJKkQfoxHDBg4" + TEST_SOL_RECIPIENT = "AQqnMFBwGZEoti85aTVRy8XYpKrho7GaMDx9ZB3CEeKA" + + def test_decode_solana_payment_required(self): + """Should decode a Solana 402 PaymentRequired header.""" + from x402.http.utils import decode_payment_required_header + + data = { + "x402Version": 2, + "accepts": [ + { + "scheme": "exact", + "network": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "amount": "1000", + "asset": self.USDC_SOLANA, + "payTo": self.TEST_SOL_RECIPIENT, + "maxTimeoutSeconds": 300, + "extra": {"feePayer": self.TEST_FEE_PAYER}, + } + ], + } + encoded = base64.b64encode(json.dumps(data).encode()).decode() + result = decode_payment_required_header(encoded) + + assert result.x402_version == 2 + assert len(result.accepts) == 1 + assert str(result.accepts[0].network).startswith("solana:") + assert result.accepts[0].pay_to == self.TEST_SOL_RECIPIENT + assert result.accepts[0].amount == "1000" + assert result.accepts[0].extra["feePayer"] == self.TEST_FEE_PAYER + + def test_keypair_signer_address(self): + """KeypairSigner should derive correct public key from bs58 secret.""" + from x402.mechanisms.svm import KeypairSigner + from solders.keypair import Keypair + + # Generate a valid keypair and get its base58 representation + kp = Keypair() + expected_address = str(kp.pubkey()) + + signer = KeypairSigner.from_base58(str(kp)) + assert signer.address == expected_address + + def test_ata_derivation_uses_correct_program_id(self): + """ATA derivation must use the correct Associated Token Program ID.""" + from x402.mechanisms.svm import derive_ata + + # Known wallet -> known USDC ATA (verified on-chain) + owner = "CtJTYWPQSL5jw9B2JRHmpQjYCSSgUX3LRvmMBhq55HmQ" + expected_ata = "HZPPxg9ZyoHu4f2pj5uEEXsArLA2rnL9FtDgC8rrAp5Q" + + result = derive_ata(owner, self.USDC_SOLANA, self.TOKEN_PROGRAM_ID) + assert result == expected_ata + + def test_is_solana_network(self): + """Should correctly identify Solana networks.""" + from blockrun_llm.x402 import is_solana_network + + assert is_solana_network("solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp") + assert is_solana_network("solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1") + assert not is_solana_network("eip155:8453") + assert not is_solana_network("base-sepolia")