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
42 changes: 41 additions & 1 deletion agentscore_commerce/payment/mppx_failures.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,46 @@ class ClassifiedMppxFailure:
)


# The dangerous one. pympp verifies a transaction-payload credential by
# broadcasting it (funds move, a signature is minted) and then awaiting
# confirmation in the same verify() call, with a fixed timeout not exposed to
# callers. When confirmation times out (routine under load, since Solana status
# propagation can lag the window even on a production RPC), verify() raises on a
# transaction that MAY HAVE ALREADY LANDED. Left unclassified, that maps to the
# generic payment_proof_invalid + regenerate_payment_credential, i.e. the
# merchant tells the agent to pay AGAIN for money that already left the wallet
# (observed live 2026-08-12: an on-chain balance delta with no service
# delivered and a regenerate 402 in hand). A confirmation timeout cannot be
# reliably told apart from never-landed, so the honest response is 504 with an
# explicit do-not-blindly-resubmit instruction; 504 (unlike 402) does not
# trigger an automatic re-pay retry in x402/MPP clients.
_SOLANA_CONFIRMATION_TIMEOUT = ClassifiedMppxFailure(
code="payment_pending_confirmation",
status=504,
message=(
"Payment was submitted on-chain but its confirmation timed out. It may "
"have settled. Do NOT resubmit without checking first, or you risk "
"paying twice."
),
next_steps={
"action": "check_settlement_before_retry",
"user_message": (
"Your payment was broadcast to the network but confirmation timed "
"out, so it is unconfirmed rather than failed. Check your wallet "
"balance and the recipient before retrying: if the balance "
"decreased, the payment likely landed and you should NOT pay again, "
"wait for the merchant to reconcile or contact support. Only "
"resubmit if the funds are still in your wallet."
),
},
extra={"chain": "solana", "broadcast": True},
)


def classify_mppx_failure(reason: str | None) -> ClassifiedMppxFailure | None:
"""Classify a failure-reason string against known patterns.

Returns ``None`` when unrecognized callers fall back to the generic
Returns ``None`` when unrecognized: callers fall back to the generic
``payment_proof_invalid`` envelope. The reason argument may be the raw
``Exception`` message, ``shortMessage`` from a viem-shaped error, or any
string carrying the upstream description. Substring match, case-insensitive.
Expand All @@ -54,4 +90,8 @@ def classify_mppx_failure(reason: str | None) -> ClassifiedMppxFailure | None:
lower = reason.lower()
if "keychain validation failed" in lower or "keynotfound" in lower:
return _TEMPO_KEY_NOT_REGISTERED
# A broadcast Solana transfer whose confirmation timed out: money may have
# moved, so this must never fall through to regenerate_payment_credential.
if "confirmation timeout" in lower or "confirmation timed out" in lower:
return _SOLANA_CONFIRMATION_TIMEOUT
return None
29 changes: 29 additions & 0 deletions tests/test_mppx_failures.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,32 @@ def test_user_message_names_both_recovery_paths() -> None:
msg = out.next_steps["user_message"]
assert "tempo wallet login" in msg
assert "Base" in msg or "Solana" in msg


def test_solana_confirmation_timeout_is_pending_not_regenerate() -> None:
out = classify_mppx_failure("Transaction confirmation timeout")
assert out is not None
assert out.code == "payment_pending_confirmation"
# 504, not 402: a 402 would trigger x402 clients to auto-repay, the exact
# double-charge this guards.
assert out.status == 504
assert out.status != 402
assert out.next_steps["action"] != "regenerate_payment_credential"
assert out.next_steps["action"] == "check_settlement_before_retry"
assert out.extra["chain"] == "solana"
assert out.extra["broadcast"] is True


def test_solana_confirmation_timeout_status_recovery_variant() -> None:
out = classify_mppx_failure("Transaction confirmation timeout (status recovery failed: RPC error)")
assert out is not None
assert out.code == "payment_pending_confirmation"


def test_solana_confirmation_timeout_warns_against_double_pay() -> None:
out = classify_mppx_failure("Transaction confirmation timeout")
assert out is not None
msg = out.next_steps["user_message"].lower()
assert "confirmation timed out" in msg
assert "check your wallet balance" in msg
assert "not pay again" in msg or "only resubmit" in msg