Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions blockrun_llm/solana_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:
Expand Down
13 changes: 13 additions & 0 deletions blockrun_llm/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
129 changes: 129 additions & 0 deletions tests/unit/test_invalid_message_fail_fast.py
Original file line number Diff line number Diff line change
@@ -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
Loading