Skip to content

Commit ec9eece

Browse files
committed
fix(metamask-agent-wallet): correct x402 v2 payment payload and related issues
Review on the PR found the v2 PaymentPayload reused the v1 top-level shape, so a v2 facilitator rejects it for missing 'accepted'. Build the v2 envelope per the spec (nest the chosen requirements under 'accepted', forward the top-level 'resource'); v1 is unchanged. Also from review: backdate validAfter instead of '0' to match the reference client; prefer the EIP-3009 option over Permit2 and report Permit2-only offers as unsupported; read the settlement receipt from the response body when the header is absent; document the v2 payload difference and multi-network selection. Verified end to end against a real v2 facilitator on Base Sepolia plus spec-vector tests.
1 parent 21e1cf9 commit ec9eece

3 files changed

Lines changed: 106 additions & 20 deletions

File tree

skills/metamask-agent-wallet/references/x402.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,25 @@ in the wallet. The script is pure Python standard library (run with `python3`).
2121

2222
The script handles both protocol versions: v1 (requirements in the `402` body, retry header
2323
`X-PAYMENT`) and v2 (requirements in the `PAYMENT-REQUIRED` header, retry header
24-
`PAYMENT-SIGNATURE`).
24+
`PAYMENT-SIGNATURE`). The payload envelope also differs: v1 puts `scheme`/`network` at the top
25+
level, while v2 nests the chosen requirements under `accepted` and forwards the top-level
26+
`resource` (see the x402 v2 spec, PaymentPayload schema). The script builds the correct envelope
27+
per version.
28+
29+
### Supported
30+
31+
- The `exact` scheme on EVM networks (`eip155:*`) using EIP-3009
32+
`transferWithAuthorization`, protocol v1 and v2.
33+
34+
### Not supported (rejected with a clear error)
35+
36+
- The Permit2 asset transfer method (`extra.assetTransferMethod: "permit2"`) and its gas-sponsoring
37+
extensions. An EIP-3009 option on the same asset is preferred when offered; a Permit2-only offer
38+
is reported as unsupported. Signing a Permit2 authorization is a separate flow.
39+
- Other schemes such as `upto`, and non-EVM networks (e.g. Solana).
40+
- Offers that omit the EIP-712 domain `name`/`version` in `extra`. The reference client falls back
41+
to reading them on-chain; this script does not, so it asks for an offer that includes them rather
42+
than risk signing the wrong domain.
2543

2644
## Usage
2745

skills/metamask-agent-wallet/scripts/x402_pay.py

Lines changed: 83 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,11 @@ def asset_meta(chain_id, asset):
172172

173173

174174
def parse_402(status, headers, body):
175-
"""Return (version, [normalized_option, ...]). Raises if not a usable 402."""
175+
"""Return (version, [option, ...], resource_info). Raises if not a usable 402.
176+
177+
resource_info is the v2 top-level ResourceInfo object (v2 forwards it into
178+
the payment payload); it is None for v1, which carries resource per option.
179+
"""
176180
if status != 402:
177181
raise CeremonyError("expected HTTP 402, got %s" % status)
178182

@@ -211,13 +215,14 @@ def parse_402(status, headers, body):
211215
"amount": str(amount) if amount is not None else None,
212216
"payTo": a.get("payTo"),
213217
"asset": a.get("asset"),
214-
"maxTimeoutSeconds": a.get("maxTimeoutSeconds", 60),
218+
"maxTimeoutSeconds": a.get("maxTimeoutSeconds", 3600),
215219
"extra": a.get("extra", {}),
216220
"resource": a.get("resource"),
217221
})
218222
if not options:
219223
raise CeremonyError("402 had no payment options")
220-
return version, options
224+
resource_info = data.get("resource") if isinstance(data, dict) else None
225+
return version, options, resource_info
221226

222227

223228
def describe(option):
@@ -228,7 +233,12 @@ def describe(option):
228233
except CeremonyError:
229234
chain_id = None
230235
out["chainId"] = chain_id
231-
out["eligible"] = chain_id is not None and option.get("scheme") == "exact"
236+
# extra.assetTransferMethod is absent on v1 and on eip3009 v2 offers; only an
237+
# explicit "permit2" is ineligible, since this script signs EIP-3009 only.
238+
transfer = (option.get("extra") or {}).get("assetTransferMethod")
239+
out["assetTransferMethod"] = transfer or "eip3009"
240+
out["eligible"] = (chain_id is not None and option.get("scheme") == "exact"
241+
and out["assetTransferMethod"] == "eip3009")
232242
meta = asset_meta(chain_id, option.get("asset"))
233243
if meta:
234244
out["symbol"] = meta["symbol"]
@@ -257,8 +267,15 @@ def select(options, want_asset=None, want_network=None):
257267
continue
258268
eligible.append(d)
259269
if not eligible:
270+
exact_on_chain = [d for d in described
271+
if d.get("chainId") is not None and d.get("scheme") == "exact"]
272+
if exact_on_chain and all(d["assetTransferMethod"] == "permit2" for d in exact_on_chain):
273+
raise CeremonyError(
274+
"the only payable options use the permit2 asset transfer method, which this "
275+
"script does not support (it signs EIP-3009 transferWithAuthorization only). "
276+
"Offered: %s" % json.dumps(described))
260277
raise CeremonyError(
261-
"no eligible option (need scheme 'exact' on a network mm supports). "
278+
"no eligible option (need scheme 'exact', EIP-3009, on a network mm supports). "
262279
"Offered: %s" % json.dumps(described))
263280
if len(eligible) > 1:
264281
raise CeremonyError(
@@ -292,14 +309,18 @@ def build_typed_data(option, chain_id, from_addr):
292309
authorization may live is the wallet's policy, not a constant here. A random
293310
nonce keeps each authorization single-use.
294311
"""
312+
now = int(time.time())
295313
nonce = "0x" + secrets.token_bytes(32).hex()
296-
valid_before = str(int(time.time()) + int(option["maxTimeoutSeconds"]))
297314
authorization = {
298315
"from": from_addr,
299316
"to": option["payTo"],
300317
"value": option["amount"],
301-
"validAfter": "0",
302-
"validBefore": valid_before,
318+
# Backdate validAfter: the spec allows "0", and the official Python
319+
# client uses it, but the TypeScript reference client backdates 10
320+
# minutes because some facilitators reject "0" and it absorbs clock
321+
# skew. Follow the safer value.
322+
"validAfter": str(now - 600),
323+
"validBefore": str(now + int(option["maxTimeoutSeconds"])),
303324
"nonce": nonce,
304325
}
305326
typed_data = {
@@ -331,6 +352,37 @@ def build_typed_data(option, chain_id, from_addr):
331352
return typed_data, authorization
332353

333354

355+
def build_payment(version, option, resource_info, signature, authorization, url):
356+
"""Assemble the PaymentPayload for the chosen option.
357+
358+
The envelope differs by version (x402 spec section 5.2): v2 nests the chosen
359+
requirements under `accepted` and forwards the `resource`, while v1 puts
360+
`scheme`/`network` at the top level. A facilitator rejects a v2 payload that
361+
is missing `accepted`.
362+
"""
363+
if version == 2:
364+
return {
365+
"x402Version": 2,
366+
"resource": resource_info or {"url": url},
367+
"accepted": {
368+
"scheme": option["scheme"],
369+
"network": option["network"],
370+
"amount": option["amount"],
371+
"asset": option["asset"],
372+
"payTo": option["payTo"],
373+
"maxTimeoutSeconds": option["maxTimeoutSeconds"],
374+
"extra": option.get("extra", {}),
375+
},
376+
"payload": {"signature": signature, "authorization": authorization},
377+
}
378+
return {
379+
"x402Version": 1,
380+
"scheme": option["scheme"],
381+
"network": option["network"],
382+
"payload": {"signature": signature, "authorization": authorization},
383+
}
384+
385+
334386
def validate(option, chain_id):
335387
"""Check the offer is structurally sound before signing.
336388
@@ -351,23 +403,38 @@ def validate(option, chain_id):
351403
raise CeremonyError("402 option missing EIP-712 domain name/version in 'extra'")
352404

353405

354-
def settlement(headers, version):
355-
"""Decode the facilitator's settlement receipt, if the server sent one."""
406+
def settlement(headers, version, body):
407+
"""Decode the facilitator's settlement receipt.
408+
409+
The spec puts it in the X-PAYMENT-RESPONSE / PAYMENT-RESPONSE header, but
410+
some servers return it only in the response body, so fall back to the body
411+
when the header is absent.
412+
"""
356413
name = "X-PAYMENT-RESPONSE" if version == 1 else "PAYMENT-RESPONSE"
357414
raw = headers.get(name)
358-
if not raw:
415+
if raw:
416+
try:
417+
return json.loads(base64.b64decode(raw))
418+
except (ValueError, UnicodeDecodeError):
419+
pass
420+
try:
421+
data = json.loads(body.decode("utf-8"))
422+
except (ValueError, UnicodeDecodeError):
359423
return None
360-
return json.loads(base64.b64decode(raw))
424+
if isinstance(data, dict) and (data.get("transaction") or data.get("txHash")):
425+
return data
426+
return None
361427

362428

363429
def cmd_inspect(url, method, data, content_type):
364430
"""Print the 402 payment requirement(s) without signing or spending."""
365431
body, headers = request_parts(data, content_type)
366432
status, rheaders, rbody = http(url, method, headers, body)
367-
version, options = parse_402(status, rheaders, rbody)
433+
version, options, resource_info = parse_402(status, rheaders, rbody)
368434
print(json.dumps({
369435
"status": "payment_required",
370436
"x402Version": version,
437+
"resource": resource_info,
371438
"options": [describe(o) for o in options],
372439
}, indent=2))
373440

@@ -380,7 +447,7 @@ def cmd_pay(url, method, data, content_type, confirm, want_asset, want_network):
380447
body, headers = request_parts(data, content_type)
381448
# Fetch fresh so the short 402 window is never stale.
382449
status, rheaders, rbody = http(url, method, headers, body)
383-
version, options = parse_402(status, rheaders, rbody)
450+
version, options, resource_info = parse_402(status, rheaders, rbody)
384451
option = select(options, want_asset, want_network)
385452
chain_id = option["chainId"]
386453
validate(option, chain_id)
@@ -393,9 +460,7 @@ def cmd_pay(url, method, data, content_type, confirm, want_asset, want_network):
393460
option["payTo"], url)
394461
signature = sign_typed_data(chain_id, typed_data, intent)
395462

396-
payment = {"x402Version": version, "scheme": option["scheme"],
397-
"network": option["network"],
398-
"payload": {"signature": signature, "authorization": authorization}}
463+
payment = build_payment(version, option, resource_info, signature, authorization, url)
399464
b64 = base64.b64encode(json.dumps(payment).encode()).decode()
400465
header = "X-PAYMENT" if version == 1 else "PAYMENT-SIGNATURE"
401466

@@ -405,7 +470,7 @@ def cmd_pay(url, method, data, content_type, confirm, want_asset, want_network):
405470
# One attempt only: do not retry a payment.
406471
raise CeremonyError("payment not accepted (HTTP %s): %s" % (status, rbody.decode("utf-8", "replace")))
407472

408-
settle = settlement(rheaders, version)
473+
settle = settlement(rheaders, version, rbody)
409474
try:
410475
resource = json.loads(rbody.decode("utf-8"))
411476
except ValueError:

skills/metamask-agent-wallet/workflows/x402-pay.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,10 @@ the resource body.
4141

4242
- `error` with "no eligible option": the server offered no `exact`-scheme payment on a network mm
4343
supports. Show the offered options to the user.
44-
- `error` with "multiple eligible options": rerun `pay` with `--asset` or `--network`.
44+
- `error` with "multiple eligible options": the server offered the same scheme on several networks
45+
or assets (e.g. Base and Polygon). Rerun `pay` with `--network` or `--asset` to choose one.
46+
- `error` mentioning "permit2": the only options use the Permit2 transfer method, which this skill
47+
does not sign (it supports EIP-3009 only). Tell the user it is unsupported.
4548
- `error` with "payment not accepted": surface it. Do not rerun blindly; rerunning makes a new
4649
payment.
4750
- `error` with "not a standard x402 challenge": the endpoint returned a 402 in a different payment

0 commit comments

Comments
 (0)