From df339d461d4ab594e290a84af7d38616cec13dcb Mon Sep 17 00:00:00 2001 From: 1bcMax Date: Wed, 15 Jul 2026 11:32:31 -0500 Subject: [PATCH 1/2] fix(solana): fail fast when the payer has no USDC token account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to blockrun-sol's invalidMessage classification. The gateway now returns the x402 facilitator's `invalidMessage` next to the coarse `invalidReason`; read it and stop retrying payments that can never pass. A wallet whose USDC token account was never created fails simulation with InvalidAccountData, which invalidReason collapses to transaction_simulation_failed. That pattern is deliberately absent from _UNRECOVERABLE_PAYMENT_PATTERNS because it usually IS recoverable under concurrent load — so the retry meant for load spikes fired on a wallet with no funds, burning all 5 attempts. Each of those cost the gateway its own 4 verify retries: one request became up to 20 facilitator calls, and one broke wallet looked like a ~260-attempt client-side storm. build_payment_rejected_error folds invalidMessage into the error message (the classifiers only ever see str(exc)) and keeps it on .response. Bounded to 256 chars, matching the existing `details` handling — same provenance, a facilitator error string rather than upstream text. Matching normalizes away non-alphanumerics so InvalidAccountData, "invalid account data" and invalid_account_data all hit one pattern; the facilitator's exact spelling is not yet confirmed. Blockhash messages stay OUT of the unrecoverable list on purpose. The gateway fails fast on them because retrying the SAME dead header is futile; here the opposite holds — re-signing with a fresh blockhash is what this retry does, and it fixes them. With both halves, verify calls per doomed request go 20 → 1. --- blockrun_llm/solana_client.py | 33 ++++- blockrun_llm/validation.py | 13 ++ tests/unit/test_invalid_message_fail_fast.py | 131 +++++++++++++++++++ 3 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 tests/unit/test_invalid_message_fail_fast.py diff --git a/blockrun_llm/solana_client.py b/blockrun_llm/solana_client.py index eb69c6f..a9845bc 100644 --- a/blockrun_llm/solana_client.py +++ b/blockrun_llm/solana_client.py @@ -166,6 +166,32 @@ def _is_permanent_payment_error(reason: str) -> bool: "denied", # payer denylisted ) +# The gateway's `invalidMessage` (x402 VerifyResponse) names the simulation-level +# cause that `invalidReason` collapses into transaction_simulation_failed — which +# is deliberately absent above because it usually IS recoverable. These messages +# are the exception: the payer's USDC token account does not exist, so no fresh +# nonce/probe/blockhash will ever make the payment pass. Without them a wallet +# that can never pay burned all _MAX_PAYMENT_RETRIES + 1 attempts, every one of +# which cost the gateway its own verify retries. +# +# NOTE the asymmetry with the gateway's list (blockrun-sol x402-solana.ts): it +# ALSO fails fast on BlockhashNotFound, because retrying the SAME dead header is +# futile there. Here the opposite holds — re-signing with a FRESH blockhash is +# precisely what this retry does, and it fixes it — so blockhash messages must +# stay OUT of this list. +_UNRECOVERABLE_INVALID_MESSAGES = ( + "invalidaccountdata", + "accountnotfound", + "couldnotfindaccount", +) + + +def _normalize_reason(reason: str) -> str: + """Lowercase and strip non-alphanumerics so one pattern matches every + spelling of a cause ("InvalidAccountData", "invalid account data", + "invalid_account_data"). Mirrors NORMALIZE in blockrun-sol x402-solana.ts.""" + return re.sub(r"[^a-z0-9]", "", reason.lower()) + def _is_unrecoverable_payment_error(reason: str) -> bool: """True iff retrying with a brand-new payment cannot possibly succeed. @@ -174,12 +200,15 @@ def _is_unrecoverable_payment_error(reason: str) -> bool: :func:`_is_permanent_payment_error` (which classifies re-signing the SAME authorization), a fresh nonce/probe/blockhash recovers replay, amount- mismatch, expiry and blockhash-window failures, so only truly terminal - conditions (no funds, bad key, denylisted) short-circuit the retry. + conditions (no funds, bad key, denylisted, no token account) short-circuit + the retry. """ if not reason: return False low = reason.lower() - return any(p in low for p in _UNRECOVERABLE_PAYMENT_PATTERNS) + if any(p in low for p in _UNRECOVERABLE_PAYMENT_PATTERNS): + return True + return any(p in _normalize_reason(reason) for p in _UNRECOVERABLE_INVALID_MESSAGES) def _get_user_agent() -> str: diff --git a/blockrun_llm/validation.py b/blockrun_llm/validation.py index c9164f9..6d552ff 100644 --- a/blockrun_llm/validation.py +++ b/blockrun_llm/validation.py @@ -289,7 +289,20 @@ def build_payment_rejected_error(response: Any) -> "PaymentError": raw_details = body.get("details") if isinstance(raw_details, str) and 0 < len(raw_details) < 256: sanitized["details"] = raw_details + # The x402 facilitator's `invalidMessage` — the simulation-level cause that + # the coarse `invalidReason` enum collapses away (an unfunded wallet and a + # stale blockhash both arrive as transaction_simulation_failed). Same + # provenance and safety rationale as `details` above: a facilitator error + # string, not upstream text, so it's safe to surface verbatim — bounded + # defensively all the same. Folded into the message because the retry + # classifiers in solana_client only ever see `str(exc)`. + raw_invalid_message = body.get("invalidMessage") + if isinstance(raw_invalid_message, str) and 0 < len(raw_invalid_message) < 256: + sanitized["invalidMessage"] = raw_invalid_message detail_part = sanitized.get("details") or sanitized.get("message") or "" + invalid_message = sanitized.get("invalidMessage") + if invalid_message: + detail_part = f"{detail_part} ({invalid_message})" if detail_part else invalid_message msg = ( f"Payment rejected by gateway: {detail_part}" if detail_part diff --git a/tests/unit/test_invalid_message_fail_fast.py b/tests/unit/test_invalid_message_fail_fast.py new file mode 100644 index 0000000..2aa6721 --- /dev/null +++ b/tests/unit/test_invalid_message_fail_fast.py @@ -0,0 +1,131 @@ +"""The client half of the verify retry-storm fix (gateway side: blockrun-sol +``x402-solana.ts`` classifyInvalidMessage). + +A payer whose USDC token account was never created fails simulation with +``InvalidAccountData``. The gateway's coarse ``invalidReason`` collapses that to +``transaction_simulation_failed``, which ``_UNRECOVERABLE_PAYMENT_PATTERNS`` +deliberately omits (it IS recoverable under concurrent load) — so the SDK burned +all 5 payment attempts on a wallet that could never pay. The gateway now returns +the facilitator's ``invalidMessage`` alongside the enum; these tests pin the SDK +reading it and failing fast. +""" + +from __future__ import annotations + +from typing import Any + +from blockrun_llm.solana_client import ( + _is_unrecoverable_payment_error, + _is_permanent_payment_error, +) +from blockrun_llm.validation import build_payment_rejected_error + + +class _FakeResponse: + def __init__(self, body: Any) -> None: + self._body = body + + def json(self) -> Any: + return self._body + + +class TestInvalidMessageReachesTheClassifier: + """build_payment_rejected_error must fold invalidMessage into the message — + the classifiers only ever see ``str(exc)``.""" + + def test_invalid_message_is_surfaced_in_the_error_string(self) -> None: + exc = build_payment_rejected_error( + _FakeResponse( + { + "error": "Payment verification failed", + "code": "PAYMENT_INVALID", + "debug": "transaction_simulation_failed", + "invalidMessage": "InvalidAccountData", + } + ) + ) + assert "InvalidAccountData" in str(exc) + assert exc.response is not None + assert exc.response["invalidMessage"] == "InvalidAccountData" + + def test_absent_invalid_message_leaves_the_message_unchanged(self) -> None: + exc = build_payment_rejected_error( + _FakeResponse({"error": "Payment settlement failed", "details": "insufficient_funds"}) + ) + assert "insufficient_funds" in str(exc) + + def test_oversized_invalid_message_is_dropped(self) -> None: + exc = build_payment_rejected_error( + _FakeResponse( + {"error": "Payment verification failed", "invalidMessage": "x" * 500} + ) + ) + assert exc.response is not None + assert "invalidMessage" not in exc.response + + +class TestUnrecoverableClassification: + def test_invalid_account_data_is_unrecoverable(self) -> None: + """An unfunded wallet: no fresh nonce/blockhash can make this pass.""" + assert ( + _is_unrecoverable_payment_error( + "Payment rejected by gateway: transaction_simulation_failed (InvalidAccountData)" + ) + is True + ) + + def test_spelling_variants_all_classify(self) -> None: + for msg in ( + "invalid account data", + "invalid_account_data", + "InvalidAccountData", + "AccountNotFound", + "Error processing Instruction 0: invalid account data", + ): + assert _is_unrecoverable_payment_error( + f"Payment rejected by gateway: transaction_simulation_failed ({msg})" + ), msg + + def test_bare_simulation_failure_stays_recoverable(self) -> None: + """Without an invalidMessage we know nothing more than before — keep the + whole-request retry that exists to ride out concurrent-load failures.""" + assert ( + _is_unrecoverable_payment_error( + "Payment rejected by gateway: transaction_simulation_failed" + ) + is False + ) + + def test_blockhash_stays_recoverable_on_the_client(self) -> None: + """Deliberate asymmetry with the gateway: it stops retrying the SAME dead + header, but re-signing with a FRESH blockhash is exactly what fixes this, + and re-signing is what the SDK's whole-request retry does.""" + for msg in ("BlockhashNotFound", "BlockHeightExceeded"): + assert ( + _is_unrecoverable_payment_error( + f"Payment rejected by gateway: transaction_simulation_failed ({msg})" + ) + is False + ), msg + + def test_transient_errors_still_retry(self) -> None: + assert _is_unrecoverable_payment_error("503 Service Unavailable") is False + assert _is_unrecoverable_payment_error("") is False + + +class TestPermanentClassifierUnaffected: + """_is_permanent_payment_error governs the *fallback-model* decision and + already treats simulation/blockhash as permanent. The new patterns must not + perturb it.""" + + def test_still_permanent(self) -> None: + assert _is_permanent_payment_error("transaction_simulation_failed") is True + assert ( + _is_permanent_payment_error( + "Payment rejected by gateway: transaction_simulation_failed (InvalidAccountData)" + ) + is True + ) + + def test_still_transient(self) -> None: + assert _is_permanent_payment_error("503 Service Unavailable") is False From ccd6fc0992d5bf5e81feffc80b8a447d53f5d979 Mon Sep 17 00:00:00 2001 From: 1bcMax Date: Wed, 15 Jul 2026 11:56:22 -0500 Subject: [PATCH 2/2] style: apply black formatting to invalid-message fail-fast tests --- tests/unit/test_invalid_message_fail_fast.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/unit/test_invalid_message_fail_fast.py b/tests/unit/test_invalid_message_fail_fast.py index 2aa6721..9aa29fb 100644 --- a/tests/unit/test_invalid_message_fail_fast.py +++ b/tests/unit/test_invalid_message_fail_fast.py @@ -56,9 +56,7 @@ def test_absent_invalid_message_leaves_the_message_unchanged(self) -> None: def test_oversized_invalid_message_is_dropped(self) -> None: exc = build_payment_rejected_error( - _FakeResponse( - {"error": "Payment verification failed", "invalidMessage": "x" * 500} - ) + _FakeResponse({"error": "Payment verification failed", "invalidMessage": "x" * 500}) ) assert exc.response is not None assert "invalidMessage" not in exc.response