Skip to content

Commit f780358

Browse files
vvillait88claude
andcommitted
fix(commerce): verify_x402_request failure bodies carry regenerate_payment_credential next_steps
Mirror of node-commerce be4495d. All 5 verify_x402_request failure paths now emit `next_steps.action=regenerate_payment_credential` + condition-specific user_message + shared footgun warning, matching the rich denial shape main martin-estate had before the commerce migration. Adds test_verify_x402_failures_carry_regenerate_next_steps. 483 → 484 tests pass; 95.83% coverage. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0b3137e commit f780358

2 files changed

Lines changed: 77 additions & 32 deletions

File tree

‎agentscore_commerce/payment/x402_validation.py‎

Lines changed: 60 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -116,32 +116,59 @@ def _header_lookup(headers: dict[str, str], *names: str) -> str | None:
116116
return None
117117

118118

119+
_REGENERATE_WARNING = (
120+
"If you're trying to pay with Tempo USDC, use `tempo request` (sends Authorization: Payment), "
121+
"not a manual X-Payment header. Do NOT use `tempo wallet transfer` — that sends USDC on-chain "
122+
"but will not complete the MPP handshake. For x402 on Base/Solana, use `agentscore-pay pay` so "
123+
"the X-Payment credential is signed and submitted; bare wallet transfers do not complete the handshake."
124+
)
125+
126+
127+
def _regenerate_body(message: str, user_message: str) -> dict[str, Any]:
128+
return {
129+
"error": {"code": "payment_proof_invalid", "message": message},
130+
"next_steps": {
131+
"action": "regenerate_payment_credential",
132+
"user_message": user_message,
133+
"warning": _REGENERATE_WARNING,
134+
},
135+
}
136+
137+
119138
async def verify_x402_request(input: VerifyX402RequestInput) -> VerifyX402RequestResult:
120139
"""Parse the x402 X-Payment header and validate network + payTo + cache hit.
121140
122141
Returns ``VerifyX402RequestSuccess`` when valid; the caller passes ``payload``
123142
straight into :func:`process_x402_settle`. Returns ``VerifyX402RequestFailure``
124-
when invalid — the merchant just returns ``body`` with HTTP ``status``.
143+
when invalid — ``body`` includes ``next_steps`` with ``regenerate_payment_credential``
144+
so agents can recover deterministically from the response alone.
125145
126146
Reads the header from ``payment-signature`` first, falling back to ``x-payment``
127147
(both are in the wild as the binary-friendly transport name evolved).
128148
"""
129149
header_value = _header_lookup(input.headers, "payment-signature", "x-payment")
130150
if not header_value:
131151
return VerifyX402RequestFailure(
132-
body={"error": {"code": "payment_proof_invalid", "message": "X-Payment header missing"}},
152+
body=_regenerate_body(
153+
"X-Payment header missing",
154+
(
155+
"No X-Payment header was sent. Generate the credential from the 402 "
156+
"challenge and resubmit on the same endpoint."
157+
),
158+
),
133159
)
134160

135161
try:
136162
payload = json.loads(base64.b64decode(header_value).decode())
137163
except Exception:
138164
return VerifyX402RequestFailure(
139-
body={
140-
"error": {
141-
"code": "payment_proof_invalid",
142-
"message": "X-Payment header is not valid base64 JSON",
143-
}
144-
},
165+
body=_regenerate_body(
166+
"X-Payment header is not valid base64 JSON",
167+
(
168+
"The payment credential could not be decoded. Reconstruct the "
169+
"credential from the 402 challenge and retry."
170+
),
171+
),
145172
)
146173

147174
accepted = payload.get("accepted") or {}
@@ -153,40 +180,41 @@ async def verify_x402_request(input: VerifyX402RequestInput) -> VerifyX402Reques
153180
input.accepted_svm_network,
154181
):
155182
return VerifyX402RequestFailure(
156-
body={
157-
"error": {
158-
"code": "payment_proof_invalid",
159-
"message": (
160-
f"Unsupported x402 network {signed_network or '<missing>'}; "
161-
f"this server accepts {input.accepted_base_network} (Base) "
162-
f"and {input.accepted_svm_network} (Solana)"
163-
),
164-
}
165-
},
183+
body=_regenerate_body(
184+
(
185+
f"Unsupported x402 network {signed_network or '<missing>'}; "
186+
f"this server accepts {input.accepted_base_network} (Base) "
187+
f"and {input.accepted_svm_network} (Solana)"
188+
),
189+
(
190+
"The credential signed for an unsupported network. Pick one of the "
191+
"accepted networks from the 402 challenge and re-sign."
192+
),
193+
),
166194
)
167195

168196
is_solana = network_family(signed_network) == "solana"
169197
re_match = _SOLANA_ADDRESS_RE if is_solana else _EVM_ADDRESS_RE
170198
if not signed_pay_to or not isinstance(signed_pay_to, str) or not re_match.match(signed_pay_to):
171199
return VerifyX402RequestFailure(
172-
body={
173-
"error": {
174-
"code": "payment_proof_invalid",
175-
"message": (
176-
f"Payment payload missing or malformed accepted.payTo address for network {signed_network}"
177-
),
178-
}
179-
},
200+
body=_regenerate_body(
201+
f"Payment payload missing or malformed accepted.payTo address for network {signed_network}",
202+
(
203+
"The credential payload is missing or malformed payTo for the signed "
204+
"network. Reconstruct the credential from the 402 challenge."
205+
),
206+
),
180207
)
181208

182209
if not await input.is_cached_address(signed_pay_to):
183210
return VerifyX402RequestFailure(
184-
body={
185-
"error": {
186-
"code": "payment_proof_invalid",
187-
"message": "payTo address not found in cache or expired. Request a fresh 402 challenge and retry.",
188-
}
189-
},
211+
body=_regenerate_body(
212+
"payTo address not found in cache or expired. Request a fresh 402 challenge and retry.",
213+
(
214+
"The deposit address is unknown or expired on this server. Request a "
215+
"fresh 402 challenge and re-sign against the new payTo."
216+
),
217+
),
190218
)
191219

192220
return VerifyX402RequestSuccess(

‎tests/test_lifted_helpers.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,23 @@ async def test_verify_x402_pay_to_not_in_cache():
299299
assert "not found in cache" in res.body["error"]["message"]
300300

301301

302+
@pytest.mark.asyncio
303+
async def test_verify_x402_failures_carry_regenerate_next_steps():
304+
"""Every failure path emits next_steps with regenerate_payment_credential + user_message + warning."""
305+
res = await verify_x402_request(
306+
VerifyX402RequestInput(
307+
headers={},
308+
is_cached_address=_always_true,
309+
accepted_base_network=networks.base.sepolia.caip2,
310+
accepted_svm_network=networks.solana.devnet.caip2,
311+
)
312+
)
313+
assert isinstance(res, VerifyX402RequestFailure)
314+
assert res.body["next_steps"]["action"] == "regenerate_payment_credential"
315+
assert "user_message" in res.body["next_steps"]
316+
assert "tempo wallet transfer" in res.body["next_steps"]["warning"]
317+
318+
302319
@pytest.mark.asyncio
303320
async def test_verify_x402_success_evm():
304321
pay_to = "0x" + "1" * 40

0 commit comments

Comments
 (0)