Skip to content

Commit ac7a1c8

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 ac7a1c8

3 files changed

Lines changed: 92 additions & 19 deletions

File tree

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,14 @@ 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+
Only the `exact` scheme with EIP-3009 `transferWithAuthorization` is supported. An option that
30+
advertises `extra.assetTransferMethod: "permit2"` is skipped (and reported as unsupported if it is
31+
the only thing offered), since signing a Permit2 authorization is a different flow.
2532

2633
## Usage
2734

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

Lines changed: 80 additions & 17 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

@@ -217,7 +221,8 @@ def parse_402(status, headers, body):
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,16 @@ 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 (matching the reference client); some facilitators
319+
# reject "0" as anomalous and it tolerates minor clock skew.
320+
"validAfter": str(now - 600),
321+
"validBefore": str(now + int(option["maxTimeoutSeconds"])),
303322
"nonce": nonce,
304323
}
305324
typed_data = {
@@ -331,6 +350,37 @@ def build_typed_data(option, chain_id, from_addr):
331350
return typed_data, authorization
332351

333352

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

353403

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

362426

363427
def cmd_inspect(url, method, data, content_type):
364428
"""Print the 402 payment requirement(s) without signing or spending."""
365429
body, headers = request_parts(data, content_type)
366430
status, rheaders, rbody = http(url, method, headers, body)
367-
version, options = parse_402(status, rheaders, rbody)
431+
version, options, resource_info = parse_402(status, rheaders, rbody)
368432
print(json.dumps({
369433
"status": "payment_required",
370434
"x402Version": version,
435+
"resource": resource_info,
371436
"options": [describe(o) for o in options],
372437
}, indent=2))
373438

@@ -380,7 +445,7 @@ def cmd_pay(url, method, data, content_type, confirm, want_asset, want_network):
380445
body, headers = request_parts(data, content_type)
381446
# Fetch fresh so the short 402 window is never stale.
382447
status, rheaders, rbody = http(url, method, headers, body)
383-
version, options = parse_402(status, rheaders, rbody)
448+
version, options, resource_info = parse_402(status, rheaders, rbody)
384449
option = select(options, want_asset, want_network)
385450
chain_id = option["chainId"]
386451
validate(option, chain_id)
@@ -393,9 +458,7 @@ def cmd_pay(url, method, data, content_type, confirm, want_asset, want_network):
393458
option["payTo"], url)
394459
signature = sign_typed_data(chain_id, typed_data, intent)
395460

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

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

408-
settle = settlement(rheaders, version)
471+
settle = settlement(rheaders, version, rbody)
409472
try:
410473
resource = json.loads(rbody.decode("utf-8"))
411474
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)