Skip to content

Commit 52c861a

Browse files
vvillait88claude
andauthored
Never map a Solana confirmation timeout to regenerate/pay-again (#109)
## Summary Python mirror of agentscore/node-commerce#105. Fixes a live double-charge on the Solana MPP rail. pympp verifies a transaction-payload credential by broadcasting it (funds move, a signature is minted) then awaiting confirmation in the same synchronous verify() call, with a fixed timeout not exposed to callers. Under load that window can expire before the network surfaces confirmation, even on a healthy production RPC (verified: Helius getHealth ok in ~70ms during the failure), so verify() raises on a transfer that may already have landed. `_handle_mppx` mapped every unclassified pympp failure to `payment_proof_invalid` + `regenerate_payment_credential`, so the merchant told the agent to pay again for money that already left the wallet. Observed live 2026-08-12 on the hosted stores: an on-chain balance delta with no service delivered and a regenerate 402 in hand. `classify_mppx_failure` now recognizes the confirmation-timeout reason and returns `payment_pending_confirmation` (HTTP 504, `action: check_settlement_before_retry`) with a message telling the buyer to verify settlement before retrying. 504 rather than 402 is deliberate: x402/MPP clients version-route on status and a 402 auto-triggers a re-pay retry, which is the double-charge this guards. A confirmation timeout cannot be reliably distinguished from never-landed, so the response surfaces the ambiguity rather than asserting success or failure. ## Type of change - [x] Bug fix (no breaking change) - [ ] New feature (no breaking change) - [ ] Breaking change (existing callers must update) - [ ] Docs, tests, or internal maintenance only ## Public API No signature changes. Behavior change on one error path: a Solana MPP settle whose confirmation times out now returns HTTP 504 `payment_pending_confirmation` instead of 402 `payment_proof_invalid` / `regenerate_payment_credential`. Callers that branched on that 402 to auto-repay should treat 504 as submitted-but-unconfirmed and not blindly resubmit. ## Test plan ruff check + format, ty clean, full suite 1844 passing. New tests: the classifier returns 504 `payment_pending_confirmation` (never 402, never `regenerate_payment_credential`) with a do-not-double-pay message on both the plain and status-recovery-failed variants. ## Checklist - [x] Tests cover the new behavior, and the suite passes locally - [x] Lint, format, and type checks pass - [x] Docs and README examples updated if the public surface changed - [x] No secrets, credentials, or personal data in the diff or the tests Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 5655b1e commit 52c861a

2 files changed

Lines changed: 70 additions & 1 deletion

File tree

‎agentscore_commerce/payment/mppx_failures.py‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,46 @@ class ClassifiedMppxFailure:
4141
)
4242

4343

44+
# The dangerous one. pympp verifies a transaction-payload credential by
45+
# broadcasting it (funds move, a signature is minted) and then awaiting
46+
# confirmation in the same verify() call, with a fixed timeout not exposed to
47+
# callers. When confirmation times out (routine under load, since Solana status
48+
# propagation can lag the window even on a production RPC), verify() raises on a
49+
# transaction that MAY HAVE ALREADY LANDED. Left unclassified, that maps to the
50+
# generic payment_proof_invalid + regenerate_payment_credential, i.e. the
51+
# merchant tells the agent to pay AGAIN for money that already left the wallet
52+
# (observed live 2026-08-12: an on-chain balance delta with no service
53+
# delivered and a regenerate 402 in hand). A confirmation timeout cannot be
54+
# reliably told apart from never-landed, so the honest response is 504 with an
55+
# explicit do-not-blindly-resubmit instruction; 504 (unlike 402) does not
56+
# trigger an automatic re-pay retry in x402/MPP clients.
57+
_SOLANA_CONFIRMATION_TIMEOUT = ClassifiedMppxFailure(
58+
code="payment_pending_confirmation",
59+
status=504,
60+
message=(
61+
"Payment was submitted on-chain but its confirmation timed out. It may "
62+
"have settled. Do NOT resubmit without checking first, or you risk "
63+
"paying twice."
64+
),
65+
next_steps={
66+
"action": "check_settlement_before_retry",
67+
"user_message": (
68+
"Your payment was broadcast to the network but confirmation timed "
69+
"out, so it is unconfirmed rather than failed. Check your wallet "
70+
"balance and the recipient before retrying: if the balance "
71+
"decreased, the payment likely landed and you should NOT pay again, "
72+
"wait for the merchant to reconcile or contact support. Only "
73+
"resubmit if the funds are still in your wallet."
74+
),
75+
},
76+
extra={"chain": "solana", "broadcast": True},
77+
)
78+
79+
4480
def classify_mppx_failure(reason: str | None) -> ClassifiedMppxFailure | None:
4581
"""Classify a failure-reason string against known patterns.
4682
47-
Returns ``None`` when unrecognized — callers fall back to the generic
83+
Returns ``None`` when unrecognized: callers fall back to the generic
4884
``payment_proof_invalid`` envelope. The reason argument may be the raw
4985
``Exception`` message, ``shortMessage`` from a viem-shaped error, or any
5086
string carrying the upstream description. Substring match, case-insensitive.
@@ -54,4 +90,8 @@ def classify_mppx_failure(reason: str | None) -> ClassifiedMppxFailure | None:
5490
lower = reason.lower()
5591
if "keychain validation failed" in lower or "keynotfound" in lower:
5692
return _TEMPO_KEY_NOT_REGISTERED
93+
# A broadcast Solana transfer whose confirmation timed out: money may have
94+
# moved, so this must never fall through to regenerate_payment_credential.
95+
if "confirmation timeout" in lower or "confirmation timed out" in lower:
96+
return _SOLANA_CONFIRMATION_TIMEOUT
5797
return None

‎tests/test_mppx_failures.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,32 @@ def test_user_message_names_both_recovery_paths() -> None:
3737
msg = out.next_steps["user_message"]
3838
assert "tempo wallet login" in msg
3939
assert "Base" in msg or "Solana" in msg
40+
41+
42+
def test_solana_confirmation_timeout_is_pending_not_regenerate() -> None:
43+
out = classify_mppx_failure("Transaction confirmation timeout")
44+
assert out is not None
45+
assert out.code == "payment_pending_confirmation"
46+
# 504, not 402: a 402 would trigger x402 clients to auto-repay, the exact
47+
# double-charge this guards.
48+
assert out.status == 504
49+
assert out.status != 402
50+
assert out.next_steps["action"] != "regenerate_payment_credential"
51+
assert out.next_steps["action"] == "check_settlement_before_retry"
52+
assert out.extra["chain"] == "solana"
53+
assert out.extra["broadcast"] is True
54+
55+
56+
def test_solana_confirmation_timeout_status_recovery_variant() -> None:
57+
out = classify_mppx_failure("Transaction confirmation timeout (status recovery failed: RPC error)")
58+
assert out is not None
59+
assert out.code == "payment_pending_confirmation"
60+
61+
62+
def test_solana_confirmation_timeout_warns_against_double_pay() -> None:
63+
out = classify_mppx_failure("Transaction confirmation timeout")
64+
assert out is not None
65+
msg = out.next_steps["user_message"].lower()
66+
assert "confirmation timed out" in msg
67+
assert "check your wallet balance" in msg
68+
assert "not pay again" in msg or "only resubmit" in msg

0 commit comments

Comments
 (0)