Skip to content

feat: add x402 buyer payment support#16

Merged
chaitanyapotti merged 2 commits into
MetaMask:mainfrom
basgys:feat/x402-buyer-skill
Jun 29, 2026
Merged

feat: add x402 buyer payment support#16
chaitanyapotti merged 2 commits into
MetaMask:mainfrom
basgys:feat/x402-buyer-skill

Conversation

@basgys

@basgys basgys commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Context

AI agents increasingly run into HTTP 402 Payment Required responses when fetching APIs, data, or other resources that use the x402 protocol, where a service charges per request directly over HTTP. When an agent driving the MetaMask Agent CLI encounters one, it has had to assemble the whole payment by hand: parse the challenge, build an EIP-3009 authorization, sign it correctly, base64 the payload, and retry. Doing that step by step is fiddly and easy to get subtly wrong.

This change teaches the metamask-agent-wallet skill to act as an x402 buyer through a small helper script, scripts/x402_pay.py, in the same spirit as the existing amount_to_hex.py. The script performs the mechanical parts of the flow in one place, and signing is delegated to mm wallet sign-typed-data, so the private key never leaves the wallet. It is pure Python standard library, with no third party dependencies.

What it provides

The script has two phases. inspect <url> fetches the resource, reads the server's payment requirements, and prints them as JSON (asset, amount, network, payTo, resource) without signing or spending, so the payment can be reviewed first. pay <url> --confirm runs the payment for a single offered option: it builds the EIP-3009 transferWithAuthorization, signs it through mm, retries with the payment header, and reports the settlement transaction and the resource body. It refuses to run without --confirm, and when a server offers more than one option the caller selects with --asset or --network. The resource may use any HTTP method: pass --method and --data, and the same request is replayed with the payment attached, so a paid POST works the same as a GET.

Both protocol versions are handled, including their different payload envelopes: v1 keeps scheme/network at the top level, while v2 nests the chosen requirements under accepted and forwards the top-level resource. The currency and amount come from the server's offer and are confirmed by the user, and mm performs the signing, so which currencies and limits are acceptable stays with the wallet rather than being hardcoded in the skill. Network support and the asset's decimals are read from mm (mm chains list and mm token assets), with the raw amount shown when decimals cannot be resolved. Before signing, the script checks that the offer is structurally sound (scheme, supported network, positive integer amount, valid asset and recipient addresses, EIP-712 domain present, https URL) and does not follow redirects, so a payment cannot be diverted to another host. The settlement receipt is read from the response header, with a fallback to the body for servers that return it there.

The reference and workflow docs describe inspecting, confirming with the user, and paying, and the skill's safety and validation tables cover x402 payments.

Not yet supported

These are out of scope for this version. Each is detected and returns a clear, structured error rather than failing silently or paying incorrectly:

  • Permit2 asset transfer method (extra.assetTransferMethod: "permit2") and its gas-sponsoring extensions. EIP-3009 is preferred when a server offers both; a Permit2-only offer is reported as unsupported. This is the most likely follow-up, since it would extend payment to ERC-20s that do not implement EIP-3009.
  • Schemes other than exact (for example upto, used for metered billing).
  • Non-EVM networks (for example Solana), since signing goes through mm's EIP-712 path.
  • Offers that omit the EIP-712 domain name/version in extra. The script asks for a complete offer rather than guess the token domain.

Testing

  • Real deployed third-party endpoints on Base mainnet, discovered through the CDP x402 directory and settled on-chain through the CDP facilitator:
  • Against the official x402 SDK (x402-foundation, Python, v2.13.1) as an external oracle, in both directions: payloads the script builds are validated and round-tripped through the SDK's own PaymentPayload model and decoded with the SDK's header decoder, and 402 challenges and settlement receipts encoded by the SDK are fed back into the script's parser.
  • Against the x402 v2 specification: conformance tests assert the built payloads and challenge parsing field-for-field against the example vectors in the spec, so a future drift from the spec fails the tests.
  • On Base Sepolia for repeatable free testing, each version against the official x402 server SDK and a real facilitator (v1 x402-express via the x402.org facilitator including a paid POST with the body replayed; v2 @x402/express via the x402.rs facilitator). The reproduction steps below cover this.
  • Error paths were checked across the unsupported cases above plus a malformed challenge, a non-standard 402 from a real third-party endpoint, a cross-host redirect, an invalid amount or recipient, a plaintext URL, and an insufficient balance, each returning a clear error.

How to test on Base Sepolia

The script signs with the active mm wallet, so first authenticate (mm auth status) and fund that wallet with test USDC.

1. Top up from the faucet

Get the wallet address with mm wallet address, then open the Circle faucet at https://faucet.circle.com, select network Base Sepolia and token USDC, paste the address, and request funds. Confirm it arrived:

mm wallet balance --testnet-chain-id 84532 \
  --token-contracts 0x036CbD53842c5426634e7929541eC2318f3dCF7e

2. Run an x402 server using the official SDK

Replace 0xYourPayToAddress with a burner address (for example 0x000000000000000000000000000000000000dEaD) so the funds actually leave, or your own address to round-trip.

v1, with npm i express x402-express:

import express from "express";
import { paymentMiddleware } from "x402-express";

const app = express();
app.use(express.json());
app.use(
  paymentMiddleware(
    "0xYourPayToAddress",
    {
      "GET /weather": { price: "$0.001", network: "base-sepolia" },
      "POST /echo": { price: "$0.001", network: "base-sepolia" },
    },
    { url: "https://x402.org/facilitator" },
  ),
);

app.get("/weather", (_req, res) => res.json({ weather: "sunny", temp: 21 }));
app.post("/echo", (req, res) => res.json({ echoed: req.body }));
app.listen(4021);

v2, with npm i express @x402/express @x402/core @x402/evm:

import express from "express";
import { paymentMiddleware, x402ResourceServer } from "@x402/express";
import { ExactEvmScheme } from "@x402/evm/exact/server";
import { HTTPFacilitatorClient } from "@x402/core/server";

const facilitator = new HTTPFacilitatorClient({ url: "https://facilitator.x402.rs" });
const server = new x402ResourceServer(facilitator).register(
  "eip155:84532",
  new ExactEvmScheme(),
);

const app = express();
app.use(express.json());
app.use(
  paymentMiddleware(
    {
      "GET /weather": {
        accepts: {
          scheme: "exact",
          price: "$0.001",
          network: "eip155:84532",
          payTo: "0xYourPayToAddress",
          maxTimeoutSeconds: 60,
        },
        description: "Weather",
      },
    },
    server,
  ),
);

app.get("/weather", (_req, res) => res.json({ weather: "sunny", temp: 21 }));
app.listen(4023);

3. Pay with the skill

Inspect first (read-only), then pay:

# v1 (server on :4021)
python3 skills/metamask-agent-wallet/scripts/x402_pay.py inspect http://127.0.0.1:4021/weather
python3 skills/metamask-agent-wallet/scripts/x402_pay.py pay http://127.0.0.1:4021/weather --confirm
python3 skills/metamask-agent-wallet/scripts/x402_pay.py pay http://127.0.0.1:4021/echo \
  --method POST --data '{"q":"AI"}' --confirm

# v2 (server on :4023)
python3 skills/metamask-agent-wallet/scripts/x402_pay.py inspect http://127.0.0.1:4023/weather
python3 skills/metamask-agent-wallet/scripts/x402_pay.py pay http://127.0.0.1:4023/weather --confirm

Each pay prints "status": "settled" with a transaction hash and the resource body.

4. Confirm the payment on the explorer

Open the transaction on Base Sepolia Basescan. It is a transferWithAuthorization on the USDC contract that moves the amount from the wallet to payTo. Example settled transactions from these runs:

@basgys basgys force-pushed the feat/x402-buyer-skill branch 2 times, most recently from 9f6c424 to 17810f3 Compare June 17, 2026 15:16
@AyushBherwani1998 AyushBherwani1998 self-assigned this Jun 24, 2026
@basgys basgys force-pushed the feat/x402-buyer-skill branch from 05f9c24 to 79e4ab1 Compare June 24, 2026 11:52
@basgys basgys changed the title feat(metamask-agent-wallet): add x402 buyer payment support feat: add x402 buyer payment support Jun 24, 2026
@basgys basgys force-pushed the feat/x402-buyer-skill branch 2 times, most recently from 49e7c89 to fa61ccd Compare June 24, 2026 12:37
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.
@basgys basgys force-pushed the feat/x402-buyer-skill branch from fa61ccd to 21e1cf9 Compare June 24, 2026 13:11
@AyushBherwani1998

Copy link
Copy Markdown
Member

@basgys I tested again, but it didn't work. I had to modify the scripts the to make it run successfully. Most it was because of the wrong spec for the v2.

scripts/x402_pay.py line 401–403 — v2 PaymentPayload uses v1 structure
The script always builds:

  payment = {"x402Version": version, "scheme": option["scheme"],
             "network": option["network"],
             "payload": {"signature": signature, "authorization": authorization}}

For x402Version: 2, this is wrong. The
https://github.com/coinbase/x402/blob/main/specs/x402-specification-v2.md#52-paymentpayload-schema defines a completelly different top-level structure:

  {
    "x402Version": 2,
    "resource": { "url": "...", "description": "...", "mimeType": "..." },
    "accepted": {
      "scheme": "exact",
      "network": "eip155:8453",
      "amount": "1000",
      "asset": "0x...",
      "payTo": "0x...",
      "maxTimeoutSeconds": 300,
      "extra": { "name": "USD Coin", "version": "2" }
    },
    "payload": { "signature": "0x...", "authorization": { ... } }
  }

scheme and network move from the top level into accepted. The Coinbase CDP facilitator explicitly rejects payloads missing accepted with:

'paymentPayload' is invalid: must match one of [x402V2PaymentPayload, x402V1PaymentPayload].

x402V2PaymentPayload requires 'accepted'

The resource field must also be extracted from the top-level of the PAYMENT-REQUIRED response (where v2 puts it as a ResourceInfo object) and forwarded into the payload. parse_402 currently discards it.

Fix:

In parse_402, also return resource_info:

  resource_info = data.get("resource") if isinstance(data, dict) else None
  return version, options, resource_info

In cmd_pay, branch on version:

  if version == 2:
      payment = {
          "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},
      }
  else:
      payment = {"x402Version": 1, "scheme": option["scheme"],
                 "network": option["network"],
                 "payload": {"signature": signature, "authorization": authorization}}

scripts/x402_pay.py line 298 — validAfter: "0" rejected by some facilitators "validAfter": "0",

The Coinbase x402 JS client (https://github.com/coinbase/x402/blob/main/packages/typescript/x402/src/schemes/exact/evm/paymentUtils.ts) sets:
validAfter = BigInt(Math.floor(Date.now() / 1e3) - 600).toString() // 10 min ago

Using "0" means "valid from the Unix epoch" — technically legal per https://github.com/coinbase/x402/blob/main/specs/x402-specification-v2.md#61-exact-scheme-evm but some facilitator implementations reject it as anomalous. Use str(int(time.time()) - 600) to match the reference client.

scripts/x402_pay.py line 263–266 — Permit2 disambiguation crashes instead of auto-selecting

When a server offers both a standard EIP-3009 option and a Permit2 option on the same network and asset (common — e.g. Otto AI, ApiToll both do this), select() errors:
multiple eligible options; disambiguate with --asset/--network. Eligible: [...]

--asset and --network can't help because both options share the same values. The agent gets stuck. The Coinbase x402 JS client always prefers the non-Permit2 option. Fix by falling back to non-permit2 when the only difference is assetTransferMethod:

  if len(eligible) > 1:
      non_permit2 = [d for d in eligible
                     if d.get("extra", {}).get("assetTransferMethod") != "permit2"]
      if len(non_permit2) == 1:
          return non_permit2[0]
      raise CeremonyError("multiple eligible options; disambiguate with --asset/--network. ...")

references/x402.md — description of v2 is incomplete

The reference says: v2 (requirements in the PAYMENT-REQUIRED header, retry header PAYMENT-SIGNATURE)"

This correctly describes the request side, but omits the structural change to the PaymentPayload itself (the accepted + resource fields). An agent reading only the reference doc won't know the v2 payload differs from v1. Worth adding a sentence pointing to the x042 spec.

scripts/x402_pay.py line 360–365 — Settlement receipt only read from headers

name = "X-PAYMENT-RESPONSE" if version == 1 else "PAYMENT-RESPONSE"
raw = headers.get(name)

Per https://github.com/coinbase/x402/blob/main/specs/transports-v2/http.md, the settlement receipt goes in the
PAYMENT-RESPONSE header — but several real servers (confirmed: klymax402) include settlement data only in the response body.

If the header is absent, settlement() returns None and the transaction field in the output is silently null. Consider also parsing settle from the body when the header is missing.

workflows/x402-pay.md — Workflow doesn't mention the --network flag requirement

The workflow guides the agent through inspect → confirm → pay --confirm, but doesn't mention that if a server offers multiple networks (e.g. Base and Polygon), the agent must pass chain flag to avoid the disambiguation error.
Agents will hit this on any multi-network endpoint.

@basgys basgys force-pushed the feat/x402-buyer-skill branch from ac7a1c8 to ec9eece Compare June 25, 2026 15:18
@basgys

basgys commented Jun 25, 2026

Copy link
Copy Markdown
Contributor Author

@AyushBherwani1998 Thanks for the review, and sorry about the v2 issue. I pushed an update to correctly implement the v2 spec, and I tested against the official Python SDK.

@basgys basgys force-pushed the feat/x402-buyer-skill branch 2 times, most recently from d523e82 to dea5513 Compare June 25, 2026 16:35
…ed 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.
@basgys basgys force-pushed the feat/x402-buyer-skill branch from dea5513 to b76ee4e Compare June 26, 2026 11:13

@AyushBherwani1998 AyushBherwani1998 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, thanks @basgys

@chaitanyapotti chaitanyapotti merged commit 0d5c09a into MetaMask:main Jun 29, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants