From 21e1cf9162f2ae6e880bdd44307a2a23874257f3 Mon Sep 17 00:00:00 2001 From: basgys Date: Wed, 17 Jun 2026 13:02:20 +0200 Subject: [PATCH 1/2] feat(metamask-agent-wallet): add x402 buyer payment support Agents that fetch APIs and resources increasingly hit HTTP 402 (x402) paywalls and must pay per request, but the CLI has no native x402 support, so an agent improvises the protocol and the EIP-3009 signing, which is error-prone and skips payment safety checks. Guide the agent to detect a 402, select an offered exact-scheme option from an asset allowlist, sign an EIP-3009 transferWithAuthorization with mm wallet sign-typed-data, retry with the payment header, and verify settlement. Handle protocol v1 (X-PAYMENT) and v2 (PAYMENT-SIGNATURE). Confirm before signing; no autonomous auto-pay. --- skills/metamask-agent-wallet/SKILL.md | 11 +- .../metamask-agent-wallet/references/x402.md | 90 ++++ .../metamask-agent-wallet/scripts/x402_pay.py | 459 ++++++++++++++++++ .../workflows/x402-pay.md | 51 ++ 4 files changed, 609 insertions(+), 2 deletions(-) create mode 100644 skills/metamask-agent-wallet/references/x402.md create mode 100644 skills/metamask-agent-wallet/scripts/x402_pay.py create mode 100644 skills/metamask-agent-wallet/workflows/x402-pay.md diff --git a/skills/metamask-agent-wallet/SKILL.md b/skills/metamask-agent-wallet/SKILL.md index 5edadef..6b35c37 100644 --- a/skills/metamask-agent-wallet/SKILL.md +++ b/skills/metamask-agent-wallet/SKILL.md @@ -1,10 +1,10 @@ --- name: metamask-agent-wallet -description: Use when the user asks anything about blockchain wallets, transactions, signing, token transfers, supported chains, wallet balances, perpetual futures trading, prediction markets, token swaps, cross-chain bridges, market data, token discovery, decoding EVM calldata, Aave V3 lending and borrowing, or authentication via the MetaMask Agentic CLI. Single entry point for all mm CLI operations. +description: Use when the user asks anything about blockchain wallets, transactions, signing, token transfers, supported chains, wallet balances, perpetual futures trading, prediction markets, token swaps, cross-chain bridges, market data, token discovery, decoding EVM calldata, Aave V3 lending and borrowing, or authentication via the MetaMask Agentic CLI; also when an HTTP request returns 402 Payment Required (x402) or the agent needs to pay for a paywalled API, endpoint, file, or resource over HTTP. Single entry point for all mm CLI operations. license: MIT metadata: author: metamask - version: "4.0.0" + version: "4.1.0" cliVersion: "3.0.0" --- @@ -104,6 +104,7 @@ Match the user's intent to a command and reference file, then read the reference | Execute a token swap or bridge | `mm swap execute` | [swap.md](references/swap.md) | | Check swap or bridge status | `mm swap status` | [swap.md](references/swap.md) | | Bridge tokens to another chain | `mm swap execute` | [swap.md](references/swap.md) | +| Pay an HTTP `402` / x402 paywalled request | `python3 scripts/x402_pay.py` | [x402.md](references/x402.md) | ## Workflows @@ -133,6 +134,7 @@ CLI behavior lives in `references/`. Repeatable patterns live in `workflows/`. L | Toggle Aave V3 collateral | [aave-collateral.md](workflows/aave-collateral.md) | | Check Aave V3 positions and health factor | [aave-positions.md](workflows/aave-positions.md) | | Discover Aave V3 tokens, rates, and liquidity | [aave-markets.md](workflows/aave-markets.md) | +| Pay an HTTP `402` (x402) paywalled request | [x402-pay.md](workflows/x402-pay.md) | ## Global Flags @@ -218,6 +220,10 @@ Before constructing any command, validate all user-provided values: | `--to-address` | Must match `^0x[0-9a-fA-F]{40}$`. Only valid for cross-chain swaps (`--to-chain` differs from `--from-chain`); rejected for same-chain swaps | | `--refuel` | Boolean flag (no value). Only meaningful for cross-chain swaps (`--to-chain` differs from `--from-chain`); no effect on same-chain swaps | | `--password` | Must be a non-empty string. Never log, display, or store the value. | +| x402 `asset` | Must be a valid contract address on a network returned by `mm chains list`. The currency choice is the server's offer confirmed by the user; the script keeps no currency allowlist. | +| x402 `payTo` / authorization `to` | Must match `^0x[0-9a-fA-F]{40}$` and equal the recipient in the `402` | +| x402 `value` | Atomic-unit integer that exactly equals the offered amount (the `exact` scheme is not a maximum) | +| x402 resource URL | Must be `https://`. Reject a `402` reached via an unexpected cross-host redirect | Do not pass unvalidated user input into any command. @@ -230,6 +236,7 @@ Do not pass unvalidated user input into any command. | Message signing | Always show exact message and chain before signing | | Typed-data signing | Always show domain, primary type, chain, verifying contract, and message summary before signing | | Swaps / bridges | Always confirm from/to tokens, amount, source/destination chain, slippage, quoted output, recipient address (if `--to-address` is set), and the destination gas top-up (if `--refuel` is set) before executing | +| x402 payments | Always confirm asset, decimals-correct amount, network, `payTo`, and resource URL before signing the authorization. One payment attempt per resource, never auto-retry a payment. Autonomous auto-pay is not supported. | | Perps trading | Always confirm symbol, side, size, leverage, venue, order type, and limit price if present before executing | | Perps deposit/withdraw | Always confirm amount, asset, venue, network, and destination where applicable before executing | | Predict trading | Always confirm token ID, side, size, price, order type, market, and outcome before executing | diff --git a/skills/metamask-agent-wallet/references/x402.md b/skills/metamask-agent-wallet/references/x402.md new file mode 100644 index 0000000..d1f8e05 --- /dev/null +++ b/skills/metamask-agent-wallet/references/x402.md @@ -0,0 +1,90 @@ +# x402 Payments (buyer / payer) + +Use this when an HTTP request returns **`402 Payment Required`** following the +[x402 protocol](https://docs.x402.org), or when the user asks to pay for / fetch a paywalled API, +endpoint, file, or resource over HTTP. + +`scripts/x402_pay.py` performs the payment: it fetches the URL, reads the server's payment +requirements, signs an EIP-3009 authorization with `mm wallet sign-typed-data`, retries with the +payment header, and reports the settlement. Signing is delegated to `mm`, so the private key stays +in the wallet. The script is pure Python standard library (run with `python3`). + +## How x402 works (exact scheme, EVM) + +1. The client requests a resource. The server replies `402` with one or more **payment options** + (scheme, network, amount, asset, `payTo`, validity window). +2. The client signs an EIP-3009 `TransferWithAuthorization` for one option. This is a gasless, + off-chain authorization (no on-chain tx from the payer) permitting a pull of exactly the amount + to `payTo`. +3. The client retries with the payment header. The server's facilitator settles on-chain and + returns the resource plus a settlement header (tx hash). + +The script handles both protocol versions: v1 (requirements in the `402` body, retry header +`X-PAYMENT`) and v2 (requirements in the `PAYMENT-REQUIRED` header, retry header +`PAYMENT-SIGNATURE`). + +## Usage + +Inspect first (read-only: no signing, no spending), show the result to the user, then pay. + +```bash +python3 scripts/x402_pay.py inspect [--method M] [--data BODY] +python3 scripts/x402_pay.py pay --confirm [--method M] [--data BODY] [--asset ] [--network ] +``` + +`inspect` prints the payment requirement(s) as JSON, including the human-readable amount, asset +symbol, network, `payTo`, and resource. `pay` runs the payment for a single eligible option and +prints the settlement (`transaction`) and the resource body. It requires `--confirm`. When the +`402` offers more than one eligible option, choose one with `--asset` or `--network`. + +The resource may use any HTTP method. For a non-GET endpoint pass `--method` (and `--data` for a +request body); the same request is replayed with the payment attached, so a paid `POST` works the +same as a `GET`. `--data` defaults to a JSON content type, overridable with `--content-type`. + +### Example + +```bash +python3 scripts/x402_pay.py inspect https://api.example.com/premium +# review the amount / payTo / resource with the user, then: +python3 scripts/x402_pay.py pay https://api.example.com/premium --confirm +``` + +## What the script checks + +It pays an option whose `scheme` is `exact` on a network `mm` supports (resolved from +`mm chains list`). Before signing it verifies the amount is a positive atomic-unit integer, the +asset and `payTo` are valid addresses, the EIP-712 domain (`name`/`version`) is present, and the +URL is `https://` (plain `http` is allowed only on loopback for local testing). It does not follow +redirects, so a cross-host redirect cannot divert payment. After paying it makes a single retry +and reports settlement; it never retries a payment. + +These are protocol-correctness checks. Which currencies and amounts are allowed is the wallet's +decision: the asset comes from the server's offer, the user confirms it, and `mm` signs (and is +where spend policy applies). The script does not keep its own currency allowlist. + +## Assets and decimals + +The server chooses which assets it accepts; the client can only pay one the server offered. The +script reads the asset's symbol and decimals from `mm token assets` for display. When `mm` cannot +resolve them (for example on testnets the Token API does not index), it shows the raw atomic +amount; the signed value is the server's atomic amount either way. + +## Wallet modes + +- **Server-wallet:** signing runs synchronously via `--wait` (guard mode permits EIP-712 signing). + The x402 window is short, so if a signature needs manual approval and is slow, the authorization + can expire; rerun `pay` to sign again with fresh values. +- **BYOK:** signing returns immediately. Set `MM_PASSWORD` for an encrypted mnemonic so signing is + non-interactive. + +## Confirmation + +Run `inspect` and show the user the asset, amount, network, `payTo`, and resource URL. Run +`pay ... --confirm` only after the user approves. A signature authorizes a real token debit. + +## Idempotency + +The script is stateless. The EIP-3009 nonce prevents the same signed authorization from settling +twice, and each run makes one payment, but rerunning `pay` for the same resource makes a new +payment. Guard repeated calls at the caller. A local idempotency ledger (a caller-supplied key +mapped to prior settlements) is a possible future improvement. diff --git a/skills/metamask-agent-wallet/scripts/x402_pay.py b/skills/metamask-agent-wallet/scripts/x402_pay.py new file mode 100644 index 0000000..c0a0d65 --- /dev/null +++ b/skills/metamask-agent-wallet/scripts/x402_pay.py @@ -0,0 +1,459 @@ +#!/usr/bin/env python3 +"""Pay an HTTP 402 (x402) request with the active wallet. + +Fetches a paywalled URL, reads the server's x402 payment requirements, signs an +EIP-3009 authorization with `mm wallet sign-typed-data`, retries with the +payment header, and reports the settlement. Supports x402 protocol v1 +(X-PAYMENT) and v2 (PAYMENT-SIGNATURE). Signing is delegated to `mm`, so the +private key stays in the wallet. + +Usage: + python3 x402_pay.py inspect [--method M] [--data BODY] + python3 x402_pay.py pay --confirm [--method M] [--data BODY] + [--asset ] [--network ] + +`inspect` fetches the URL and prints the payment requirement(s) as JSON +(asset, amount, network, payTo, resource) without signing or spending. Review +this before paying. + +`pay` runs the payment for a single offered option and prints the settlement +(transaction hash) and the resource body. It requires --confirm. When the 402 +offers more than one eligible option, select one with --asset or --network. + +The resource may use any HTTP method; pass --method (and --data for a request +body) and the same request is replayed with the payment attached. + +The script pays the exact scheme on networks `mm` supports; which currencies +and amounts are allowed is the wallet's decision at signing. Each run makes one +payment with a single retry; rerunning makes a new payment, so guard repeated +calls at the caller. A local idempotency ledger is a possible future addition. + +Examples: + python3 x402_pay.py inspect https://api.example.com/premium + python3 x402_pay.py pay https://api.example.com/premium --confirm +""" + +import argparse +import base64 +import json +import re +import secrets +import subprocess +import sys +import time +import urllib.error +import urllib.request +from decimal import Decimal +from urllib.parse import urlparse + +ADDRESS_RE = re.compile(r"^0x[0-9a-fA-F]{40}$") +LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1"} + + +class CeremonyError(Exception): + """An expected failure to surface to the caller.""" + + +class _NoRedirect(urllib.request.HTTPRedirectHandler): + """Surfaces redirects instead of following them. + + A cross-host redirect on a payment request could divert payment to a host + the user never chose, so the 3xx is returned and rejected by the caller. + """ + + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +_OPENER = urllib.request.build_opener(_NoRedirect) + + +def _check_url(url): + """Reject plaintext URLs so a payment is never sent over http. + + Loopback is exempt to allow testing against a local resource server. + """ + parsed = urlparse(url) + if parsed.scheme == "https": + return + if parsed.scheme == "http" and parsed.hostname in LOOPBACK_HOSTS: + return + raise CeremonyError("resource URL must be https:// (http allowed only on loopback)") + + +def http(url, method="GET", headers=None, body=None): + """Make a request. Returns (status, headers, body_bytes); no raise on 4xx/5xx.""" + _check_url(url) + req = urllib.request.Request(url, data=body, method=method, headers=headers or {}) + try: + resp = _OPENER.open(req, timeout=30) + return resp.status, resp.headers, resp.read() + except urllib.error.HTTPError as e: + return e.code, e.headers, e.read() + + +def request_parts(data, content_type): + """Turn an optional request body string into (bytes, headers) for replay. + + x402 is method-agnostic, so a paid POST must be sent identically on both the + initial request and the retry that carries the payment. + """ + if data is None: + return None, {} + return data.encode("utf-8"), {"Content-Type": content_type} + + +def _mm_json(args): + """Run an `mm` command and return its JSON result. + + The JSON is sliced out of stdout because `mm` may emit non-JSON warnings + (e.g. Node startup notices) around it. + """ + out = subprocess.run(["mm", *args], capture_output=True, text=True) + text = out.stdout + start, end = text.find("{"), text.rfind("}") + if start == -1 or end == -1: + raise CeremonyError("mm produced no JSON (%s): %s" % (out.returncode, out.stderr.strip())) + return json.loads(text[start:end + 1]) + + +_chains_cache = None + + +def _chains(): + """Return mm's supported chains, so network support stays the wallet's call.""" + global _chains_cache + if _chains_cache is None: + _chains_cache = (_mm_json(["chains", "list", "--json"]).get("data") or {}).get("chains", []) + return _chains_cache + + +def chain_id_for(network): + """Resolve an x402 network identifier to an EVM chain id. + + Accepts v2 CAIP-2 ids (e.g. "eip155:8453") directly and v1 network names + (e.g. "base-sepolia") by matching mm's chain list. + """ + if network.startswith("eip155:"): + try: + return int(network.split(":", 1)[1]) + except ValueError: + raise CeremonyError("unparseable CAIP-2 network: %s" % network) + for c in _chains(): + if network in (c.get("key"), c.get("caip2")) or network.lower() == (c.get("name") or "").lower(): + return c.get("chainId") + raise CeremonyError("network '%s' is not supported by mm (see `mm chains list`)" % network) + + +_asset_meta_cache = {} + + +def asset_meta(chain_id, asset): + """Look up an asset's symbol and decimals from mm, for display only. + + Returns None when mm cannot resolve it (e.g. testnets the Token API does + not index); the signed amount is the server's atomic value regardless. + """ + if chain_id is None or not asset: + return None + key = (chain_id, asset.lower()) + if key not in _asset_meta_cache: + meta = None + try: + res = _mm_json(["token", "assets", "--asset-ids", + "eip155:%s/erc20:%s" % (chain_id, asset), "--json"]) + assets = (res.get("data") or {}).get("assets") or [] + if assets and assets[0].get("decimals") is not None: + meta = {"symbol": assets[0].get("symbol"), "decimals": assets[0]["decimals"]} + except (CeremonyError, ValueError): + meta = None + _asset_meta_cache[key] = meta + return _asset_meta_cache[key] + + +def parse_402(status, headers, body): + """Return (version, [normalized_option, ...]). Raises if not a usable 402.""" + if status != 402: + raise CeremonyError("expected HTTP 402, got %s" % status) + + # v2 carries requirements in the PAYMENT-REQUIRED header; v1 in the body. + # The accepts[] array is what marks a response as a standard x402 challenge: + # other paywalls also answer 402 but with their own, unsupported schemes. + pr = headers.get("PAYMENT-REQUIRED") + if pr: + try: + data = json.loads(base64.b64decode(pr)) + except (ValueError, UnicodeDecodeError) as e: + raise CeremonyError("could not parse the PAYMENT-REQUIRED header: %s" % e) + version = 2 + else: + try: + data = json.loads(body.decode("utf-8")) + except (ValueError, UnicodeDecodeError) as e: + raise CeremonyError("could not parse the 402 response body: %s" % e) + version = 1 + # No PAYMENT-REQUIRED header and no accepts[] means another paywall + # scheme answering 402, not x402. + if not isinstance(data, dict) or "accepts" not in data: + raise CeremonyError( + "received HTTP 402 but it is not a standard x402 challenge " + "(no PAYMENT-REQUIRED header and no accepts[] in the body); " + "this endpoint uses a payment scheme this script does not support") + + accepts = (data.get("accepts") if isinstance(data, dict) else None) or [] + + options = [] + for a in accepts: + amount = a.get("amount", a.get("maxAmountRequired")) + options.append({ + "scheme": a.get("scheme"), + "network": a.get("network"), + "amount": str(amount) if amount is not None else None, + "payTo": a.get("payTo"), + "asset": a.get("asset"), + "maxTimeoutSeconds": a.get("maxTimeoutSeconds", 60), + "extra": a.get("extra", {}), + "resource": a.get("resource"), + }) + if not options: + raise CeremonyError("402 had no payment options") + return version, options + + +def describe(option): + """Annotate an option with chain and display metadata; never raises.""" + out = dict(option) + try: + chain_id = chain_id_for(option["network"]) + except CeremonyError: + chain_id = None + out["chainId"] = chain_id + out["eligible"] = chain_id is not None and option.get("scheme") == "exact" + meta = asset_meta(chain_id, option.get("asset")) + if meta: + out["symbol"] = meta["symbol"] + out["decimals"] = meta["decimals"] + amount = option.get("amount") + if isinstance(amount, str) and amount.isdigit(): + out["humanAmount"] = str(Decimal(amount) / (Decimal(10) ** meta["decimals"])) + return out + + +def select(options, want_asset=None, want_network=None): + """Pick the single payable option, or raise so the caller can react. + + Requires exactly one option with the exact scheme on a network mm supports; + --asset/--network narrow the choice when a server offers several, since + picking one silently could spend on an asset the user did not intend. + """ + described = [describe(o) for o in options] + eligible = [] + for d in described: + if not d["eligible"]: + continue + if want_asset and (d.get("asset") or "").lower() != want_asset.lower(): + continue + if want_network and d.get("network") != want_network: + continue + eligible.append(d) + if not eligible: + raise CeremonyError( + "no eligible option (need scheme 'exact' on a network mm supports). " + "Offered: %s" % json.dumps(described)) + if len(eligible) > 1: + raise CeremonyError( + "multiple eligible options; disambiguate with --asset/--network. " + "Eligible: %s" % json.dumps(eligible)) + return eligible[0] + + +def wallet_address(): + """Return the active wallet address, the payer for the authorization.""" + d = _mm_json(["wallet", "address", "--json"]).get("data") + return d["address"] if isinstance(d, dict) else d + + +def sign_typed_data(chain_id, payload, intent): + """Sign EIP-712 typed data with `mm`, keeping the key inside the wallet.""" + res = _mm_json(["wallet", "sign-typed-data", "--chain-id", str(chain_id), + "--wait", "--json", "--intent", intent, + "--payload", json.dumps(payload)]) + sig = (res.get("data") or {}).get("signature") + if not res.get("ok") or not sig: + raise CeremonyError("signing failed: %s" % json.dumps(res)) + return sig + + +def build_typed_data(option, chain_id, from_addr): + """Build the EIP-3009 TransferWithAuthorization message to sign. + + validBefore honors the server's maxTimeoutSeconds so the facilitator can + settle within the window it asked for; any ceiling on how long an + authorization may live is the wallet's policy, not a constant here. A random + nonce keeps each authorization single-use. + """ + nonce = "0x" + secrets.token_bytes(32).hex() + valid_before = str(int(time.time()) + int(option["maxTimeoutSeconds"])) + authorization = { + "from": from_addr, + "to": option["payTo"], + "value": option["amount"], + "validAfter": "0", + "validBefore": valid_before, + "nonce": nonce, + } + typed_data = { + "types": { + "EIP712Domain": [ + {"name": "name", "type": "string"}, + {"name": "version", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"}, + ], + "TransferWithAuthorization": [ + {"name": "from", "type": "address"}, + {"name": "to", "type": "address"}, + {"name": "value", "type": "uint256"}, + {"name": "validAfter", "type": "uint256"}, + {"name": "validBefore", "type": "uint256"}, + {"name": "nonce", "type": "bytes32"}, + ], + }, + "primaryType": "TransferWithAuthorization", + "domain": { + "name": option["extra"].get("name"), + "version": option["extra"].get("version"), + "chainId": chain_id, + "verifyingContract": option["asset"], + }, + "message": authorization, + } + return typed_data, authorization + + +def validate(option, chain_id): + """Check the offer is structurally sound before signing. + + Currency and spend policy are the wallet's responsibility; this only + guards protocol correctness so a malformed offer is never signed. + """ + if option["scheme"] != "exact": + raise CeremonyError("scheme must be 'exact', got %r" % option["scheme"]) + if chain_id is None: + raise CeremonyError("network %r is not supported by mm" % option.get("network")) + if not option["asset"] or not ADDRESS_RE.match(option["asset"]): + raise CeremonyError("asset is not a valid contract address: %r" % option.get("asset")) + if not option["amount"] or not option["amount"].isdigit() or int(option["amount"]) <= 0: + raise CeremonyError("amount must be a positive atomic-unit integer, got %r" % option["amount"]) + if not option["payTo"] or not ADDRESS_RE.match(option["payTo"]): + raise CeremonyError("payTo is not a valid address: %r" % option["payTo"]) + if not option["extra"].get("name") or not option["extra"].get("version"): + raise CeremonyError("402 option missing EIP-712 domain name/version in 'extra'") + + +def settlement(headers, version): + """Decode the facilitator's settlement receipt, if the server sent one.""" + name = "X-PAYMENT-RESPONSE" if version == 1 else "PAYMENT-RESPONSE" + raw = headers.get(name) + if not raw: + return None + return json.loads(base64.b64decode(raw)) + + +def cmd_inspect(url, method, data, content_type): + """Print the 402 payment requirement(s) without signing or spending.""" + body, headers = request_parts(data, content_type) + status, rheaders, rbody = http(url, method, headers, body) + version, options = parse_402(status, rheaders, rbody) + print(json.dumps({ + "status": "payment_required", + "x402Version": version, + "options": [describe(o) for o in options], + }, indent=2)) + + +def cmd_pay(url, method, data, content_type, confirm, want_asset, want_network): + """Run the payment for one offered option and print the settlement.""" + if not confirm: + raise CeremonyError("refusing to pay without --confirm; run 'inspect' first and " + "get user approval, then re-run 'pay --confirm'") + body, headers = request_parts(data, content_type) + # Fetch fresh so the short 402 window is never stale. + status, rheaders, rbody = http(url, method, headers, body) + version, options = parse_402(status, rheaders, rbody) + option = select(options, want_asset, want_network) + chain_id = option["chainId"] + validate(option, chain_id) + + from_addr = wallet_address() + typed_data, authorization = build_typed_data(option, chain_id, from_addr) + + intent = "x402: %s %s to %s for %s" % ( + option.get("humanAmount", option["amount"]), option.get("symbol", ""), + option["payTo"], url) + signature = sign_typed_data(chain_id, typed_data, intent) + + payment = {"x402Version": version, "scheme": option["scheme"], + "network": option["network"], + "payload": {"signature": signature, "authorization": authorization}} + b64 = base64.b64encode(json.dumps(payment).encode()).decode() + header = "X-PAYMENT" if version == 1 else "PAYMENT-SIGNATURE" + + # Replay the same request with the payment attached. + status, rheaders, rbody = http(url, method, dict(headers, **{header: b64}), body) + if status != 200: + # One attempt only: do not retry a payment. + raise CeremonyError("payment not accepted (HTTP %s): %s" % (status, rbody.decode("utf-8", "replace"))) + + settle = settlement(rheaders, version) + try: + resource = json.loads(rbody.decode("utf-8")) + except ValueError: + resource = rbody.decode("utf-8", "replace") + print(json.dumps({ + "status": "settled", + "asset": option.get("symbol", option["asset"]), + "amount": option.get("humanAmount", option["amount"]), + "network": option["network"], + "payTo": option["payTo"], + "transaction": (settle or {}).get("transaction") or (settle or {}).get("txHash"), + "settlement": settle, + "resource": resource, + }, indent=2)) + + +def main(argv=None): + """Parse arguments and report CeremonyError as JSON on stderr.""" + parser = argparse.ArgumentParser(description="Pay an HTTP 402 (x402) request with the active wallet.") + sub = parser.add_subparsers(dest="command", required=True) + + for p in (sub.add_parser("inspect", help="Fetch and parse a 402 (read-only)."), + sub.add_parser("pay", help="Run the full payment ceremony.")): + p.add_argument("url") + p.add_argument("--method", default="GET", + help="HTTP method to request the resource with (default GET).") + p.add_argument("--data", help="Request body to send (and replay on the paid retry).") + p.add_argument("--content-type", default="application/json", + help="Content-Type for --data (default application/json).") + + p_pay = sub.choices["pay"] + p_pay.add_argument("--confirm", action="store_true", + help="Required. Explicit user approval to sign and spend.") + p_pay.add_argument("--asset", help="Disambiguate by asset contract when multiple are offered.") + p_pay.add_argument("--network", help="Disambiguate by network when multiple are offered.") + + args = parser.parse_args(argv) + try: + if args.command == "inspect": + cmd_inspect(args.url, args.method.upper(), args.data, args.content_type) + else: + cmd_pay(args.url, args.method.upper(), args.data, args.content_type, + args.confirm, args.asset, args.network) + except CeremonyError as e: + print(json.dumps({"status": "error", "error": str(e)}), file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/metamask-agent-wallet/workflows/x402-pay.md b/skills/metamask-agent-wallet/workflows/x402-pay.md new file mode 100644 index 0000000..aa65a53 --- /dev/null +++ b/skills/metamask-agent-wallet/workflows/x402-pay.md @@ -0,0 +1,51 @@ +# x402 pay workflow (buyer) + +Use this when an HTTP request returns `402 Payment Required` (x402), or the user asks to +fetch/pay for a paywalled API, endpoint, file, or resource over HTTP. + +`scripts/x402_pay.py` does the payment. Command details are in `references/x402.md`. + +## Flow + +1. Inspect the resource. +2. Show the payment to the user and get approval. +3. Pay. +4. Report the settlement and return the resource. + +## Inspect + +```bash +python3 scripts/x402_pay.py inspect +``` + +Prints the payment requirement(s) as JSON: asset, human-readable amount, network, `payTo`, and +resource. This is read-only and does not spend. + +## Confirm + +Show the user the asset, amount, network, `payTo`, and resource URL from the inspect output, and +get explicit approval. A signature authorizes a real token debit. + +## Pay + +```bash +python3 scripts/x402_pay.py pay --confirm +``` + +Add `--asset ` or `--network ` if the `402` offered more than one eligible +option. For a non-GET resource add `--method` (and `--data` for a body); the same request is +replayed with the payment attached. On success the script prints the settlement transaction and +the resource body. + +## Edge cases + +- `error` with "no eligible option": the server offered no `exact`-scheme payment on a network mm + supports. Show the offered options to the user. +- `error` with "multiple eligible options": rerun `pay` with `--asset` or `--network`. +- `error` with "payment not accepted": surface it. Do not rerun blindly; rerunning makes a new + payment. +- `error` with "not a standard x402 challenge": the endpoint returned a 402 in a different payment + scheme (for example pay-first then send a transaction hash). This skill supports the standard + x402 exact scheme only. Tell the user it is unsupported rather than trying to pay. +- Encrypted BYOK mnemonic: set `MM_PASSWORD` so signing is non-interactive. +- Unknown network: the network is not in `mm chains list`; confirm the chain is supported. From b76ee4e7517909e7d01cbf6323ed6596ef568973 Mon Sep 17 00:00:00 2001 From: basgys Date: Thu, 25 Jun 2026 16:50:38 +0200 Subject: [PATCH 2/2] 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: 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; surface the rejection reason from the PAYMENT-REQUIRED header when a paid retry fails; document the v2 payload difference, multi-network selection, and unsupported cases. Verified against the official x402 SDK and the v2 spec vectors, end to end on Base Sepolia against real facilitators (v1 and v2), and against a real third-party endpoint up to contract-level verification. --- .../metamask-agent-wallet/references/x402.md | 34 +- .../metamask-agent-wallet/scripts/x402_pay.py | 303 ++++++++++++++---- .../workflows/x402-pay.md | 14 +- 3 files changed, 273 insertions(+), 78 deletions(-) diff --git a/skills/metamask-agent-wallet/references/x402.md b/skills/metamask-agent-wallet/references/x402.md index d1f8e05..2c93049 100644 --- a/skills/metamask-agent-wallet/references/x402.md +++ b/skills/metamask-agent-wallet/references/x402.md @@ -21,15 +21,39 @@ in the wallet. The script is pure Python standard library (run with `python3`). The script handles both protocol versions: v1 (requirements in the `402` body, retry header `X-PAYMENT`) and v2 (requirements in the `PAYMENT-REQUIRED` header, retry header -`PAYMENT-SIGNATURE`). +`PAYMENT-SIGNATURE`). The payload envelope also differs: v1 puts `scheme`/`network` at the top +level, while v2 nests the chosen requirements under `accepted` and forwards the top-level +`resource` (see the x402 v2 spec, PaymentPayload schema). The script builds the correct envelope +per version. + +### Supported + +- The `exact` scheme on EVM networks (`eip155:*`) using EIP-3009 + `transferWithAuthorization`, protocol v1 and v2. + +### Not supported (rejected with a clear error) + +- The Permit2 asset transfer method (`extra.assetTransferMethod: "permit2"`) and its gas-sponsoring + extensions. An EIP-3009 option on the same asset is preferred when offered; a Permit2-only offer + is reported as unsupported. Signing a Permit2 authorization is a separate flow. +- Other schemes such as `upto`, and non-EVM networks (e.g. Solana). +- Offers that omit the EIP-712 domain `name`/`version` in `extra`. The reference client falls back + to reading them on-chain; this script does not, so it asks for an offer that includes them rather + than risk signing the wrong domain. ## Usage +`scripts/x402_pay.py` is a helper script (not an `mm` subcommand). Two things follow from that: +call it by its **full path inside this skill's directory**, since the shell's working directory is +not stable between commands and a bare `scripts/x402_pay.py` will not be found; and **do not pass +the global `--toon`/`--format` flags** to it, as it always prints JSON (it accepts and ignores them +if passed). Let `SKILL_DIR` be this skill's base directory (the folder containing `SKILL.md`). + Inspect first (read-only: no signing, no spending), show the result to the user, then pay. ```bash -python3 scripts/x402_pay.py inspect [--method M] [--data BODY] -python3 scripts/x402_pay.py pay --confirm [--method M] [--data BODY] [--asset ] [--network ] +python3 "$SKILL_DIR/scripts/x402_pay.py" inspect [--method M] [--data BODY] +python3 "$SKILL_DIR/scripts/x402_pay.py" pay --confirm [--method M] [--data BODY] [--asset ] [--network ] ``` `inspect` prints the payment requirement(s) as JSON, including the human-readable amount, asset @@ -44,9 +68,9 @@ same as a `GET`. `--data` defaults to a JSON content type, overridable with `--c ### Example ```bash -python3 scripts/x402_pay.py inspect https://api.example.com/premium +python3 "$SKILL_DIR/scripts/x402_pay.py" inspect https://api.example.com/premium # review the amount / payTo / resource with the user, then: -python3 scripts/x402_pay.py pay https://api.example.com/premium --confirm +python3 "$SKILL_DIR/scripts/x402_pay.py" pay https://api.example.com/premium --confirm ``` ## What the script checks diff --git a/skills/metamask-agent-wallet/scripts/x402_pay.py b/skills/metamask-agent-wallet/scripts/x402_pay.py index c0a0d65..1e4f294 100644 --- a/skills/metamask-agent-wallet/scripts/x402_pay.py +++ b/skills/metamask-agent-wallet/scripts/x402_pay.py @@ -114,7 +114,7 @@ def _mm_json(args): start, end = text.find("{"), text.rfind("}") if start == -1 or end == -1: raise CeremonyError("mm produced no JSON (%s): %s" % (out.returncode, out.stderr.strip())) - return json.loads(text[start:end + 1]) + return json.loads(text[start : end + 1]) _chains_cache = None @@ -140,7 +140,10 @@ def chain_id_for(network): except ValueError: raise CeremonyError("unparseable CAIP-2 network: %s" % network) for c in _chains(): - if network in (c.get("key"), c.get("caip2")) or network.lower() == (c.get("name") or "").lower(): + if ( + network in (c.get("key"), c.get("caip2")) + or network.lower() == (c.get("name") or "").lower() + ): return c.get("chainId") raise CeremonyError("network '%s' is not supported by mm (see `mm chains list`)" % network) @@ -160,8 +163,15 @@ def asset_meta(chain_id, asset): if key not in _asset_meta_cache: meta = None try: - res = _mm_json(["token", "assets", "--asset-ids", - "eip155:%s/erc20:%s" % (chain_id, asset), "--json"]) + res = _mm_json( + [ + "token", + "assets", + "--asset-ids", + "eip155:%s/erc20:%s" % (chain_id, asset), + "--json", + ] + ) assets = (res.get("data") or {}).get("assets") or [] if assets and assets[0].get("decimals") is not None: meta = {"symbol": assets[0].get("symbol"), "decimals": assets[0]["decimals"]} @@ -172,7 +182,11 @@ def asset_meta(chain_id, asset): def parse_402(status, headers, body): - """Return (version, [normalized_option, ...]). Raises if not a usable 402.""" + """Return (version, [option, ...], resource_info). Raises if not a usable 402. + + resource_info is the v2 top-level ResourceInfo object (v2 forwards it into + the payment payload); it is None for v1, which carries resource per option. + """ if status != 402: raise CeremonyError("expected HTTP 402, got %s" % status) @@ -198,26 +212,30 @@ def parse_402(status, headers, body): raise CeremonyError( "received HTTP 402 but it is not a standard x402 challenge " "(no PAYMENT-REQUIRED header and no accepts[] in the body); " - "this endpoint uses a payment scheme this script does not support") + "this endpoint uses a payment scheme this script does not support" + ) accepts = (data.get("accepts") if isinstance(data, dict) else None) or [] options = [] for a in accepts: amount = a.get("amount", a.get("maxAmountRequired")) - options.append({ - "scheme": a.get("scheme"), - "network": a.get("network"), - "amount": str(amount) if amount is not None else None, - "payTo": a.get("payTo"), - "asset": a.get("asset"), - "maxTimeoutSeconds": a.get("maxTimeoutSeconds", 60), - "extra": a.get("extra", {}), - "resource": a.get("resource"), - }) + options.append( + { + "scheme": a.get("scheme"), + "network": a.get("network"), + "amount": str(amount) if amount is not None else None, + "payTo": a.get("payTo"), + "asset": a.get("asset"), + "maxTimeoutSeconds": a.get("maxTimeoutSeconds", 3600), + "extra": a.get("extra", {}), + "resource": a.get("resource"), + } + ) if not options: raise CeremonyError("402 had no payment options") - return version, options + resource_info = data.get("resource") if isinstance(data, dict) else None + return version, options, resource_info def describe(option): @@ -228,7 +246,15 @@ def describe(option): except CeremonyError: chain_id = None out["chainId"] = chain_id - out["eligible"] = chain_id is not None and option.get("scheme") == "exact" + # extra.assetTransferMethod is absent on v1 and on eip3009 v2 offers; only an + # explicit "permit2" is ineligible, since this script signs EIP-3009 only. + transfer = (option.get("extra") or {}).get("assetTransferMethod") + out["assetTransferMethod"] = transfer or "eip3009" + out["eligible"] = ( + chain_id is not None + and option.get("scheme") == "exact" + and out["assetTransferMethod"] == "eip3009" + ) meta = asset_meta(chain_id, option.get("asset")) if meta: out["symbol"] = meta["symbol"] @@ -257,13 +283,24 @@ def select(options, want_asset=None, want_network=None): continue eligible.append(d) if not eligible: + exact_on_chain = [ + d for d in described if d.get("chainId") is not None and d.get("scheme") == "exact" + ] + if exact_on_chain and all(d["assetTransferMethod"] == "permit2" for d in exact_on_chain): + raise CeremonyError( + "the only payable options use the permit2 asset transfer method, which this " + "script does not support (it signs EIP-3009 transferWithAuthorization only). " + "Offered: %s" % json.dumps(described) + ) raise CeremonyError( - "no eligible option (need scheme 'exact' on a network mm supports). " - "Offered: %s" % json.dumps(described)) + "no eligible option (need scheme 'exact', EIP-3009, on a network mm supports). " + "Offered: %s" % json.dumps(described) + ) if len(eligible) > 1: raise CeremonyError( "multiple eligible options; disambiguate with --asset/--network. " - "Eligible: %s" % json.dumps(eligible)) + "Eligible: %s" % json.dumps(eligible) + ) return eligible[0] @@ -275,9 +312,20 @@ def wallet_address(): def sign_typed_data(chain_id, payload, intent): """Sign EIP-712 typed data with `mm`, keeping the key inside the wallet.""" - res = _mm_json(["wallet", "sign-typed-data", "--chain-id", str(chain_id), - "--wait", "--json", "--intent", intent, - "--payload", json.dumps(payload)]) + res = _mm_json( + [ + "wallet", + "sign-typed-data", + "--chain-id", + str(chain_id), + "--wait", + "--json", + "--intent", + intent, + "--payload", + json.dumps(payload), + ] + ) sig = (res.get("data") or {}).get("signature") if not res.get("ok") or not sig: raise CeremonyError("signing failed: %s" % json.dumps(res)) @@ -292,14 +340,18 @@ def build_typed_data(option, chain_id, from_addr): authorization may live is the wallet's policy, not a constant here. A random nonce keeps each authorization single-use. """ + now = int(time.time()) nonce = "0x" + secrets.token_bytes(32).hex() - valid_before = str(int(time.time()) + int(option["maxTimeoutSeconds"])) authorization = { "from": from_addr, "to": option["payTo"], "value": option["amount"], - "validAfter": "0", - "validBefore": valid_before, + # Backdate validAfter: the spec allows "0", and the official Python + # client uses it, but the TypeScript reference client backdates 10 + # minutes because some facilitators reject "0" and it absorbs clock + # skew. Follow the safer value. + "validAfter": str(now - 600), + "validBefore": str(now + int(option["maxTimeoutSeconds"])), "nonce": nonce, } typed_data = { @@ -331,6 +383,37 @@ def build_typed_data(option, chain_id, from_addr): return typed_data, authorization +def build_payment(version, option, resource_info, signature, authorization, url): + """Assemble the PaymentPayload for the chosen option. + + The envelope differs by version (x402 spec section 5.2): v2 nests the chosen + requirements under `accepted` and forwards the `resource`, while v1 puts + `scheme`/`network` at the top level. A facilitator rejects a v2 payload that + is missing `accepted`. + """ + if version == 2: + return { + "x402Version": 2, + "resource": resource_info or {"url": url}, + "accepted": { + "scheme": option["scheme"], + "network": option["network"], + "amount": option["amount"], + "asset": option["asset"], + "payTo": option["payTo"], + "maxTimeoutSeconds": option["maxTimeoutSeconds"], + "extra": option.get("extra", {}), + }, + "payload": {"signature": signature, "authorization": authorization}, + } + return { + "x402Version": 1, + "scheme": option["scheme"], + "network": option["network"], + "payload": {"signature": signature, "authorization": authorization}, + } + + def validate(option, chain_id): """Check the offer is structurally sound before signing. @@ -344,43 +427,91 @@ def validate(option, chain_id): if not option["asset"] or not ADDRESS_RE.match(option["asset"]): raise CeremonyError("asset is not a valid contract address: %r" % option.get("asset")) if not option["amount"] or not option["amount"].isdigit() or int(option["amount"]) <= 0: - raise CeremonyError("amount must be a positive atomic-unit integer, got %r" % option["amount"]) + raise CeremonyError( + "amount must be a positive atomic-unit integer, got %r" % option["amount"] + ) if not option["payTo"] or not ADDRESS_RE.match(option["payTo"]): raise CeremonyError("payTo is not a valid address: %r" % option["payTo"]) if not option["extra"].get("name") or not option["extra"].get("version"): raise CeremonyError("402 option missing EIP-712 domain name/version in 'extra'") -def settlement(headers, version): - """Decode the facilitator's settlement receipt, if the server sent one.""" +def settlement(headers, version, body): + """Decode the facilitator's settlement receipt. + + The spec puts it in the X-PAYMENT-RESPONSE / PAYMENT-RESPONSE header, but + some servers return it only in the response body, so fall back to the body + when the header is absent. + """ name = "X-PAYMENT-RESPONSE" if version == 1 else "PAYMENT-RESPONSE" raw = headers.get(name) - if not raw: + if raw: + try: + return json.loads(base64.b64decode(raw)) + except (ValueError, UnicodeDecodeError): + pass + try: + data = json.loads(body.decode("utf-8")) + except (ValueError, UnicodeDecodeError): return None - return json.loads(base64.b64decode(raw)) + if isinstance(data, dict) and (data.get("transaction") or data.get("txHash")): + return data + return None + + +def failure_reason(headers, body): + """Extract why a paid retry was rejected. + + A v2 server re-challenges with the reason in the PAYMENT-REQUIRED header and + often an empty body, so check that header before falling back to the body. + """ + pr = headers.get("PAYMENT-REQUIRED") + if pr: + try: + err = json.loads(base64.b64decode(pr)).get("error") + if err: + return err + except (ValueError, UnicodeDecodeError): + pass + text = body.decode("utf-8", "replace").strip() + try: + err = json.loads(text).get("error") + if err: + return err + except (ValueError, AttributeError): + pass + return text or "(no detail)" def cmd_inspect(url, method, data, content_type): """Print the 402 payment requirement(s) without signing or spending.""" body, headers = request_parts(data, content_type) status, rheaders, rbody = http(url, method, headers, body) - version, options = parse_402(status, rheaders, rbody) - print(json.dumps({ - "status": "payment_required", - "x402Version": version, - "options": [describe(o) for o in options], - }, indent=2)) + version, options, resource_info = parse_402(status, rheaders, rbody) + print( + json.dumps( + { + "status": "payment_required", + "x402Version": version, + "resource": resource_info, + "options": [describe(o) for o in options], + }, + indent=2, + ) + ) def cmd_pay(url, method, data, content_type, confirm, want_asset, want_network): """Run the payment for one offered option and print the settlement.""" if not confirm: - raise CeremonyError("refusing to pay without --confirm; run 'inspect' first and " - "get user approval, then re-run 'pay --confirm'") + raise CeremonyError( + "refusing to pay without --confirm; run 'inspect' first and " + "get user approval, then re-run 'pay --confirm'" + ) body, headers = request_parts(data, content_type) # Fetch fresh so the short 402 window is never stale. status, rheaders, rbody = http(url, method, headers, body) - version, options = parse_402(status, rheaders, rbody) + version, options, resource_info = parse_402(status, rheaders, rbody) option = select(options, want_asset, want_network) chain_id = option["chainId"] validate(option, chain_id) @@ -389,13 +520,14 @@ def cmd_pay(url, method, data, content_type, confirm, want_asset, want_network): typed_data, authorization = build_typed_data(option, chain_id, from_addr) intent = "x402: %s %s to %s for %s" % ( - option.get("humanAmount", option["amount"]), option.get("symbol", ""), - option["payTo"], url) + option.get("humanAmount", option["amount"]), + option.get("symbol", ""), + option["payTo"], + url, + ) signature = sign_typed_data(chain_id, typed_data, intent) - payment = {"x402Version": version, "scheme": option["scheme"], - "network": option["network"], - "payload": {"signature": signature, "authorization": authorization}} + payment = build_payment(version, option, resource_info, signature, authorization, url) b64 = base64.b64encode(json.dumps(payment).encode()).decode() header = "X-PAYMENT" if version == 1 else "PAYMENT-SIGNATURE" @@ -403,42 +535,68 @@ def cmd_pay(url, method, data, content_type, confirm, want_asset, want_network): status, rheaders, rbody = http(url, method, dict(headers, **{header: b64}), body) if status != 200: # One attempt only: do not retry a payment. - raise CeremonyError("payment not accepted (HTTP %s): %s" % (status, rbody.decode("utf-8", "replace"))) + raise CeremonyError( + "payment not accepted (HTTP %s): %s" % (status, failure_reason(rheaders, rbody)) + ) - settle = settlement(rheaders, version) + settle = settlement(rheaders, version, rbody) try: resource = json.loads(rbody.decode("utf-8")) except ValueError: resource = rbody.decode("utf-8", "replace") - print(json.dumps({ - "status": "settled", - "asset": option.get("symbol", option["asset"]), - "amount": option.get("humanAmount", option["amount"]), - "network": option["network"], - "payTo": option["payTo"], - "transaction": (settle or {}).get("transaction") or (settle or {}).get("txHash"), - "settlement": settle, - "resource": resource, - }, indent=2)) + print( + json.dumps( + { + "status": "settled", + "asset": option.get("symbol", option["asset"]), + "amount": option.get("humanAmount", option["amount"]), + "network": option["network"], + "payTo": option["payTo"], + "transaction": (settle or {}).get("transaction") or (settle or {}).get("txHash"), + "settlement": settle, + "resource": resource, + }, + indent=2, + ) + ) def main(argv=None): """Parse arguments and report CeremonyError as JSON on stderr.""" - parser = argparse.ArgumentParser(description="Pay an HTTP 402 (x402) request with the active wallet.") + parser = argparse.ArgumentParser( + description="Pay an HTTP 402 (x402) request with the active wallet." + ) sub = parser.add_subparsers(dest="command", required=True) - for p in (sub.add_parser("inspect", help="Fetch and parse a 402 (read-only)."), - sub.add_parser("pay", help="Run the full payment ceremony.")): + # The skill tells agents to "always use --toon"; this is a script, not an mm + # command, and always prints JSON. Accept and ignore those format flags so + # the reflex does not error. + fmt = argparse.ArgumentParser(add_help=False) + fmt.add_argument("--toon", action="store_true", help=argparse.SUPPRESS) + fmt.add_argument("--json", action="store_true", help=argparse.SUPPRESS) + fmt.add_argument("-f", "--format", help=argparse.SUPPRESS) + + for p in ( + sub.add_parser("inspect", parents=[fmt], help="Fetch and parse a 402 (read-only)."), + sub.add_parser("pay", parents=[fmt], help="Run the full payment ceremony."), + ): p.add_argument("url") - p.add_argument("--method", default="GET", - help="HTTP method to request the resource with (default GET).") + p.add_argument( + "--method", + default="GET", + help="HTTP method to request the resource with (default GET).", + ) p.add_argument("--data", help="Request body to send (and replay on the paid retry).") - p.add_argument("--content-type", default="application/json", - help="Content-Type for --data (default application/json).") + p.add_argument( + "--content-type", + default="application/json", + help="Content-Type for --data (default application/json).", + ) p_pay = sub.choices["pay"] - p_pay.add_argument("--confirm", action="store_true", - help="Required. Explicit user approval to sign and spend.") + p_pay.add_argument( + "--confirm", action="store_true", help="Required. Explicit user approval to sign and spend." + ) p_pay.add_argument("--asset", help="Disambiguate by asset contract when multiple are offered.") p_pay.add_argument("--network", help="Disambiguate by network when multiple are offered.") @@ -447,8 +605,15 @@ def main(argv=None): if args.command == "inspect": cmd_inspect(args.url, args.method.upper(), args.data, args.content_type) else: - cmd_pay(args.url, args.method.upper(), args.data, args.content_type, - args.confirm, args.asset, args.network) + cmd_pay( + args.url, + args.method.upper(), + args.data, + args.content_type, + args.confirm, + args.asset, + args.network, + ) except CeremonyError as e: print(json.dumps({"status": "error", "error": str(e)}), file=sys.stderr) return 1 diff --git a/skills/metamask-agent-wallet/workflows/x402-pay.md b/skills/metamask-agent-wallet/workflows/x402-pay.md index aa65a53..36d70b6 100644 --- a/skills/metamask-agent-wallet/workflows/x402-pay.md +++ b/skills/metamask-agent-wallet/workflows/x402-pay.md @@ -3,7 +3,10 @@ Use this when an HTTP request returns `402 Payment Required` (x402), or the user asks to fetch/pay for a paywalled API, endpoint, file, or resource over HTTP. -`scripts/x402_pay.py` does the payment. Command details are in `references/x402.md`. +`scripts/x402_pay.py` does the payment. Command details are in `references/x402.md`. Call it by +its full path inside this skill's directory (`$SKILL_DIR` is the folder containing `SKILL.md`), not +a bare `scripts/...`, because the shell working directory is not stable between commands. It is a +script, not an `mm` command, so do not pass `--toon`/`--format`; it always prints JSON. ## Flow @@ -15,7 +18,7 @@ fetch/pay for a paywalled API, endpoint, file, or resource over HTTP. ## Inspect ```bash -python3 scripts/x402_pay.py inspect +python3 "$SKILL_DIR/scripts/x402_pay.py" inspect ``` Prints the payment requirement(s) as JSON: asset, human-readable amount, network, `payTo`, and @@ -29,7 +32,7 @@ get explicit approval. A signature authorizes a real token debit. ## Pay ```bash -python3 scripts/x402_pay.py pay --confirm +python3 "$SKILL_DIR/scripts/x402_pay.py" pay --confirm ``` Add `--asset ` or `--network ` if the `402` offered more than one eligible @@ -41,7 +44,10 @@ the resource body. - `error` with "no eligible option": the server offered no `exact`-scheme payment on a network mm supports. Show the offered options to the user. -- `error` with "multiple eligible options": rerun `pay` with `--asset` or `--network`. +- `error` with "multiple eligible options": the server offered the same scheme on several networks + or assets (e.g. Base and Polygon). Rerun `pay` with `--network` or `--asset` to choose one. +- `error` mentioning "permit2": the only options use the Permit2 transfer method, which this skill + does not sign (it supports EIP-3009 only). Tell the user it is unsupported. - `error` with "payment not accepted": surface it. Do not rerun blindly; rerunning makes a new payment. - `error` with "not a standard x402 challenge": the endpoint returned a 402 in a different payment