Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions skills/metamask-agent-wallet/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +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) |
| Pay an HTTP `402` / x402 paywalled request or prepare MCP x402 payment metadata | `python3 scripts/x402_pay.py` | [x402.md](references/x402.md) |

## Workflows

Expand Down Expand Up @@ -134,7 +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) |
| Pay an HTTP `402` (x402) paywalled request or MCP x402 tool call | [x402-pay.md](workflows/x402-pay.md) |

## Global Flags

Expand Down Expand Up @@ -236,7 +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. |
| x402 payments | Always confirm asset, decimals-correct amount, network, `payTo`, and HTTP or MCP 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 |
Expand Down
59 changes: 48 additions & 11 deletions skills/metamask-agent-wallet/references/x402.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
# 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.
[x402 protocol](https://docs.x402.org), when an MCP tool returns an x402
`PaymentRequired` result, or when the user asks to pay for / fetch a paywalled API,
endpoint, file, tool, or resource.

`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`).
payment header, and reports the settlement. For MCP, it parses the tool result's
`PaymentRequired` data and can prepare the `_meta["x402/payment"]` object that an MCP client
uses on the retry. 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)

Expand All @@ -19,17 +22,23 @@ in the wallet. The script is pure Python standard library (run with `python3`).
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
The script handles both HTTP 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.

For MCP transport, x402 uses v2 `PaymentRequired` data in the tool result. Servers must return it
in `result.structuredContent` and in `result.content[0].text`; the script prefers
`structuredContent` and falls back to parsing the text field. The payment retry is not an HTTP
header. It is the MCP request parameter `_meta["x402/payment"]`, and settlement is returned in
`_meta["x402/payment-response"]`.

### Supported

- The `exact` scheme on EVM networks (`eip155:*`) using EIP-3009
`transferWithAuthorization`, protocol v1 and v2.
`transferWithAuthorization`, HTTP protocol v1/v2 and MCP transport v2.

### Not supported (rejected with a clear error)

Expand All @@ -54,6 +63,8 @@ Inspect first (read-only: no signing, no spending), show the result to the user,
```bash
python3 "$SKILL_DIR/scripts/x402_pay.py" inspect <url> [--method M] [--data BODY]
python3 "$SKILL_DIR/scripts/x402_pay.py" pay <url> --confirm [--method M] [--data BODY] [--asset <contract>] [--network <network>]
python3 "$SKILL_DIR/scripts/x402_pay.py" inspect-mcp <payment-required-json-or-file>
python3 "$SKILL_DIR/scripts/x402_pay.py" prepare-mcp <payment-required-json-or-file> --confirm [--asset <contract>] [--network <network>]
```

`inspect` prints the payment requirement(s) as JSON, including the human-readable amount, asset
Expand All @@ -65,6 +76,23 @@ The resource may use any HTTP method. For a non-GET endpoint pass `--method` (an
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`.

For MCP, pass either a raw `PaymentRequired` JSON object, a full MCP JSON-RPC tool result, a file
path containing one of those, or `-` to read from stdin. `inspect-mcp` is read-only. `prepare-mcp`
signs an authorization and prints:

```json
{
"meta": {
"x402/payment": {
"...": "PaymentPayload"
}
}
}
```

The caller must retry the MCP `tools/call` with `params._meta["x402/payment"]` set to that payment
object. `prepare-mcp` does not call the MCP tool and does not perform settlement itself.

### Example

```bash
Expand All @@ -73,6 +101,14 @@ python3 "$SKILL_DIR/scripts/x402_pay.py" inspect https://api.example.com/premium
python3 "$SKILL_DIR/scripts/x402_pay.py" pay https://api.example.com/premium --confirm
```

### MCP example

```bash
python3 "$SKILL_DIR/scripts/x402_pay.py" inspect-mcp payment-required.json
# review asset / amount / network / payTo / mcp:// resource with the user, then:
python3 "$SKILL_DIR/scripts/x402_pay.py" prepare-mcp payment-required.json --confirm
```

## What the script checks

It pays an option whose `scheme` is `exact` on a network `mm` supports (resolved from
Expand Down Expand Up @@ -103,12 +139,13 @@ amount; the signed value is the server's atomic amount either way.

## 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.
Run `inspect` or `inspect-mcp` and show the user the asset, amount, network, `payTo`, and resource
URL. Run `pay ... --confirm` or `prepare-mcp ... --confirm` only after the user approves. A
signature authorizes a real token debit if submitted to the facilitator/server.

## 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.
twice, and each run makes one payment, but rerunning `pay` or `prepare-mcp` for the same resource
makes a new authorization. Guard repeated calls at the caller. A local idempotency ledger (a
caller-supplied key mapped to prior settlements) is a possible future improvement.
179 changes: 176 additions & 3 deletions skills/metamask-agent-wallet/scripts/x402_pay.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
python3 x402_pay.py inspect <url> [--method M] [--data BODY]
python3 x402_pay.py pay <url> --confirm [--method M] [--data BODY]
[--asset <contract>] [--network <network>]
python3 x402_pay.py inspect-mcp <payment-required-json-or-file>
python3 x402_pay.py prepare-mcp <payment-required-json-or-file> --confirm
[--asset <contract>] [--network <network>]

`inspect` fetches the URL and prints the payment requirement(s) as JSON
(asset, amount, network, payTo, resource) without signing or spending. Review
Expand All @@ -31,6 +34,7 @@
Examples:
python3 x402_pay.py inspect https://api.example.com/premium
python3 x402_pay.py pay https://api.example.com/premium --confirm
python3 x402_pay.py inspect-mcp mcp-payment-required.json
"""

import argparse
Expand Down Expand Up @@ -238,12 +242,91 @@ def parse_402(status, headers, body):
return version, options, resource_info


def read_json_source(source):
"""Read JSON from a file path, stdin (`-`), or an inline JSON string."""
if source == "-":
text = sys.stdin.read()
else:
try:
with open(source, "r", encoding="utf-8") as f:
text = f.read()
except OSError:
text = source
try:
return json.loads(text)
except ValueError as e:
raise CeremonyError("could not parse JSON input: %s" % e)


def _looks_like_payment_required(data):
return isinstance(data, dict) and data.get("x402Version") is not None and "accepts" in data


def parse_mcp_payment_required(data):
"""Extract PaymentRequired from an MCP tool result or raw PaymentRequired.

Per the x402 MCP transport, clients should prefer result.structuredContent
and fall back to parsing result.content[0].text.
"""
candidate = data
if isinstance(data, dict) and "result" in data:
candidate = data.get("result")

if _looks_like_payment_required(candidate):
payment_required = candidate
elif isinstance(candidate, dict) and _looks_like_payment_required(
candidate.get("structuredContent")
):
payment_required = candidate["structuredContent"]
else:
payment_required = None
content = candidate.get("content") if isinstance(candidate, dict) else None
if isinstance(content, list) and content:
text = content[0].get("text") if isinstance(content[0], dict) else None
if text:
try:
parsed = json.loads(text)
except ValueError as e:
raise CeremonyError("could not parse MCP content[0].text as JSON: %s" % e)
if _looks_like_payment_required(parsed):
payment_required = parsed

if not _looks_like_payment_required(payment_required):
raise CeremonyError(
"input did not contain MCP PaymentRequired data "
"(expected x402Version and accepts in structuredContent or content[0].text)"
)

version = payment_required.get("x402Version")
if version != 2:
raise CeremonyError("MCP transport requires x402Version 2, got %r" % version)

options = []
for a in payment_required.get("accepts") or []:
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("MCP PaymentRequired had no payment options")
return version, options, payment_required.get("resource"), payment_required.get("error")


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:
except (CeremonyError, OSError):
chain_id = None
out["chainId"] = chain_id
# extra.assetTransferMethod is absent on v1 and on eip3009 v2 offers; only an
Expand All @@ -255,7 +338,10 @@ def describe(option):
and option.get("scheme") == "exact"
and out["assetTransferMethod"] == "eip3009"
)
meta = asset_meta(chain_id, option.get("asset"))
try:
meta = asset_meta(chain_id, option.get("asset"))
except OSError:
meta = None
if meta:
out["symbol"] = meta["symbol"]
out["decimals"] = meta["decimals"]
Expand Down Expand Up @@ -501,6 +587,66 @@ def cmd_inspect(url, method, data, content_type):
)


def cmd_inspect_mcp(source):
"""Print MCP PaymentRequired options without signing or spending."""
data = read_json_source(source)
version, options, resource_info, error = parse_mcp_payment_required(data)
print(
json.dumps(
{
"status": "payment_required",
"transport": "mcp",
"x402Version": version,
"error": error,
"resource": resource_info,
"options": [describe(o) for o in options],
"retryMetaKey": "x402/payment",
"settlementMetaKey": "x402/payment-response",
},
indent=2,
)
)


def cmd_prepare_mcp(source, confirm, want_asset, want_network):
"""Sign and print an MCP _meta payment payload without calling the tool."""
if not confirm:
raise CeremonyError(
"refusing to sign without --confirm; run 'inspect-mcp' first and "
"get user approval, then re-run 'prepare-mcp <json> --confirm'"
)
data = read_json_source(source)
version, options, resource_info, _error = parse_mcp_payment_required(data)
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)
resource_url = (resource_info or {}).get("url") or option.get("resource") or "mcp://unknown"

intent = "x402 MCP: %s %s to %s for %s" % (
option.get("humanAmount", option["amount"]),
option.get("symbol", ""),
option["payTo"],
resource_url,
)
signature = sign_typed_data(chain_id, typed_data, intent)
payment = build_payment(version, option, resource_info, signature, authorization, resource_url)
print(
json.dumps(
{
"status": "prepared",
"transport": "mcp",
"meta": {"x402/payment": payment},
"payment": payment,
"usage": "retry the MCP tools/call with params._meta[\"x402/payment\"] set to this payment object",
},
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:
Expand Down Expand Up @@ -600,11 +746,34 @@ def main(argv=None):
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.")

p_inspect_mcp = sub.add_parser(
"inspect-mcp", parents=[fmt], help="Parse an MCP PaymentRequired result (read-only)."
)
p_inspect_mcp.add_argument(
"source", help="MCP JSON-RPC result, raw PaymentRequired JSON, file path, or '-' for stdin."
)

p_prepare_mcp = sub.add_parser(
"prepare-mcp", parents=[fmt], help="Sign and print an MCP _meta payment payload."
)
p_prepare_mcp.add_argument(
"source", help="MCP JSON-RPC result, raw PaymentRequired JSON, file path, or '-' for stdin."
)
p_prepare_mcp.add_argument(
"--confirm", action="store_true", help="Required. Explicit user approval to sign."
)
p_prepare_mcp.add_argument(
"--asset", help="Disambiguate by asset contract when multiple are offered."
)
p_prepare_mcp.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:
elif args.command == "pay":
cmd_pay(
args.url,
args.method.upper(),
Expand All @@ -614,6 +783,10 @@ def main(argv=None):
args.asset,
args.network,
)
elif args.command == "inspect-mcp":
cmd_inspect_mcp(args.source)
else:
cmd_prepare_mcp(args.source, args.confirm, args.asset, args.network)
except CeremonyError as e:
print(json.dumps({"status": "error", "error": str(e)}), file=sys.stderr)
return 1
Expand Down
Loading