feat: add x402 buyer payment support#16
Conversation
9f6c424 to
17810f3
Compare
05f9c24 to
79e4ab1
Compare
49e7c89 to
fa61ccd
Compare
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.
fa61ccd to
21e1cf9
Compare
|
@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 payment = {"x402Version": version, "scheme": option["scheme"],
"network": option["network"],
"payload": {"signature": signature, "authorization": authorization}}For x402Version: 2, this is wrong. The 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_infoIn 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: 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: --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: 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 Per https://github.com/coinbase/x402/blob/main/specs/transports-v2/http.md, the settlement receipt goes in the 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. |
ac7a1c8 to
ec9eece
Compare
|
@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. |
d523e82 to
dea5513
Compare
…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.
dea5513 to
b76ee4e
Compare
AyushBherwani1998
left a comment
There was a problem hiding this comment.
LGTM, thanks @basgys
Context
AI agents increasingly run into HTTP
402 Payment Requiredresponses 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-walletskill to act as an x402 buyer through a small helper script,scripts/x402_pay.py, in the same spirit as the existingamount_to_hex.py. The script performs the mechanical parts of the flow in one place, and signing is delegated tomm 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> --confirmruns the payment for a single offered option: it builds the EIP-3009transferWithAuthorization, signs it throughmm, 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--assetor--network. The resource may use any HTTP method: pass--methodand--data, and the same request is replayed with the payment attached, so a paidPOSTworks the same as aGET.Both protocol versions are handled, including their different payload envelopes: v1 keeps
scheme/networkat the top level, while v2 nests the chosen requirements underacceptedand forwards the top-levelresource. The currency and amount come from the server's offer and are confirmed by the user, andmmperforms 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 frommm(mm chains listandmm 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:
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.exact(for exampleupto, used for metered billing).mm's EIP-712 path.name/versioninextra. The script asks for a complete offer rather than guess the token domain.Testing
/chain/tokens/trending, 0.001 USDC) returned live data and settled. Tx: https://basescan.org/tx/0xfdb18a9bf3ba65674f910bf938bb8d0bbffe9403863e33387c765ed6024292d0/crypto-news, 0.001 USDC) returned the paid content plus an EIP-712 receipt and settled. Tx: https://basescan.org/tx/0x645b376c566cbc74f2f0b79ff631164ea2a699ec91e1e68a16b8cb1734d8adbfx402-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 ownPaymentPayloadmodel 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.x402-expressvia thex402.orgfacilitator including a paidPOSTwith the body replayed; v2@x402/expressvia thex402.rsfacilitator). The reproduction steps below cover this.How to test on Base Sepolia
The script signs with the active
mmwallet, 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:2. Run an x402 server using the official SDK
Replace
0xYourPayToAddresswith a burner address (for example0x000000000000000000000000000000000000dEaD) so the funds actually leave, or your own address to round-trip.v1, with
npm i express x402-express:v2, with
npm i express @x402/express @x402/core @x402/evm:3. Pay with the skill
Inspect first (read-only), then pay:
Each
payprints"status": "settled"with atransactionhash and the resource body.4. Confirm the payment on the explorer
Open the transaction on Base Sepolia Basescan. It is a
transferWithAuthorizationon the USDC contract that moves the amount from the wallet topayTo. Example settled transactions from these runs: