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..9aa29fb --- /dev/null +++ b/tests/unit/test_invalid_message_fail_fast.py @@ -0,0 +1,129 @@ +"""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