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..2c93049 --- /dev/null +++ b/skills/metamask-agent-wallet/references/x402.md @@ -0,0 +1,114 @@ +# 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`). 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 "$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 +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 "$SKILL_DIR/scripts/x402_pay.py" inspect https://api.example.com/premium +# review the amount / payTo / resource with the user, then: +python3 "$SKILL_DIR/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..1e4f294 --- /dev/null +++ b/skills/metamask-agent-wallet/scripts/x402_pay.py @@ -0,0 +1,624 @@ +#!/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, [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) + + # 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", 3600), + "extra": a.get("extra", {}), + "resource": a.get("resource"), + } + ) + if not options: + raise CeremonyError("402 had no payment options") + resource_info = data.get("resource") if isinstance(data, dict) else None + return version, options, resource_info + + +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 + # 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"] + 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: + 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', 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) + ) + 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. + """ + now = int(time.time()) + nonce = "0x" + secrets.token_bytes(32).hex() + authorization = { + "from": from_addr, + "to": option["payTo"], + "value": option["amount"], + # 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 = { + "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 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. + + 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, 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 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 + 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, 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'" + ) + 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, resource_info = 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 = 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" + + # 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, failure_reason(rheaders, rbody)) + ) + + 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, + ) + ) + + +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) + + # 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("--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..36d70b6 --- /dev/null +++ b/skills/metamask-agent-wallet/workflows/x402-pay.md @@ -0,0 +1,57 @@ +# 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`. 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 + +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 "$SKILL_DIR/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 "$SKILL_DIR/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": 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 + 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.